Workout "mark all complete" + recurring-task done confirmation notification

- Workout window: the header ⋯ menu gains "Mark All Complete" and "Clear All
  Sets" (Manage Workouts preserved). New WorkoutViewModel.setAllSetsDone(_:)
  ticks every set across all sections/exercises and runs the normal completion/
  logging path.
- Recurring tasks: completing an occurrence now posts a quiet "✓ Done. Repeats
  <tomorrow / in 1 week / …>" confirmation via NotificationManager
  .notifyRecurringCompleted, phrased from the next active occurrence. Hooked
  into TaskViewModel.toggleOccurrence; un-completing does nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-16 23:20:13 +03:00
parent e7e8d07100
commit bcb66ef691
4 changed files with 80 additions and 2 deletions

View File

@@ -168,6 +168,43 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
add(Self.periodIds[2], "This Year", "One more year passed.", year) 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) // MARK: - Compensation workout reminders (one-off, on chosen rest days)
private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) { private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) {

View File

@@ -766,6 +766,24 @@ class WorkoutViewModel: ObservableObject {
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() } 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) { func addSet(sectionId: UUID, to exerciseId: UUID) {
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }), guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }) let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })

View File

@@ -322,9 +322,15 @@ class TaskViewModel: ObservableObject {
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return } guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
let key = occurrenceKey(date) let key = occurrenceKey(date)
var set = Set(tasks[idx].completedOccurrences ?? []) 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) tasks[idx].completedOccurrences = Array(set)
save() 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 /// A display copy of a recurring task pinned to one occurrence date, with that

View File

@@ -105,7 +105,24 @@ struct WorkoutView: View {
Spacer() Spacer()
IButton(icon: "chart.bar.xaxis") { showStats = true } IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = 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) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)