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:
@@ -1,5 +1,7 @@
|
|||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
|
Scheduled workouts can now remind you with a notification at a time you choose.
|
||||||
|
|
||||||
The Today board now shows just the day's docket — only schedules actually due on the viewed day appear.
|
The Today board now shows just the day's docket — only schedules actually due on the viewed day appear.
|
||||||
|
|
||||||
Adding from the Today board is now New Workout, with a Now option that starts a workout immediately instead of creating a schedule.
|
Adding from the Today board is now New Workout, with a Now option that starts a workout immediately instead of creating a schedule.
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable {
|
|||||||
var recurrence: String // ScheduleRecurrence raw value
|
var recurrence: String // ScheduleRecurrence raw value
|
||||||
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only
|
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only
|
||||||
var date: Date? = nil // the one-off day; .once only
|
var date: Date? = nil // the one-off day; .once only
|
||||||
|
/// Reminder time as minutes from midnight (local wall-clock, 0–1439); nil = no
|
||||||
|
/// reminder. Optional addition — decode-compatible, deliberately NOT schema-bumped
|
||||||
|
/// (an older app dropping it on rewrite loses only a notification preference).
|
||||||
|
var reminderMinutes: Int? = nil
|
||||||
var order: Int
|
var order: Int
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
|||||||
@@ -318,13 +318,15 @@ final class Schedule {
|
|||||||
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
|
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
|
||||||
var weekdays: [Int]?
|
var weekdays: [Int]?
|
||||||
var date: Date?
|
var date: Date?
|
||||||
|
/// Reminder time as minutes from midnight (local wall-clock); nil = no reminder.
|
||||||
|
var reminderMinutes: Int?
|
||||||
var order: Int = 0
|
var order: Int = 0
|
||||||
var createdAt: Date = Date()
|
var createdAt: Date = Date()
|
||||||
var updatedAt: Date = Date()
|
var updatedAt: Date = Date()
|
||||||
var jsonRelativePath: String = ""
|
var jsonRelativePath: String = ""
|
||||||
|
|
||||||
init(id: String, routineID: String, routineName: String, goalRaw: String?,
|
init(id: String, routineID: String, routineName: String, goalRaw: String?,
|
||||||
recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil,
|
recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, reminderMinutes: Int? = nil,
|
||||||
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.routineID = routineID
|
self.routineID = routineID
|
||||||
@@ -333,6 +335,7 @@ final class Schedule {
|
|||||||
self.recurrenceRaw = recurrenceRaw
|
self.recurrenceRaw = recurrenceRaw
|
||||||
self.weekdays = weekdays
|
self.weekdays = weekdays
|
||||||
self.date = date
|
self.date = date
|
||||||
|
self.reminderMinutes = reminderMinutes
|
||||||
self.order = order
|
self.order = order
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ extension ScheduleDocument {
|
|||||||
routineID: schedule.routineID, routineName: schedule.routineName,
|
routineID: schedule.routineID, routineName: schedule.routineName,
|
||||||
goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
|
goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
|
||||||
weekdays: schedule.weekdays,
|
weekdays: schedule.weekdays,
|
||||||
date: schedule.date,
|
date: schedule.date, reminderMinutes: schedule.reminderMinutes,
|
||||||
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
|
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ enum CacheMapper {
|
|||||||
} else {
|
} else {
|
||||||
schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
||||||
goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays,
|
goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays,
|
||||||
date: doc.date, order: doc.order,
|
date: doc.date, reminderMinutes: doc.reminderMinutes, order: doc.order,
|
||||||
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||||
jsonRelativePath: relativePath)
|
jsonRelativePath: relativePath)
|
||||||
context.insert(schedule)
|
context.insert(schedule)
|
||||||
@@ -266,6 +266,7 @@ enum CacheMapper {
|
|||||||
schedule.recurrenceRaw = doc.recurrence
|
schedule.recurrenceRaw = doc.recurrence
|
||||||
schedule.weekdays = doc.weekdays
|
schedule.weekdays = doc.weekdays
|
||||||
schedule.date = doc.date
|
schedule.date = doc.date
|
||||||
|
schedule.reminderMinutes = doc.reminderMinutes
|
||||||
schedule.order = doc.order
|
schedule.order = doc.order
|
||||||
schedule.createdAt = doc.createdAt
|
schedule.createdAt = doc.createdAt
|
||||||
schedule.updatedAt = doc.updatedAt
|
schedule.updatedAt = doc.updatedAt
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ final class AppServices {
|
|||||||
let workoutLauncher = WorkoutLauncher()
|
let workoutLauncher = WorkoutLauncher()
|
||||||
let workoutHealthDeleter = WorkoutHealthDeleter()
|
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).
|
/// 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.
|
/// Shared so a new exercise's cue cancels whatever the last screen was still saying.
|
||||||
let speechAnnouncer = SpeechAnnouncer()
|
let speechAnnouncer = SpeechAnnouncer()
|
||||||
@@ -43,6 +47,7 @@ final class AppServices {
|
|||||||
let liveRunState = LiveRunState()
|
let liveRunState = LiveRunState()
|
||||||
self.liveRunState = liveRunState
|
self.liveRunState = liveRunState
|
||||||
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, 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
|
// Launch the wrist session only when a run actually begins (the first
|
||||||
// exercise leaves `.notStarted` on the phone) — creating a workout alone is
|
// exercise leaves `.notStarted` on the phone) — creating a workout alone is
|
||||||
// just a peek and must not start anything on the watch. Watch-originated
|
// 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.liveActivity.endStaleActivities()
|
||||||
await self.syncEngine.connect()
|
await self.syncEngine.connect()
|
||||||
self.watchBridge.activate()
|
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
|
// 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.
|
// delete legacy phone-estimate workouts from Health when deleting them here.
|
||||||
self.workoutHealthDeleter.authorizeIfNeeded()
|
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 IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
/// The UI-only "When" choice — a superset of `ScheduleRecurrence` with one extra
|
/// The UI-only "When" choice — a superset of `ScheduleRecurrence` with one extra
|
||||||
/// option, `.now`, that never gets persisted: it skips the schedule document
|
/// 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 when: WhenChoice
|
||||||
@State private var weekdays: Set<Int>
|
@State private var weekdays: Set<Int>
|
||||||
@State private var date: Date
|
@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
|
/// "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
|
/// 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)
|
_goal = State(initialValue: schedule?.goalKind)
|
||||||
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
|
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
|
||||||
_date = State(initialValue: schedule?.date ?? defaultDate)
|
_date = State(initialValue: schedule?.date ?? defaultDate)
|
||||||
|
_reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil)
|
||||||
|
_reminderTime = State(initialValue: Self.time(fromMinutes: schedule?.reminderMinutes))
|
||||||
self.onStartNow = onStartNow
|
self.onStartNow = onStartNow
|
||||||
|
|
||||||
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
|
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
|
||||||
@@ -131,6 +140,7 @@ struct ScheduleAddEditView: View {
|
|||||||
routineSection
|
routineSection
|
||||||
if when != .now {
|
if when != .now {
|
||||||
goalSection
|
goalSection
|
||||||
|
reminderSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(isEditing ? "Edit Workout" : "New Workout")
|
.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).
|
// Default to the first routine when adding (the @Query isn't populated at init).
|
||||||
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
|
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.
|
/// A row of toggleable weekday chips, Monday-first.
|
||||||
private var weekdayPicker: some View {
|
private var weekdayPicker: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -264,6 +294,7 @@ struct ScheduleAddEditView: View {
|
|||||||
recurrence: recurrence.rawValue,
|
recurrence: recurrence.rawValue,
|
||||||
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
|
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
|
||||||
date: recurrence == .once ? date : nil,
|
date: recurrence == .once ? date : nil,
|
||||||
|
reminderMinutes: reminderEnabled ? Self.minutesFromMidnight(of: reminderTime) : nil,
|
||||||
order: isEditing ? order : nextOrder(),
|
order: isEditing ? order : nextOrder(),
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
@@ -277,6 +308,22 @@ struct ScheduleAddEditView: View {
|
|||||||
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
|
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
|
||||||
return (existing.map(\.order).max() ?? -1) + 1
|
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
|
// MARK: - Routine picker
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Workouts
|
||||||
|
|
||||||
|
/// Locks `ReminderPlanner`'s pure trigger derivation — schedule snapshots in,
|
||||||
|
/// (identifier, DateComponents, repeats) plans out. `UNUserNotificationCenter`
|
||||||
|
/// itself is deliberately untested; the scheduler is a thin adapter over these plans.
|
||||||
|
struct ReminderPlannerTests {
|
||||||
|
|
||||||
|
/// UTC Gregorian so nothing depends on the host machine's timezone.
|
||||||
|
private let cal: Calendar = {
|
||||||
|
var cal = Calendar(identifier: .gregorian)
|
||||||
|
cal.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
return cal
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Midnight UTC on an ISO `yyyy-MM-dd` date.
|
||||||
|
private func day(_ iso: String) -> Date {
|
||||||
|
let parts = iso.split(separator: "-").compactMap { Int($0) }
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = parts[0]; components.month = parts[1]; components.day = parts[2]
|
||||||
|
return cal.date(from: components)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func source(
|
||||||
|
id: String = "01SCHEDULE0000000000000001",
|
||||||
|
routineName: String = "Push Day",
|
||||||
|
recurrence: ScheduleRecurrence,
|
||||||
|
weekdays: [Int]? = nil,
|
||||||
|
date: Date? = nil,
|
||||||
|
reminderMinutes: Int?
|
||||||
|
) -> ReminderSource {
|
||||||
|
ReminderSource(id: id, routineName: routineName, recurrence: recurrence,
|
||||||
|
weekdays: weekdays, date: date, reminderMinutes: reminderMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily
|
||||||
|
|
||||||
|
@Test func dailyProducesOneRepeatingHourMinuteTrigger() throws {
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .daily, reminderMinutes: 7 * 60 + 30)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
|
||||||
|
let plan = try #require(plans.first)
|
||||||
|
#expect(plans.count == 1)
|
||||||
|
#expect(plan.identifier == "reminder-01SCHEDULE0000000000000001")
|
||||||
|
#expect(plan.routineName == "Push Day")
|
||||||
|
#expect(plan.repeats)
|
||||||
|
#expect(plan.dateComponents.hour == 7)
|
||||||
|
#expect(plan.dateComponents.minute == 30)
|
||||||
|
// Only hour+minute — no weekday/day, so it fires every day.
|
||||||
|
#expect(plan.dateComponents.weekday == nil)
|
||||||
|
#expect(plan.dateComponents.day == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fixed days
|
||||||
|
|
||||||
|
@Test func fixedDaysProducesOneRepeatingTriggerPerWeekdaySorted() {
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .fixedDays, weekdays: [5, 2], reminderMinutes: 18 * 60)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
|
||||||
|
#expect(plans.count == 2)
|
||||||
|
#expect(plans.map(\.identifier) == [
|
||||||
|
"reminder-01SCHEDULE0000000000000001-2",
|
||||||
|
"reminder-01SCHEDULE0000000000000001-5",
|
||||||
|
])
|
||||||
|
#expect(plans.allSatisfy { $0.repeats })
|
||||||
|
#expect(plans.map(\.dateComponents.weekday) == [2, 5])
|
||||||
|
#expect(plans.allSatisfy { $0.dateComponents.hour == 18 && $0.dateComponents.minute == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fixedDaysIgnoresOutOfRangeWeekdaysAndEmptyListYieldsNothing() {
|
||||||
|
let outOfRange = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .fixedDays, weekdays: [0, 3, 9], reminderMinutes: 60)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
#expect(outOfRange.map(\.dateComponents.weekday) == [3])
|
||||||
|
|
||||||
|
let empty = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .fixedDays, weekdays: [], reminderMinutes: 60)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
#expect(empty.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Once
|
||||||
|
|
||||||
|
@Test func onceFutureProducesOneNonRepeatingFullDateTrigger() throws {
|
||||||
|
// Reminder at 06:15 on 2026-07-20; "now" is well before it.
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .once, date: day("2026-07-20"), reminderMinutes: 6 * 60 + 15)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
|
||||||
|
let plan = try #require(plans.first)
|
||||||
|
#expect(plans.count == 1)
|
||||||
|
#expect(!plan.repeats)
|
||||||
|
#expect(plan.dateComponents.year == 2026)
|
||||||
|
#expect(plan.dateComponents.month == 7)
|
||||||
|
#expect(plan.dateComponents.day == 20)
|
||||||
|
#expect(plan.dateComponents.hour == 6)
|
||||||
|
#expect(plan.dateComponents.minute == 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func oncePastMomentProducesNothing() {
|
||||||
|
let reminderDay = day("2026-07-11")
|
||||||
|
|
||||||
|
// The whole day is past.
|
||||||
|
let dayPast = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)],
|
||||||
|
calendar: cal, now: day("2026-07-12"))
|
||||||
|
#expect(dayPast.isEmpty)
|
||||||
|
|
||||||
|
// Same day, but the reminder minute (08:00) has already passed by noon.
|
||||||
|
let minutePast = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)],
|
||||||
|
calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!)
|
||||||
|
#expect(minutePast.isEmpty)
|
||||||
|
|
||||||
|
// Same day, reminder still ahead (20:00 vs noon) — scheduled.
|
||||||
|
let stillAhead = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 20 * 60)],
|
||||||
|
calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!)
|
||||||
|
#expect(stillAhead.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func onceWithoutDateProducesNothing() {
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [source(recurrence: .once, date: nil, reminderMinutes: 8 * 60)],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
#expect(plans.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - No reminder
|
||||||
|
|
||||||
|
@Test func nilReminderMinutesContributesNothingAcrossAllRecurrences() {
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [
|
||||||
|
source(id: "A", recurrence: .daily, reminderMinutes: nil),
|
||||||
|
source(id: "B", recurrence: .fixedDays, weekdays: [2, 4], reminderMinutes: nil),
|
||||||
|
source(id: "C", recurrence: .once, date: day("2026-08-01"), reminderMinutes: nil),
|
||||||
|
],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
#expect(plans.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mixed batch
|
||||||
|
|
||||||
|
@Test func mixedSchedulesFlattenInInputOrderWithPrefixOnEveryIdentifier() {
|
||||||
|
let plans = ReminderPlanner.plans(
|
||||||
|
schedules: [
|
||||||
|
source(id: "A", recurrence: .daily, reminderMinutes: 6 * 60),
|
||||||
|
source(id: "B", recurrence: .fixedDays, weekdays: [2, 6], reminderMinutes: 17 * 60),
|
||||||
|
source(id: "C", recurrence: .daily, reminderMinutes: nil),
|
||||||
|
],
|
||||||
|
calendar: cal, now: day("2026-07-11"))
|
||||||
|
|
||||||
|
#expect(plans.map(\.identifier) == ["reminder-A", "reminder-B-2", "reminder-B-6"])
|
||||||
|
#expect(plans.allSatisfy { $0.identifier.hasPrefix(ReminderPlanner.identifierPrefix) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ struct ScheduleDocumentTests {
|
|||||||
goal: GoalKind.strength.rawValue,
|
goal: GoalKind.strength.rawValue,
|
||||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||||
weekdays: [2, 5],
|
weekdays: [2, 5],
|
||||||
|
reminderMinutes: 7 * 60 + 30,
|
||||||
order: 3,
|
order: 3,
|
||||||
createdAt: Self.created,
|
createdAt: Self.created,
|
||||||
updatedAt: Self.updated
|
updatedAt: Self.updated
|
||||||
@@ -45,6 +46,7 @@ struct ScheduleDocumentTests {
|
|||||||
#expect(decoded.isReadable)
|
#expect(decoded.isReadable)
|
||||||
#expect(decoded.goalKind == .strength)
|
#expect(decoded.goalKind == .strength)
|
||||||
#expect(decoded.recurrenceEnum == .fixedDays)
|
#expect(decoded.recurrenceEnum == .fixedDays)
|
||||||
|
#expect(decoded.reminderMinutes == 450)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
|
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
|
||||||
@@ -70,6 +72,7 @@ struct ScheduleDocumentTests {
|
|||||||
#expect(!json.contains("goal"))
|
#expect(!json.contains("goal"))
|
||||||
#expect(!json.contains("weekdays"))
|
#expect(!json.contains("weekdays"))
|
||||||
#expect(!json.contains("\"date\""))
|
#expect(!json.contains("\"date\""))
|
||||||
|
#expect(!json.contains("reminderMinutes"))
|
||||||
|
|
||||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
||||||
#expect(decoded == original)
|
#expect(decoded == original)
|
||||||
@@ -77,6 +80,7 @@ struct ScheduleDocumentTests {
|
|||||||
#expect(decoded.goalKind == nil)
|
#expect(decoded.goalKind == nil)
|
||||||
#expect(decoded.weekdays == nil)
|
#expect(decoded.weekdays == nil)
|
||||||
#expect(decoded.date == nil)
|
#expect(decoded.date == nil)
|
||||||
|
#expect(decoded.reminderMinutes == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `.once` schedule carries its one-off day and drops the recurring-only fields.
|
/// A `.once` schedule carries its one-off day and drops the recurring-only fields.
|
||||||
@@ -179,6 +183,7 @@ struct ScheduleDocumentTests {
|
|||||||
goal: GoalKind.mobility.rawValue,
|
goal: GoalKind.mobility.rawValue,
|
||||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||||
weekdays: [2, 4, 6],
|
weekdays: [2, 4, 6],
|
||||||
|
reminderMinutes: 18 * 60 + 45,
|
||||||
order: 7,
|
order: 7,
|
||||||
createdAt: Self.created,
|
createdAt: Self.created,
|
||||||
updatedAt: Self.updated
|
updatedAt: Self.updated
|
||||||
@@ -195,6 +200,7 @@ struct ScheduleDocumentTests {
|
|||||||
#expect(entity.recurrenceRaw == original.recurrence)
|
#expect(entity.recurrenceRaw == original.recurrence)
|
||||||
#expect(entity.recurrenceEnum == .fixedDays)
|
#expect(entity.recurrenceEnum == .fixedDays)
|
||||||
#expect(entity.weekdays == [2, 4, 6])
|
#expect(entity.weekdays == [2, 4, 6])
|
||||||
|
#expect(entity.reminderMinutes == 1125)
|
||||||
#expect(entity.order == 7)
|
#expect(entity.order == 7)
|
||||||
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
|
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user