diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index ea1d6d4..3e11648 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -168,6 +168,43 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable add(Self.periodIds[2], "This Year", "One more year passed.", year) } + // MARK: - Recurring task completion confirmation + + /// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's + /// occurrence is completed, telling the user when it next comes around. + func notifyRecurringCompleted(title: String, nextOccurrence: Date?) { + center.getNotificationSettings { [weak self] settings in + guard let self, + settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional else { return } + let content = UNMutableNotificationContent() + content.title = title + content.body = nextOccurrence.map { "✓ Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "✓ Done." + content.sound = nil // quiet confirmation, not an alert + self.center.add(UNNotificationRequest( + identifier: "kisani.recurdone.\(UUID().uuidString)", + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false))) + } + } + + /// "tomorrow", "in 1 week", "in 3 days" … relative to today. + static func relativeRepeat(to date: Date) -> String { + let cal = Calendar.current + let days = cal.dateComponents([.day], from: cal.startOfDay(for: Date()), + to: cal.startOfDay(for: date)).day ?? 0 + switch days { + case ..<1: return "again today" + case 1: return "tomorrow" + case 2...6: return "in \(days) days" + case 7: return "in 1 week" + default: + if days % 7 == 0 { let w = days / 7; return "in \(w) week\(w == 1 ? "" : "s")" } + if days % 30 == 0 { let m = days / 30; return "in \(m) month\(m == 1 ? "" : "s")" } + return "in \(days) days" + } + } + // MARK: - Compensation workout reminders (one-off, on chosen rest days) private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) { diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 0c130d4..151c9be 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -766,6 +766,24 @@ class WorkoutViewModel: ObservableObject { if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() } } + /// Mark every set of the active workout done/undone — the "Select all & mark + /// complete" quick action. Logs completion when everything is checked. + func setAllSetsDone(_ done: Bool) { + for si in activeSections.indices { + for ei in activeSections[si].exercises.indices { + for idx in activeSections[si].exercises[ei].sets.indices { + activeSections[si].exercises[ei].sets[idx].isDone = done + } + } + } + if done { + if sessionStartDate == nil { sessionStartDate = Date() } + UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay) + } + syncBack() + if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() } + } + func addSet(sectionId: UUID, to exerciseId: UUID) { guard let si = activeSections.firstIndex(where: { $0.id == sectionId }), let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }) diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index bd051c7..7807c53 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -322,9 +322,15 @@ class TaskViewModel: ObservableObject { guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return } let key = occurrenceKey(date) var set = Set(tasks[idx].completedOccurrences ?? []) - if set.contains(key) { set.remove(key) } else { set.insert(key) } + let nowDone: Bool + if set.contains(key) { set.remove(key); nowDone = false } else { set.insert(key); nowDone = true } tasks[idx].completedOccurrences = Array(set) save() + // Completing a recurring occurrence → "✓ Done. Repeats …" confirmation. + if nowDone { + let next = nextActiveOccurrence(tasks[idx], from: date) + NotificationManager.shared.notifyRecurringCompleted(title: tasks[idx].title, nextOccurrence: next) + } } /// A display copy of a recurring task pinned to one occurrence date, with that diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 51d09f6..6b36c87 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -105,7 +105,24 @@ struct WorkoutView: View { Spacer() IButton(icon: "chart.bar.xaxis") { showStats = true } IButton(icon: "clock.arrow.circlepath") { showHistory = true } - IButton(icon: "ellipsis") { showManage = true } + Menu { + Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: { + Label("Mark All Complete", systemImage: "checkmark.circle") + } + Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: { + Label("Clear All Sets", systemImage: "circle") + } + Divider() + Button { showManage = true } label: { Label("Manage Workouts", systemImage: "slider.horizontal.3") } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppColors.text2(cs)) + .frame(width: 34, height: 34) + .background(AppColors.surface2(cs)) + .clipShape(Circle()) + .overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1)) + } } .listRowBackground(Color.clear) .listRowSeparator(.hidden)