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
+15
View File
@@ -19,6 +19,10 @@ final class AppServices {
let workoutLauncher = WorkoutLauncher()
let workoutHealthDeleter = WorkoutHealthDeleter()
/// Keeps pending local notifications in sync with the schedules' reminder times
/// (an idempotent full resync on every cache change).
let reminderScheduler: ReminderScheduler
/// Speaks exercise instructions aloud (library Speak button + hands-free workout cues).
/// Shared so a new exercise's cue cancels whatever the last screen was still saying.
let speechAnnouncer = SpeechAnnouncer()
@@ -43,6 +47,7 @@ final class AppServices {
let liveRunState = LiveRunState()
self.liveRunState = liveRunState
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
self.reminderScheduler = ReminderScheduler(container: container)
// Launch the wrist session only when a run actually begins (the first
// exercise leaves `.notStarted` on the phone) creating a workout alone is
// just a peek and must not start anything on the watch. Watch-originated
@@ -70,6 +75,16 @@ final class AppServices {
await self.liveActivity.endStaleActivities()
await self.syncEngine.connect()
self.watchBridge.activate()
// `activate()` just claimed `onCacheChanged` for the watch push multiplex
// rather than replace, so reminder reconciliation rides the same signal.
let watchPush = self.syncEngine.onCacheChanged
self.syncEngine.onCacheChanged = { [weak self] in
watchPush?()
self?.reminderScheduler.scheduleReconcile()
}
// Initial post-bootstrap resync (covers changes that happened while
// the app wasn't running e.g. schedules edited on another device).
self.reminderScheduler.scheduleReconcile()
// Past the iCloud gate: request the workout-share scope so the user can still
// delete legacy phone-estimate workouts from Health when deleting them here.
self.workoutHealthDeleter.authorizeIfNeeded()
@@ -0,0 +1,93 @@
//
// ReminderPlanner.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// A plain snapshot of the schedule fields the reminder planner needs (precedent:
/// `ScheduleFacts`). Built from `Schedule` cache entities by the scheduler.
struct ReminderSource: Sendable, Equatable {
var id: String
var routineName: String
var recurrence: ScheduleRecurrence
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var date: Date? // the one-off day; .once only
var reminderMinutes: Int? // minutes from midnight, local wall-clock; nil = no reminder
}
/// One notification request to schedule: a stable identifier (all carrying the
/// `reminder-` prefix so a resync can wipe exactly ours), the trigger's date
/// components, and whether it repeats.
struct ReminderPlan: Sendable, Equatable {
var identifier: String
var routineName: String
var dateComponents: DateComponents
var repeats: Bool
}
/// Pure derivation of notification plans from schedule snapshots (precedent:
/// `SeedReconcilePlanner` / `ProgressPlanner`) the scheduler consumes its output,
/// so the trigger math is unit-testable without touching `UNUserNotificationCenter`.
enum ReminderPlanner {
/// Every reminder identifier starts with this, so a full resync can remove all
/// pending reminder requests (and nothing else) before re-adding.
static let identifierPrefix = "reminder-"
/// The plans for the given schedules. Schedules with no `reminderMinutes`
/// contribute nothing; a `.once` schedule whose reminder moment is already past
/// (or that has no date) contributes nothing.
static func plans(
schedules: [ReminderSource],
calendar: Calendar = .current,
now: Date = Date()
) -> [ReminderPlan] {
schedules.flatMap { plans(for: $0, calendar: calendar, now: now) }
}
private static func plans(
for schedule: ReminderSource,
calendar: Calendar,
now: Date
) -> [ReminderPlan] {
guard let minutes = schedule.reminderMinutes else { return [] }
let hour = minutes / 60
let minute = minutes % 60
switch schedule.recurrence {
case .daily:
var comps = DateComponents()
comps.hour = hour
comps.minute = minute
return [ReminderPlan(identifier: identifierPrefix + schedule.id,
routineName: schedule.routineName,
dateComponents: comps, repeats: true)]
case .fixedDays:
return (schedule.weekdays ?? [])
.filter { (1...7).contains($0) }
.sorted()
.map { weekday in
var comps = DateComponents()
comps.weekday = weekday
comps.hour = hour
comps.minute = minute
return ReminderPlan(identifier: "\(identifierPrefix)\(schedule.id)-\(weekday)",
routineName: schedule.routineName,
dateComponents: comps, repeats: true)
}
case .once:
guard let day = schedule.date else { return [] }
let startOfDay = calendar.startOfDay(for: day)
guard let fireDate = calendar.date(byAdding: .minute, value: minutes, to: startOfDay),
fireDate > now else { return [] }
let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate)
return [ReminderPlan(identifier: identifierPrefix + schedule.id,
routineName: schedule.routineName,
dateComponents: comps, repeats: false)]
}
}
}
@@ -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)
}
}
}
@@ -8,6 +8,7 @@
import IndieSync
import SwiftUI
import SwiftData
import UserNotifications
/// The UI-only "When" choice a superset of `ScheduleRecurrence` with one extra
/// option, `.now`, that never gets persisted: it skips the schedule document
@@ -68,6 +69,12 @@ struct ScheduleAddEditView: View {
@State private var when: WhenChoice
@State private var weekdays: Set<Int>
@State private var date: Date
@State private var reminderEnabled: Bool
@State private var reminderTime: Date
/// Notification permission has been explicitly denied surfaced as a footer so
/// the user knows reminders won't arrive until re-enabled in Settings. Checked
/// async on appear; never blocks the form.
@State private var notificationsDenied = false
/// "Now" starts a workout immediately instead of saving a schedule only offered
/// when adding (not editing) for today's default date, since a past/future day
@@ -95,6 +102,8 @@ struct ScheduleAddEditView: View {
_goal = State(initialValue: schedule?.goalKind)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_date = State(initialValue: schedule?.date ?? defaultDate)
_reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil)
_reminderTime = State(initialValue: Self.time(fromMinutes: schedule?.reminderMinutes))
self.onStartNow = onStartNow
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
@@ -131,6 +140,7 @@ struct ScheduleAddEditView: View {
routineSection
if when != .now {
goalSection
reminderSection
}
}
.navigationTitle(isEditing ? "Edit Workout" : "New Workout")
@@ -148,6 +158,11 @@ struct ScheduleAddEditView: View {
// Default to the first routine when adding (the @Query isn't populated at init).
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
}
.task {
// Surface a denied permission as a footer note informational only.
let settings = await UNUserNotificationCenter.current().notificationSettings()
notificationsDenied = settings.authorizationStatus == .denied
}
}
}
@@ -221,6 +236,21 @@ struct ScheduleAddEditView: View {
}
}
private var reminderSection: some View {
Section {
Toggle("Remind Me", isOn: $reminderEnabled)
if reminderEnabled {
DatePicker("Time", selection: $reminderTime, displayedComponents: .hourAndMinute)
}
} header: {
Text("Reminder")
} footer: {
if reminderEnabled && notificationsDenied {
Text("Notifications are turned off for Workouts. Reminders won't arrive until you enable them in Settings.")
}
}
}
/// A row of toggleable weekday chips, Monday-first.
private var weekdayPicker: some View {
HStack(spacing: 6) {
@@ -264,6 +294,7 @@ struct ScheduleAddEditView: View {
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
date: recurrence == .once ? date : nil,
reminderMinutes: reminderEnabled ? Self.minutesFromMidnight(of: reminderTime) : nil,
order: isEditing ? order : nextOrder(),
createdAt: createdAt,
updatedAt: Date()
@@ -277,6 +308,22 @@ struct ScheduleAddEditView: View {
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
return (existing.map(\.order).max() ?? -1) + 1
}
// MARK: - Reminder time conversion
/// A Date (today, local calendar) at the stored minutes-from-midnight, for the
/// hour-and-minute picker. Nil (no reminder yet) seeds a sensible 8:00 AM default.
private static func time(fromMinutes minutes: Int?) -> Date {
let startOfDay = Calendar.current.startOfDay(for: Date())
return Calendar.current.date(byAdding: .minute, value: minutes ?? 8 * 60, to: startOfDay)
?? startOfDay
}
/// The picked time back to minutes from midnight (local wall-clock).
private static func minutesFromMidnight(of time: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: time)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
}
// MARK: - Routine picker