Add reminder notifications to schedules

- ScheduleDocument/Schedule gain optional reminderMinutes (minutes from
  midnight; decode-compatible, no schema bump)
- New Workout form gets a Reminder section (toggle + time picker, hidden
  for Now; footer warns when notifications are denied)
- ReminderPlanner (pure, tested) derives notification triggers: daily
  repeating, per-weekday repeating for fixed days, one-shot for once
- ReminderScheduler resyncs pending requests from the cache on every
  change, multiplexed onto onCacheChanged after the watch push; asks
  notification permission only once a reminder actually exists
This commit is contained in:
2026-07-11 12:32:08 -04:00
parent df07e0a043
commit 5ec5de9295
10 changed files with 440 additions and 3 deletions
@@ -0,0 +1,107 @@
//
// ReminderScheduler.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import SwiftData
import UserNotifications
import OSLog
/// Keeps pending local notifications in sync with the schedules' reminder times.
/// `reconcile()` is an idempotent full resync: it wipes every pending request with
/// the app's `reminder-` identifier prefix and re-adds from the current cache so
/// deleted schedules drop out naturally, and repeated calls are harmless. Trigger
/// math lives in the pure `ReminderPlanner`.
@MainActor
final class ReminderScheduler {
private let container: ModelContainer
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "reminders")
private var reconcileTask: Task<Void, Never>?
init(container: ModelContainer) {
self.container = container
}
/// Coalesced entry point for cache-change storms: cancels any not-yet-run
/// reconcile and schedules a fresh one after a short beat.
func scheduleReconcile() {
reconcileTask?.cancel()
reconcileTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await self?.reconcile()
}
}
/// Full resync of pending reminder notifications against the schedule cache.
func reconcile() async {
let plans = ReminderPlanner.plans(schedules: currentSources())
let center = UNUserNotificationCenter.current()
// Wipe-then-add: remove exactly our requests, then re-add the current set.
let pending = await center.pendingNotificationRequests()
let stale = pending.map(\.identifier).filter { $0.hasPrefix(ReminderPlanner.identifierPrefix) }
if !stale.isEmpty {
center.removePendingNotificationRequests(withIdentifiers: stale)
}
guard !plans.isEmpty else { return }
// Ask permission lazily only once there is actually something to remind
// about, so users who never set reminders never see the prompt.
guard await requestAuthorizationIfNeeded() else {
log.info("reconcile: notifications not authorized — \(plans.count) reminder(s) not scheduled")
return
}
for plan in plans {
let content = UNMutableNotificationContent()
content.title = plan.routineName
content.body = "Scheduled workout — time to train."
content.sound = .default
let trigger = UNCalendarNotificationTrigger(dateMatching: plan.dateComponents,
repeats: plan.repeats)
let request = UNNotificationRequest(identifier: plan.identifier,
content: content, trigger: trigger)
do {
try await center.add(request)
} catch {
log.error("reconcile: failed to add \(plan.identifier): \(error.localizedDescription)")
}
}
log.info("reconcile: scheduled \(plans.count) reminder request(s)")
}
/// Requests `.alert`/`.sound` authorization when it has never been asked for;
/// returns whether reminders can be delivered afterward.
func requestAuthorizationIfNeeded() async -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
case .authorized, .provisional, .ephemeral:
return true
case .denied:
return false
@unknown default:
return false
}
}
/// Snapshot the schedule cache into plain planner inputs.
private func currentSources() -> [ReminderSource] {
let schedules = (try? container.mainContext.fetch(FetchDescriptor<Schedule>())) ?? []
return schedules.map { schedule in
ReminderSource(id: schedule.id,
routineName: schedule.routineName,
recurrence: schedule.recurrenceEnum,
weekdays: schedule.weekdays,
date: schedule.date,
reminderMinutes: schedule.reminderMinutes)
}
}
}