From 5c6335e43e8f9cbbdc03084b2e436f8c970e1ba0 Mon Sep 17 00:00:00 2001 From: kristinkand <163904051+kristinkand@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:18:27 +0000 Subject: [PATCH 1/3] Watch: snooze page to snooze all alarms ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third watch page shows whether all alarms are snoozed and for how long. A tap opens End Snooze, + 30 minutes, snoozes of 30 minutes to 4 hours, and "Until a time…", which picks an end time with the Digital Crown (the next time the clock shows it, at most 24 hours ahead). The phone applies the choice like the Snoozer's "Snooze all": it sets the alarm configuration's snoozeUntil and snoozes an alarm that is sounding. It needs the phone but not remote commands, like snoozing a sounding alarm. The watch state carries the snooze end, and a change on the phone is sent to the watch at once. The page follows Magnus Reintz's (t1dude) alarm snooze page for Trio's watch app: the bell in a circle with the time left, the snooze end below it, the options in a sheet, and the time left counted from now rather than the timeline's tick (nightscout/Trio#1381). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUzqfqfsM8BQYJ2yoRaLz8 --- LoopFollow/Watch/WatchCommandHandler.swift | 28 ++- LoopFollow/Watch/WatchSessionManager.swift | 2 + LoopFollow/Watch/WatchStateBuilder.swift | 1 + LoopFollowWatch/ContentView.swift | 1 + LoopFollowWatch/DirectCommandSender.swift | 4 +- LoopFollowWatch/SnoozePage.swift | 206 +++++++++++++++++++++ LoopFollowWatch/StandaloneFetcher.swift | 2 + LoopFollowWatch/WatchSession.swift | 5 +- Tests/WatchProtocolTests.swift | 2 + WatchShared/WatchCommand.swift | 3 + WatchShared/WatchState.swift | 2 + 11 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 LoopFollowWatch/SnoozePage.swift 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 08a9aacb1..a94b5af87 100644 --- a/LoopFollow/Watch/WatchSessionManager.swift +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -47,6 +47,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 21d89021c..bddbd3918 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 f8abc2df1..ea770fe50 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -46,6 +46,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..dac826fa9 --- /dev/null +++ b/LoopFollowWatch/SnoozePage.swift @@ -0,0 +1,206 @@ +// 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 picked with the Digital Crown, like the phone's snooze date and time: +/// the next time the clock shows it, so 07:00 picked at 22:00 means tomorrow morning. +private struct SnoozeUntilTimeView: View { + var onSnooze: (Date) -> Void + @State private var time: Date + + init(current: Date?, onSnooze: @escaping (Date) -> Void) { + self.onSnooze = onSnooze + // A running snooze's end, or an hour from now rounded up to the quarter hour. + let start = current.flatMap { $0 > Date() ? $0 : nil } ?? Self.roundedUpToQuarter(Date().addingTimeInterval(3600)) + _time = State(initialValue: start) + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + let now = max(context.date, Date()) + let end = Self.nextOccurrence(of: time, after: now) + ScrollView { + VStack(spacing: 6) { + DatePicker("Until", selection: $time, displayedComponents: .hourAndMinute) + .labelsHidden() + Text(Self.describe(end, now: now)) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Snooze") { onSnooze(Self.nextOccurrence(of: time, after: Date())) } + .buttonStyle(.borderedProminent) + } + } + } + .navigationTitle("Until") + } + + /// The first time after `now` the clock shows the picked hour and minute: today, or tomorrow + /// once that has passed. Always within 24 hours, as the phone requires. + static func nextOccurrence(of picked: Date, after now: Date) -> Date { + let calendar = Calendar.current + let clock = calendar.dateComponents([.hour, .minute], from: picked) + return calendar.nextDate(after: now, matching: clock, matchingPolicy: .nextTime) ?? picked + } + + /// "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 ea13ad588..be3c74ceb 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -141,6 +141,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 c6101921b..1f87c2f04 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -96,8 +96,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.") 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? From be498732541ba46f650b4674fd55c7d60c1f9af6 Mon Sep 17 00:00:00 2001 From: kristinkand <163904051+kristinkand@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:34:52 +0000 Subject: [PATCH 2/3] Watch snooze page: set the end time with the Digital Crown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The time picker took taps on its columns, not the crown. "Until a time…" now uses the same crown field as the bolus amount: the end time in quarter hours, turned with the crown or − and +, from 15 minutes up to 24 hours ahead, with "Today/Tomorrow 07:00 · in 8 h 30" under it. The field has the crown's focus when the screen opens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUzqfqfsM8BQYJ2yoRaLz8 --- LoopFollowWatch/SnoozePage.swift | 68 +++++++++++++++++++------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/LoopFollowWatch/SnoozePage.swift b/LoopFollowWatch/SnoozePage.swift index dac826fa9..6b2c2683b 100644 --- a/LoopFollowWatch/SnoozePage.swift +++ b/LoopFollowWatch/SnoozePage.swift @@ -151,45 +151,57 @@ private struct SnoozeOptionsView: View { } } -/// Snoozes until a time picked with the Digital Crown, like the phone's snooze date and time: -/// the next time the clock shows it, so 07:00 picked at 22:00 means tomorrow morning. +/// 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 - @State private var time: Date + /// 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 - // A running snooze's end, or an hour from now rounded up to the quarter hour. - let start = current.flatMap { $0 > Date() ? $0 : nil } ?? Self.roundedUpToQuarter(Date().addingTimeInterval(3600)) - _time = State(initialValue: start) + 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 { - TimelineView(.periodic(from: .now, by: 30)) { context in - let now = max(context.date, Date()) - let end = Self.nextOccurrence(of: time, after: now) - ScrollView { - VStack(spacing: 6) { - DatePicker("Until", selection: $time, displayedComponents: .hourAndMinute) - .labelsHidden() - Text(Self.describe(end, now: now)) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Button("Snooze") { onSnooze(Self.nextOccurrence(of: time, after: Date())) } - .buttonStyle(.borderedProminent) - } + 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") - } - - /// The first time after `now` the clock shows the picked hour and minute: today, or tomorrow - /// once that has passed. Always within 24 hours, as the phone requires. - static func nextOccurrence(of picked: Date, after now: Date) -> Date { - let calendar = Calendar.current - let clock = calendar.dateComponents([.hour, .minute], from: picked) - return calendar.nextDate(after: now, matching: clock, matchingPolicy: .nextTime) ?? picked + .onAppear { focused = true } } /// "Today 23:30 · in 1 h 15" or "Tomorrow 07:00 · in 8 h 30". From e9373001577962bd94da0252d852d1ccabb024eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 18:35:49 +0100 Subject: [PATCH 3/3] Handle snoozeAll in the watch's arrival check --- LoopFollowWatch/WatchSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index 492849bfc..3a9bd652a 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -190,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 } }