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
@@ -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