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