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