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:
@@ -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]) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user