diff --git a/SleepFocus/Screens/ProfileScreen.swift b/SleepFocus/Screens/ProfileScreen.swift index 0572e18..7d6b7ce 100644 --- a/SleepFocus/Screens/ProfileScreen.swift +++ b/SleepFocus/Screens/ProfileScreen.swift @@ -390,6 +390,9 @@ struct SmartAlarmSettingsScreen: View { @State private var displayedSmartAlarmSchedule = "" @State private var smartAlarmDisplayOpacity = 1.0 @State private var pendingSmartAlarmRefresh = false + @State private var showOutOfBoundsAlert = false + @State private var outOfBoundsAlertMessage = "" + @State private var suppressPreferenceOnChange = false private enum EditorSection { case sleepGoal @@ -427,6 +430,11 @@ struct SmartAlarmSettingsScreen: View { } message: { Text("Live Activities are currently disabled. Open Settings to allow lock screen and Dynamic Island smart alarm updates.") } + .alert("Out of bounds", isPresented: $showOutOfBoundsAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(outOfBoundsAlertMessage) + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -461,6 +469,7 @@ struct SmartAlarmSettingsScreen: View { Task { await refreshGuidanceAndScheduleIfNeeded(forceRefresh: true) } } .onChange(of: sleepPreferences.smartAlarmWakeTimeMinutes) { _, _ in + guard !suppressPreferenceOnChange else { return } if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() scheduleGuidanceRefreshAndSync() @@ -482,6 +491,7 @@ struct SmartAlarmSettingsScreen: View { } } .onChange(of: sleepPreferences.smartAlarmBedtimeMinutes) { _, _ in + guard !suppressPreferenceOnChange else { return } if sleepPreferences.smartAlarmMode == .bedtime { beginSmartAlarmFetchTransition() scheduleGuidanceRefreshAndSync() @@ -496,6 +506,7 @@ struct SmartAlarmSettingsScreen: View { scheduleGuidanceRefreshAndSync() } .onChange(of: sleepPreferences.earliestWakeupMinutes) { _, _ in + guard !suppressPreferenceOnChange else { return } if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() } else { @@ -504,6 +515,7 @@ struct SmartAlarmSettingsScreen: View { scheduleGuidanceRefreshAndSync() } .onChange(of: sleepPreferences.finalWakeupMinutes) { _, _ in + guard !suppressPreferenceOnChange else { return } if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() } else { @@ -513,6 +525,23 @@ struct SmartAlarmSettingsScreen: View { } } + private var allowedWakeWindowMinutes: (earliest: Int, latest: Int) { + (earliest: sleepPreferences.earliestWakeupMinutes, latest: sleepPreferences.finalWakeupMinutes) + } + + private func normalizedMinutes(_ minutes: Int) -> Int { + ((minutes % 1440) + 1440) % 1440 + } + + private func isWithinAllowedWakeWindow(_ minutes: Int, window: (earliest: Int, latest: Int)) -> Bool { + minutes >= window.earliest && minutes <= window.latest + } + + private func presentOutOfBoundsAlert(_ message: String) { + outOfBoundsAlertMessage = message + showOutOfBoundsAlert = true + } + private var inputTimeBinding: Binding { Binding( get: { @@ -526,7 +555,21 @@ struct SmartAlarmSettingsScreen: View { set: { updatedDate in switch sleepPreferences.smartAlarmMode { case .wake: + let previousMinutes = sleepPreferences.smartAlarmWakeTimeMinutes + let attemptedMinutes = SleepPreferencesStore.minutesSinceMidnight(from: updatedDate) + let window = allowedWakeWindowMinutes + guard isWithinAllowedWakeWindow(attemptedMinutes, window: window) else { + presentOutOfBoundsAlert("That alarm time (\(sleepPreferences.timeDisplay(for: attemptedMinutes))) is outside your allowed wake window (\(sleepPreferences.timeDisplay(for: window.earliest))–\(sleepPreferences.timeDisplay(for: window.latest))). Resetting to the previous alarm time.") + suppressPreferenceOnChange = true + sleepPreferences.updateSmartAlarmWakeTime(sleepPreferences.date(for: previousMinutes)) + suppressPreferenceOnChange = false + return + } + suppressPreferenceOnChange = true sleepPreferences.updateSmartAlarmWakeTime(updatedDate) + suppressPreferenceOnChange = false + beginIdealBedtimeFetchTransition() + scheduleGuidanceRefreshAndSync() case .bedtime: beginSmartAlarmFetchTransition() sleepPreferences.updateSmartAlarmBedtime(updatedDate) @@ -545,9 +588,25 @@ struct SmartAlarmSettingsScreen: View { private var bedtimeBinding: Binding { Binding( get: { sleepPreferences.date(for: sleepPreferences.smartAlarmBedtimeMinutes) }, - set: { + set: { updatedDate in + let previousBedtimeMinutes = sleepPreferences.smartAlarmBedtimeMinutes + + suppressPreferenceOnChange = true + sleepPreferences.updateSmartAlarmBedtime(updatedDate) + suppressPreferenceOnChange = false + + let recommendedWakeMinutes = normalizedMinutes(sleepPreferences.smartAlarmWakeTimeMinutes) + let window = allowedWakeWindowMinutes + guard isWithinAllowedWakeWindow(recommendedWakeMinutes, window: window) else { + presentOutOfBoundsAlert("That bedtime would recommend a wake time (\(sleepPreferences.timeDisplay(for: recommendedWakeMinutes))) outside your allowed wake window (\(sleepPreferences.timeDisplay(for: window.earliest))–\(sleepPreferences.timeDisplay(for: window.latest))). Resetting to the previous bedtime.") + suppressPreferenceOnChange = true + sleepPreferences.updateSmartAlarmBedtime(sleepPreferences.date(for: previousBedtimeMinutes)) + suppressPreferenceOnChange = false + return + } + beginSmartAlarmFetchTransition() - sleepPreferences.updateSmartAlarmBedtime($0) + scheduleGuidanceRefreshAndSync() } ) } @@ -555,14 +614,62 @@ struct SmartAlarmSettingsScreen: View { private var earliestWakeBinding: Binding { Binding( get: { sleepPreferences.date(for: sleepPreferences.earliestWakeupMinutes) }, - set: { sleepPreferences.updateEarliestWakeup($0) } + set: { updatedDate in + let previousMinutes = sleepPreferences.earliestWakeupMinutes + suppressPreferenceOnChange = true + sleepPreferences.updateEarliestWakeup(updatedDate) + suppressPreferenceOnChange = false + + let window = allowedWakeWindowMinutes + if sleepPreferences.smartAlarmEnabled { + let currentWakeMinutes = normalizedMinutes(sleepPreferences.smartAlarmWakeTimeMinutes) + if !isWithinAllowedWakeWindow(currentWakeMinutes, window: window) { + presentOutOfBoundsAlert("That wake-window change would put your next alarm (\(sleepPreferences.timeDisplay(for: currentWakeMinutes))) outside the allowed wake window (\(sleepPreferences.timeDisplay(for: window.earliest))–\(sleepPreferences.timeDisplay(for: window.latest))). Resetting to the previous window.") + suppressPreferenceOnChange = true + sleepPreferences.updateEarliestWakeup(sleepPreferences.date(for: previousMinutes)) + suppressPreferenceOnChange = false + return + } + } + + if sleepPreferences.smartAlarmMode == .wake { + beginIdealBedtimeFetchTransition() + } else { + beginSmartAlarmFetchTransition() + } + scheduleGuidanceRefreshAndSync() + } ) } private var finalWakeBinding: Binding { Binding( get: { sleepPreferences.date(for: sleepPreferences.finalWakeupMinutes) }, - set: { sleepPreferences.updateFinalWakeup($0) } + set: { updatedDate in + let previousMinutes = sleepPreferences.finalWakeupMinutes + suppressPreferenceOnChange = true + sleepPreferences.updateFinalWakeup(updatedDate) + suppressPreferenceOnChange = false + + let window = allowedWakeWindowMinutes + if sleepPreferences.smartAlarmEnabled { + let currentWakeMinutes = normalizedMinutes(sleepPreferences.smartAlarmWakeTimeMinutes) + if !isWithinAllowedWakeWindow(currentWakeMinutes, window: window) { + presentOutOfBoundsAlert("That wake-window change would put your next alarm (\(sleepPreferences.timeDisplay(for: currentWakeMinutes))) outside the allowed wake window (\(sleepPreferences.timeDisplay(for: window.earliest))–\(sleepPreferences.timeDisplay(for: window.latest))). Resetting to the previous window.") + suppressPreferenceOnChange = true + sleepPreferences.updateFinalWakeup(sleepPreferences.date(for: previousMinutes)) + suppressPreferenceOnChange = false + return + } + } + + if sleepPreferences.smartAlarmMode == .wake { + beginIdealBedtimeFetchTransition() + } else { + beginSmartAlarmFetchTransition() + } + scheduleGuidanceRefreshAndSync() + } ) } @@ -770,6 +877,7 @@ struct SmartAlarmSettingsScreen: View { .font(.system(size: 13, weight: .semibold)) .foregroundColor(.appSecondaryText) + // if let compensationText = sleepPreferences.smartAlarmSleepGoalCompensationText { // Text(compensationText) // .font(.system(size: 12))