Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions LoopFollow/Watch/WatchCommandHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
}
}
Expand Down Expand Up @@ -206,7 +230,7 @@ final class WatchCommandHandler {
case .cancelTempTarget:
manager.sendCancelTempTargetPushNotification(completion: sent)

case .snoozeAlarm:
case .snoozeAlarm, .snoozeAll:
finish(false, "Unknown request.")
}
}
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Watch/WatchSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ final class WatchSessionManager: NSObject {
private func observeSettings() {
let triggers: [AnyPublisher<Void, Never>] = [
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(),
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Watch/WatchStateBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
}
Expand Down
1 change: 1 addition & 0 deletions LoopFollowWatch/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions LoopFollowWatch/DirectCommandSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}

Expand Down Expand Up @@ -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.")
}

Expand Down
218 changes: 218 additions & 0 deletions LoopFollowWatch/SnoozePage.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions LoopFollowWatch/StandaloneFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
5 changes: 4 additions & 1 deletion LoopFollowWatch/WatchSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
2 changes: 2 additions & 0 deletions Tests/WatchProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions WatchShared/WatchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +45,7 @@ extension WatchCommand.Action {
case .startTempTarget: "startTempTarget"
case .cancelTempTarget: "cancelTempTarget"
case .snoozeAlarm: "snoozeAlarm"
case .snoozeAll: "snoozeAll"
}
}

Expand Down
2 changes: 2 additions & 0 deletions WatchShared/WatchState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down