diff --git a/LoopFollow/Watch/WatchCommandHandler.swift b/LoopFollow/Watch/WatchCommandHandler.swift index 238e1d964..13de20152 100644 --- a/LoopFollow/Watch/WatchCommandHandler.swift +++ b/LoopFollow/Watch/WatchCommandHandler.swift @@ -56,6 +56,30 @@ final class WatchCommandHandler { return } + // Snoozing ahead needs no remote control: it changes LoopFollow's own alarms, like the + // Snoozer's "Snooze all". + if case let .snoozeAll(until) = command.action { + var configuration = Storage.shared.alarmConfiguration.value + if let until { + guard until > Date(), until <= Date().addingTimeInterval(24 * 3600) else { + finish(false, "Choose a snooze of up to 24 hours.") + return + } + // As on the phone: an alarm sounding now is snoozed as well. + if Observable.shared.currentAlarm.value != nil { + AlarmManager.shared.performSnooze() + } + configuration.snoozeUntil = until + Storage.shared.alarmConfiguration.value = configuration + finish(true, "All alarms snoozed until \(until.formatted(date: .omitted, time: .shortened)).") + } else { + configuration.snoozeUntil = nil + Storage.shared.alarmConfiguration.value = configuration + finish(true, "Snooze ended. Alarms are active.") + } + return + } + guard Storage.shared.watchRemoteCommands.value else { finish(false, "Commands from Apple Watch are turned off in LoopFollow's Remote settings.") return @@ -130,7 +154,7 @@ final class WatchCommandHandler { case .cancelOverride: service.sendCancelOverrideNotification(completion: sent) - case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + case .startTempTarget, .cancelTempTarget, .snoozeAlarm, .snoozeAll: finish(false, "This command is not available for Loop.") } } @@ -206,7 +230,7 @@ final class WatchCommandHandler { case .cancelTempTarget: manager.sendCancelTempTargetPushNotification(completion: sent) - case .snoozeAlarm: + case .snoozeAlarm, .snoozeAll: finish(false, "Unknown request.") } } diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift index 041169524..d8ce53431 100644 --- a/LoopFollow/Watch/WatchSessionManager.swift +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -52,6 +52,8 @@ final class WatchSessionManager: NSObject { private func observeSettings() { let triggers: [AnyPublisher] = [ Observable.shared.currentAlarm.$value.map { _ in () }.eraseToAnyPublisher(), + // "Snooze all" set or ended on the phone shows on the watch's snooze page at once. + Storage.shared.alarmConfiguration.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.colorBGText.$value.map { _ in () }.eraseToAnyPublisher(), diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index 12a379c99..83ebf45d7 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -67,6 +67,7 @@ enum WatchStateBuilder { stats: vc.map { stats($0.statsDisplayModel) }, device: Storage.shared.device.value, alarm: currentAlarm(), + snoozeAllUntil: Storage.shared.alarmConfiguration.value.snoozeUntil.flatMap { $0 > now ? $0 : nil }, remote: remoteConfig() ) } diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index 5db091911..8b85b5cc4 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -48,6 +48,7 @@ struct ContentView: View { TabView { GlucosePage(state: state) ChartPage(state: state) + SnoozePage(state: state) if let stats = state.stats { StatsPage(state: state, stats: stats) } diff --git a/LoopFollowWatch/DirectCommandSender.swift b/LoopFollowWatch/DirectCommandSender.swift index b2320524f..0f727812f 100644 --- a/LoopFollowWatch/DirectCommandSender.swift +++ b/LoopFollowWatch/DirectCommandSender.swift @@ -73,7 +73,7 @@ enum DirectCommandSender { payload.duration = Int(preset.durationSeconds / 60) case .cancelTempTarget: payload.commandType = .cancelTempTarget - case .snoozeAlarm: + case .snoozeAlarm, .snoozeAll: return (false, "Snoozing needs the iPhone.") } @@ -121,7 +121,7 @@ enum DirectCommandSender { ) case .cancelOverride: payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: remote.returnNotification) - case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + case .startTempTarget, .cancelTempTarget, .snoozeAlarm, .snoozeAll: return (false, "This command is not available for Loop.") } diff --git a/LoopFollowWatch/SnoozePage.swift b/LoopFollowWatch/SnoozePage.swift new file mode 100644 index 000000000..6b2c2683b --- /dev/null +++ b/LoopFollowWatch/SnoozePage.swift @@ -0,0 +1,218 @@ +// LoopFollow +// SnoozePage.swift + +import SwiftUI + +/// Snoozes all alarms ahead of time, as the phone's Snoozer does with "Snooze all": +/// shows whether alarms are snoozed and for how long, and opens the snooze choices. +/// +/// Modeled on Magnus Reintz's (t1dude) alarm snooze page for Trio's watch app +/// (nightscout/Trio: "Add alarm snooze page to watch app", "Address watch snooze +/// review feedback"): the bell in a circle with the minutes left, the snooze end +/// below it, and a sheet with End Snooze and the durations. +struct SnoozePage: View { + let state: WatchState + + @EnvironmentObject private var session: WatchSession + @State private var showingOptions = false + + var body: some View { + TimelineView(.periodic(from: .now, by: 15)) { context in + // Measured from now rather than the tick date, which can be stale (nightscout/Trio#1381). + let now = max(context.date, Date()) + let until = state.snoozeAllUntil.flatMap { $0 > now ? $0 : nil } + let minutesLeft = until.map { max(Int(ceil($0.timeIntervalSince(now) / 60)), 0) } ?? 0 + + VStack(spacing: 8) { + Spacer(minLength: 0) + Button { + showingOptions = true + } label: { + VStack(spacing: 2) { + Image(systemName: until == nil ? "bell.fill" : "bell.slash.fill") + .font(.title2) + if until != nil { + Text(Self.remaining(minutesLeft)) + .font(.headline).fontWeight(.bold).fontDesign(.rounded) + } + } + .foregroundStyle(until == nil ? Color.primary : Color.secondary) + .frame(width: 80, height: 80) + .overlay(Circle().stroke(Color.primary.opacity(0.4), lineWidth: 2)) + } + .buttonStyle(.plain) + + Text(until.map { "Snoozed until \($0.formatted(date: .omitted, time: .shortened))" } ?? "Alarms active") + .font(.footnote) + .foregroundStyle(.secondary) + + if state.source == .watch { + Text("Snoozing needs the iPhone") + .font(.caption2) + .foregroundStyle(.orange) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Alarms")) + .accessibilityValue(Text(until == nil ? "active" : "snoozed, \(minutesLeft) minutes remaining")) + .accessibilityHint(Text("Opens snooze options")) + .accessibilityAddTraits(.isButton) + .accessibilityAction { showingOptions = true } + } + .navigationTitle("Snooze") + .sheet(isPresented: $showingOptions) { + SnoozeOptionsView(snoozedUntil: state.snoozeAllUntil) { + showingOptions = false + } + .environmentObject(session) + } + } + + /// "45 m" below an hour, "2 h 05" above. + static func remaining(_ minutes: Int) -> String { + minutes < 60 ? "\(minutes) m" : String(format: "%d h %02d", minutes / 60, minutes % 60) + } +} + +/// End Snooze while snoozed, then the snooze durations and a chosen end time. The phone applies the choice +/// and sends the new state back, so the page shows what the phone actually set. +private struct SnoozeOptionsView: View { + let snoozedUntil: Date? + var onDone: () -> Void + + @EnvironmentObject private var session: WatchSession + @State private var isSending = false + @State private var error: String? + + private static let durations: [(label: String, minutes: Int)] = [ + ("30 minutes", 30), + ("1 hour", 60), + ("2 hours", 120), + ("4 hours", 240), + ] + + var body: some View { + let isSnoozed = (snoozedUntil ?? .distantPast) > Date() + NavigationStack { + List { + if isSnoozed { + Button { + send(until: nil) + } label: { + HStack(spacing: 8) { + Image(systemName: "alarm.waves.left.and.right.fill") + Text("End Snooze") + } + } + .foregroundStyle(.white) + .listRowBackground(Color.red.clipShape(RoundedRectangle(cornerRadius: 8))) + + // Like the phone's "+ 30m". + Button("+ 30 minutes") { + send(until: (snoozedUntil ?? Date()).addingTimeInterval(30 * 60)) + } + } + + ForEach(Self.durations, id: \.minutes) { option in + Button(option.label) { + send(until: Date().addingTimeInterval(TimeInterval(option.minutes * 60))) + } + } + + NavigationLink("Until a time…") { + SnoozeUntilTimeView(current: snoozedUntil) { end in + send(until: end) + } + } + } + .disabled(isSending) + .navigationTitle(isSending ? "Sending…" : "Snooze All") + } + .alert("Snooze failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(error ?? "") + } + } + + private func send(until: Date?) { + Task { + isSending = true + let reply = await session.send(.snoozeAll(until: until)) + isSending = false + if reply.success { + onDone() + } else { + error = reply.message + } + } + } +} + +/// Snoozes until a time set with the Digital Crown (or − and +), in quarter hours: like the +/// phone's snooze date and time, so 07:00 set in the evening means tomorrow morning. Turning +/// on past midnight goes on into the next day, up to 24 hours ahead. +private struct SnoozeUntilTimeView: View { + var onSnooze: (Date) -> Void + /// The quarter hour the choices count from: now, rounded up. + private let base: Date + /// Minutes after `base`, in steps of 15. + @State private var minutes: Double + @FocusState private var focused: Bool + + init(current: Date?, onSnooze: @escaping (Date) -> Void) { + self.onSnooze = onSnooze + let base = Self.roundedUpToQuarter(Date()) + self.base = base + // A running snooze's end, or an hour from now. + let start = current.map { $0.timeIntervalSince(base) / 60 } ?? 60 + _minutes = State(initialValue: min(max((start / 15).rounded() * 15, Self.minimum), Self.maximum)) + } + + private static let minimum = 15.0 + private static let maximum = 24.0 * 60 - 15 + + private var end: Date { base.addingTimeInterval(minutes * 60) } + + var body: some View { + ScrollView { + VStack(spacing: 6) { + AmountField( + value: $minutes, + unit: "", + step: 15, + minimum: Self.minimum, + maximum: Self.maximum, + isHighlighted: focused, + format: { base.addingTimeInterval($0 * 60).formatted(date: .omitted, time: .shortened) } + ) + .focused($focused) + Text(Self.describe(end, now: Date())) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Label("Turn to change", systemImage: CrownSymbol.increase) + .font(.caption2) + .foregroundStyle(.secondary) + Button("Snooze") { onSnooze(end) } + .buttonStyle(.borderedProminent) + } + } + .navigationTitle("Until") + .onAppear { focused = true } + } + + /// "Today 23:30 · in 1 h 15" or "Tomorrow 07:00 · in 8 h 30". + static func describe(_ end: Date, now: Date) -> String { + let day = Calendar.current.isDate(end, inSameDayAs: now) ? "Today" : "Tomorrow" + let minutes = max(Int(ceil(end.timeIntervalSince(now) / 60)), 0) + return "\(day) \(end.formatted(date: .omitted, time: .shortened)) · in \(SnoozePage.remaining(minutes))" + } + + static func roundedUpToQuarter(_ date: Date) -> Date { + let quarter: TimeInterval = 15 * 60 + return Date(timeIntervalSinceReferenceDate: (date.timeIntervalSinceReferenceDate / quarter).rounded(.up) * quarter) + } +} diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index 0617371c0..7df90dce4 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -150,6 +150,8 @@ enum StandaloneFetcher { stats: base?.stats, device: base?.device ?? "", alarm: nil, + // The phone's snooze, as last heard, so the snooze page keeps it while the phone is away. + snoozeAllUntil: base?.snoozeAllUntil, remote: remote ) } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index 1c7d97eac..3a9bd652a 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -117,8 +117,11 @@ final class WatchSession: NSObject, ObservableObject { case .notDelivered: break } } - if case .snoozeAlarm = action { + switch action { + case .snoozeAlarm, .snoozeAll: return WatchCommandReply(success: false, message: "Snoozing needs the iPhone, which is not reachable right now.") + default: + break } guard let credentials, let config = state?.remote else { return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") @@ -187,7 +190,7 @@ final class WatchSession: NSObject, ObservableObject { guard carbs > 0 else { return { _ in false } } let at = consumedAt.timeIntervalSince1970 return { $0.carbs.contains { abs($0.t - at) < 5 * 60 && abs($0.v - carbs) < 0.5 } } - case .snoozeAlarm: + case .snoozeAlarm, .snoozeAll: return nil } } diff --git a/Tests/WatchProtocolTests.swift b/Tests/WatchProtocolTests.swift index ebb5231b0..58ee4e523 100644 --- a/Tests/WatchProtocolTests.swift +++ b/Tests/WatchProtocolTests.swift @@ -75,6 +75,8 @@ struct WatchProtocolTests { .startTempTarget(presetID: UUID().uuidString), .cancelTempTarget, .snoozeAlarm, + .snoozeAll(until: t0), + .snoozeAll(until: nil), ] for action in actions { let command = WatchCommand(action: action) diff --git a/WatchShared/WatchCommand.swift b/WatchShared/WatchCommand.swift index 5668fb463..d1a276e62 100644 --- a/WatchShared/WatchCommand.swift +++ b/WatchShared/WatchCommand.swift @@ -19,6 +19,8 @@ struct WatchCommand: Codable, Equatable { case startTempTarget(presetID: String) case cancelTempTarget case snoozeAlarm + /// Snoozes all alarms until the date, as the phone's "Snooze all" does; nil ends the snooze. + case snoozeAll(until: Date?) } let id: UUID @@ -43,6 +45,7 @@ extension WatchCommand.Action { case .startTempTarget: "startTempTarget" case .cancelTempTarget: "cancelTempTarget" case .snoozeAlarm: "snoozeAlarm" + case .snoozeAll: "snoozeAll" } } diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift index 91c73c69c..056da0629 100644 --- a/WatchShared/WatchState.swift +++ b/WatchShared/WatchState.swift @@ -39,6 +39,8 @@ struct WatchState: Codable, Equatable { var device: String /// The alarm currently sounding on the phone. var alarm: WatchAlarm? + /// While all alarms are snoozed on the phone (its "Snooze all"): until when. + var snoozeAllUntil: Date? = nil /// Present when remote commands from the watch are enabled on the phone. var remote: WatchRemoteConfig?