From 228607fde6ded588912909e4195b312f748f7f6e Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 03:30:00 +0300 Subject: [PATCH] =?UTF-8?q?Add=20rest=20timer=20with=20toggle=20=E2=80=94?= =?UTF-8?q?=20auto-starts=20after=20each=20set=20when=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floating banner shows circular progress + MM:SS countdown with skip button. Haptic + system sound fires when timer reaches zero. Duration and on/off toggle live in Settings → Rest Timer sheet. Co-Authored-By: Kutesir --- KisaniCal/Models/ExerciseModels.swift | 17 ++ KisaniCal/Views/SettingsView.swift | 35 +++- KisaniCal/Views/WorkoutView.swift | 225 ++++++++++++++++++++++---- 3 files changed, 243 insertions(+), 34 deletions(-) diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 1bc6ae1..7a4d00c 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -178,6 +178,9 @@ class WorkoutViewModel: ObservableObject { @Published var activeSections: [WorkoutSection] @Published var workoutDates: [String] = [] @Published var restTimerSeconds: Int = 60 + @Published var restTimerEnabled: Bool = false + @Published var restCountdown: Int = 0 + @Published var restTimerTotal: Int = 0 @Published var sessionStartDate: Date? = nil var streakDays: Int { @@ -404,12 +407,14 @@ class WorkoutViewModel: ObservableObject { let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }), let setIdx = activeSections[si].exercises[ei].sets.firstIndex(where: { $0.id == setId }) else { return } + let wasMarkedDone = !activeSections[si].exercises[ei].sets[setIdx].isDone activeSections[si].exercises[ei].sets[setIdx].isDone.toggle() if sessionStartDate == nil, activeSections[si].exercises[ei].sets[setIdx].isDone { sessionStartDate = Date() } syncBack() if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() } + if wasMarkedDone && restTimerEnabled { startRestTimer() } } func addSet(sectionId: UUID, to exerciseId: UUID) { @@ -495,6 +500,18 @@ class WorkoutViewModel: ObservableObject { syncBack() } + // MARK: - Rest Timer + + func startRestTimer() { + restTimerTotal = restTimerSeconds + restCountdown = restTimerSeconds + } + + func stopRestTimer() { + restCountdown = 0 + restTimerTotal = 0 + } + private func syncBack() { guard let idx = programs.firstIndex(where: { $0.id == activeProgramId }) else { return } programs[idx].sections = activeSections diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index cc8a17b..c6c75da 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -39,7 +39,7 @@ struct SettingsView: View { @State private var showAbout = false private var appearanceLabel: String { ["System", "Light", "Dark"][appearanceMode] } - private var restLabel: String { "\(workoutVM.restTimerSeconds)s" } + private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" } private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" } private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] } @@ -166,7 +166,7 @@ struct SettingsView: View { .sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).presentationDetents([.large]).presentationDragIndicator(.visible) } - .sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds).presentationDetents([.height(340)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) } @@ -825,22 +825,47 @@ private struct StepperRow: View { // MARK: - Rest Timer Sheet private struct RestTimerSheet: View { @Binding var seconds: Int + @Binding var enabled: Bool @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs private let options = [30, 45, 60, 90, 120, 180] var body: some View { VStack(spacing: 0) { SheetHeader(title: "Rest Timer") { dismiss() } - Text("\(seconds)s").font(AppFonts.mono(52, weight: .bold)).foregroundColor(AppColors.accent).padding(.bottom, 20) + + // Toggle + HStack { + Text("Auto-start after each set") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $enabled) + .labelsHidden() + .tint(AppColors.accent) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + + // Duration picker (dimmed when disabled) + Text(seconds >= 60 ? String(format: "%d:%02d", seconds / 60, seconds % 60) : "\(seconds)s") + .font(AppFonts.mono(52, weight: .bold)) + .foregroundColor(AppColors.accent) + .opacity(enabled ? 1 : 0.35) + .padding(.bottom, 20) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) { ForEach(options, id: \.self) { opt in Button { withAnimation(KisaniSpring.micro) { seconds = opt } } label: { - Text("\(opt)s").font(AppFonts.mono(14, weight: .bold)) + Text(opt >= 60 ? String(format: "%d:%02d", opt / 60, opt % 60) : "\(opt)s") + .font(AppFonts.mono(14, weight: .bold)) .foregroundColor(seconds == opt ? AppColors.accent : AppColors.text2(cs)) .frame(maxWidth: .infinity).padding(.vertical, 13) .background(seconds == opt ? AppColors.accentSoft : AppColors.surface2(cs)) .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(seconds == opt ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) - }.buttonStyle(.plain) + } + .buttonStyle(.plain) + .opacity(enabled ? 1 : 0.35) + .disabled(!enabled) } } .padding(.horizontal, 20) diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 0cc0958..c58e090 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -1,4 +1,59 @@ import SwiftUI +import AudioToolbox + +// MARK: - Rest Timer Banner +private struct RestTimerBanner: View { + let countdown: Int + let total: Int + let onSkip: () -> Void + @Environment(\.colorScheme) private var cs + + private var progress: Double { total > 0 ? Double(countdown) / Double(total) : 0 } + + private var timeLabel: String { + countdown >= 60 + ? String(format: "%d:%02d", countdown / 60, countdown % 60) + : "\(countdown)s" + } + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .stroke(AppColors.accent.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: progress) + .stroke(AppColors.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: progress) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 1) { + Text("REST").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + Text(timeLabel).font(AppFonts.mono(22, weight: .bold)).foregroundColor(AppColors.accent) + } + + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(AppColors.accentSoft) + .clipShape(Capsule()) + } + .buttonStyle(PressButtonStyle(scale: 0.94)) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).stroke(AppColors.accent.opacity(0.2), lineWidth: 1)) + .padding(.horizontal, 16) + } +} // MARK: - Workout View struct WorkoutView: View { @@ -6,6 +61,8 @@ struct WorkoutView: View { @EnvironmentObject var vm: WorkoutViewModel @State private var showManage = false @State private var showAddSection = false + @State private var isReordering = false + private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private var headerDate: String { let f = DateFormatter(); f.dateFormat = "EEE, MMM d" @@ -16,12 +73,20 @@ struct WorkoutView: View { ZStack(alignment: .bottomTrailing) { AppColors.background(cs).ignoresSafeArea() + if isReordering { + Color.black.opacity(0.001) + .ignoresSafeArea() + .onTapGesture { + withAnimation(KisaniSpring.snappy) { isReordering = false } + } + } + List { // ── Header ── HStack { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { - Text(vm.workoutEmoji).font(.system(size: 15)) + ProgramIcon(name: vm.workoutEmoji, size: 14, color: AppColors.accent) Text(vm.workoutTitle) .font(AppFonts.sans(15, weight: .semibold)) .foregroundColor(AppColors.text(cs)) @@ -73,6 +138,10 @@ struct WorkoutView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14)) + .onLongPressGesture(minimumDuration: 0.4) { + withAnimation(KisaniSpring.snappy) { isReordering = true } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } .contextMenu { Button(role: .destructive) { withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) } @@ -135,7 +204,7 @@ struct WorkoutView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .environment(\.editMode, .constant(.active)) + .environment(\.editMode, .constant(isReordering ? .active : .inactive)) .scrollDismissesKeyboard(.immediately) .toolbar { ToolbarItemGroup(placement: .keyboard) { @@ -151,6 +220,29 @@ struct WorkoutView: View { } } } + .overlay(alignment: .top) { + if isReordering { + Button { + withAnimation(KisaniSpring.snappy) { isReordering = false } + } label: { + HStack(spacing: 6) { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + Text("Done Reordering") + .font(AppFonts.sans(13, weight: .semibold)) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(AppColors.accent) + .clipShape(Capsule()) + .shadow(color: AppColors.accent.opacity(0.35), radius: 8, y: 4) + } + .buttonStyle(PressButtonStyle(scale: 0.96)) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .sheet(isPresented: $vm.showAddExercise) { if let sid = vm.addExerciseTargetSectionId { ExercisePickerSheet(sectionId: sid) @@ -169,6 +261,46 @@ struct WorkoutView: View { .presentationDetents([.height(240)]) .presentationDragIndicator(.visible) } + .safeAreaInset(edge: .bottom, spacing: 0) { + if vm.restCountdown > 0 { + RestTimerBanner( + countdown: vm.restCountdown, + total: vm.restTimerTotal, + onSkip: { vm.stopRestTimer() } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 70) + } + } + .onReceive(restTick) { _ in + guard vm.restCountdown > 0 else { return } + vm.restCountdown -= 1 + if vm.restCountdown == 0 { + UINotificationFeedbackGenerator().notificationOccurred(.success) + AudioServicesPlaySystemSound(1007) + } + } + .animation(KisaniSpring.snappy, value: vm.restCountdown > 0) + } +} + +// MARK: - Program Icon +// Renders SF Symbol names (contain ".") or legacy emoji strings. +struct ProgramIcon: View { + let name: String + var size: CGFloat = 18 + var color: Color + + private var isSFSymbol: Bool { name.contains(".") } + + var body: some View { + if isSFSymbol { + Image(systemName: name) + .font(.system(size: size, weight: .medium)) + .foregroundColor(color) + } else { + Text(name).font(.system(size: size)) + } } } @@ -787,10 +919,11 @@ private struct ProgramRow: View { // Tappable body → program detail Button { showDetail = true } label: { HStack(spacing: 12) { - Text(program.emoji).font(.system(size: 22)) - .frame(width: 40, height: 40) - .background(AppColors.surface2(cs)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + ZStack { + RoundedRectangle(cornerRadius: 10).fill(AppColors.accentSoft) + ProgramIcon(name: program.emoji, size: 20, color: AppColors.accent) + } + .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 2) { Text(program.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) @@ -865,7 +998,7 @@ struct ProgramDetailSheet: View { // Header HStack(spacing: 10) { if let p = program { - Text(p.emoji).font(.system(size: 20)) + ProgramIcon(name: p.emoji, size: 18, color: AppColors.accent) Text(p.name).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) } Spacer() @@ -1099,9 +1232,11 @@ private struct DayScheduleCell: View { .fill(assignedProgram != nil ? AppColors.accentSoft : AppColors.surface2(cs)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(isToday ? AppColors.accent : AppColors.border(cs), lineWidth: isToday ? 1.5 : 1)) if let prog = assignedProgram { - Text(prog.emoji).font(.system(size: 16)) + ProgramIcon(name: prog.emoji, size: 17, color: AppColors.accent) } else { - Text("─").font(.system(size: 14)).foregroundColor(AppColors.text3(cs)) + Image(systemName: "minus") + .font(.system(size: 12)) + .foregroundColor(AppColors.text3(cs)) } } .frame(height: 44) @@ -1114,7 +1249,7 @@ private struct DayScheduleCell: View { .buttonStyle(.plain) .confirmationDialog("Assign \(day)", isPresented: $showPicker, titleVisibility: .visible) { ForEach(vm.programs) { prog in - Button("\(prog.emoji) \(prog.name)") { vm.setSchedule(weekday: weekdayNum, programId: prog.id) } + Button(prog.name) { vm.setSchedule(weekday: weekdayNum, programId: prog.id) } } Button("Rest Day", role: .destructive) { vm.setSchedule(weekday: weekdayNum, programId: nil) } Button("Cancel", role: .cancel) {} @@ -1131,7 +1266,16 @@ struct RenameProgramSheet: View { @State private var name: String @State private var selectedEmoji: String @FocusState private var nameFocused: Bool - private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🫀"] + private let iconSymbols = [ + "figure.strengthtraining.traditional", "figure.strengthtraining.functional", + "figure.run", "figure.highintensity.intervaltraining", + "figure.yoga", "figure.cycling", + "figure.boxing", "figure.core.training", + "figure.cooldown", "figure.arms.open", + "dumbbell.fill", "heart.fill", + "bolt.fill", "figure.walk", + "figure.jumprope", "trophy.fill" + ] init(program: WorkoutProgram) { self.program = program @@ -1162,14 +1306,21 @@ struct RenameProgramSheet: View { } VStack(alignment: .leading, spacing: 7) { Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) { - ForEach(emojis, id: \.self) { em in - Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: { - Text(em).font(.system(size: 22)) - .frame(maxWidth: .infinity).frame(height: 44) - .background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1)) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { + ForEach(iconSymbols, id: \.self) { sym in + Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke( + selectedEmoji == sym ? AppColors.accent : AppColors.border(cs), + lineWidth: selectedEmoji == sym ? 1.5 : 1 + )) + Image(systemName: sym) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs)) + } + .frame(height: 52) } .buttonStyle(.plain) } @@ -1203,9 +1354,18 @@ struct AddProgramSheet: View { @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss @State private var name = "" - @State private var selectedEmoji = "💪" + @State private var selectedEmoji = "figure.strengthtraining.traditional" @FocusState private var nameFocused: Bool - private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🫀"] + private let iconSymbols = [ + "figure.strengthtraining.traditional", "figure.strengthtraining.functional", + "figure.run", "figure.highintensity.intervaltraining", + "figure.yoga", "figure.cycling", + "figure.boxing", "figure.core.training", + "figure.cooldown", "figure.arms.open", + "dumbbell.fill", "heart.fill", + "bolt.fill", "figure.walk", + "figure.jumprope", "trophy.fill" + ] var body: some View { VStack(spacing: 0) { @@ -1230,14 +1390,21 @@ struct AddProgramSheet: View { } VStack(alignment: .leading, spacing: 7) { Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) { - ForEach(emojis, id: \.self) { em in - Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: { - Text(em).font(.system(size: 22)) - .frame(maxWidth: .infinity).frame(height: 44) - .background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1)) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { + ForEach(iconSymbols, id: \.self) { sym in + Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke( + selectedEmoji == sym ? AppColors.accent : AppColors.border(cs), + lineWidth: selectedEmoji == sym ? 1.5 : 1 + )) + Image(systemName: sym) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs)) + } + .frame(height: 52) } .buttonStyle(.plain) }