From 1c300d5984138c67e763114c2fbaa4ecb3cb8f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:28:25 +0000 Subject: [PATCH 1/3] SwiftUI lint: fix observer leak, dead code, and style violations in Watch app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix notification observer leak: snooze-sheet observer in WatchViewModel.init was never captured or removed in deinit; store it in snoozeSheetObserver and remove both observers on dealloc - Add import WatchKit to ContentView.swift so UIColor is explicitly available on watchOS (was implicitly resolved via transitive SwiftUI import, fragile) - Add .accessibilityLabel to icon-only gear and antenna buttons in ContentView - Extract inline filter from SlotSelectionView.body into a computed property (avoids reallocating the array on every render pass) - Replace ForEach(Array(enumerated()), id: \.offset) with ForEach(indices) - Name stale-data threshold: 900 → GlucoseView.staleThreshold (15 * 60) - Remove redundant = nil / = false defaults from @Published properties - Fix alignment whitespace in refresh() / update() - stride(...).map { $0 } → Array(stride(...)) in WatchAlertSettingsView - Remove dead instance scheduleNextRefresh() in WatchAppDelegate (shadows the static version and was never called as an instance method) - Remove redundant .onAppear re-initialisation in SnoozeView (init already seeds state from WatchAppSettings.shared) - Remove extra blank line in WatchAlertSettingsView https://claude.ai/code/session_01B2JCvJgyxUzsdzBhquj13R --- LoopFollowWatch Watch App/ContentView.swift | 43 +++++++++++-------- .../LoopFollowWatchApp.swift | 4 -- .../Remote/WatchOverridePickerView.swift | 2 +- LoopFollowWatch Watch App/SnoozeView.swift | 4 -- .../WatchAlertSettingsView.swift | 3 +- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift index 580c2cc3a..bf3006b4b 100644 --- a/LoopFollowWatch Watch App/ContentView.swift +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -9,6 +9,7 @@ import Combine import SwiftUI import WatchConnectivity +import WatchKit // MARK: - Root view @@ -28,6 +29,7 @@ struct ContentView: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .accessibilityLabel("Settings") Button { showRemote = true } label: { Image(systemName: "antenna.radiowaves.left.and.right") @@ -35,6 +37,7 @@ struct ContentView: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .accessibilityLabel("Remote Commands") } .padding([.top, .trailing], 4) } @@ -45,8 +48,8 @@ struct ContentView: View { NavigationStack { WatchAlertSettingsView() } } - ForEach(Array(model.pages.enumerated()), id: \.offset) { _, page in - DataGridPage(slots: page, snapshot: model.snapshot) + ForEach(model.pages.indices, id: \.self) { idx in + DataGridPage(slots: model.pages[idx], snapshot: model.snapshot) } SlotSelectionView(model: model) @@ -61,14 +64,15 @@ struct ContentView: View { final class WatchViewModel: ObservableObject { @Published var snapshot: GlucoseSnapshot? @Published var selectedSlots: [LiveActivitySlotOption] = LAAppGroupSettings.watchSelectedSlots() - @Published var showSnoozeSheet: Bool = false - @Published var pendingSnoozeType: WatchAlertType? = nil - @Published var isSnoozed: Bool = false - @Published var snoozeUntil: Date? = nil - @Published var hasActiveAlert: Bool = false + @Published var showSnoozeSheet = false + @Published var pendingSnoozeType: WatchAlertType? + @Published var isSnoozed = false + @Published var snoozeUntil: Date? + @Published var hasActiveAlert = false private var timer: Timer? private var notificationObserver: Any? + private var snoozeSheetObserver: Any? init() { snapshot = GlucoseSnapshotStore.shared.load() @@ -86,7 +90,7 @@ final class WatchViewModel: ObservableObject { self?.refresh() } } - NotificationCenter.default.addObserver( + snoozeSheetObserver = NotificationCenter.default.addObserver( forName: .showSnoozeSheet, object: nil, queue: .main @@ -99,9 +103,8 @@ final class WatchViewModel: ObservableObject { deinit { timer?.invalidate() - if let obs = notificationObserver { - NotificationCenter.default.removeObserver(obs) - } + if let obs = notificationObserver { NotificationCenter.default.removeObserver(obs) } + if let obs = snoozeSheetObserver { NotificationCenter.default.removeObserver(obs) } } func refresh() { @@ -109,14 +112,14 @@ final class WatchViewModel: ObservableObject { snapshot = loaded } selectedSlots = LAAppGroupSettings.watchSelectedSlots() - isSnoozed = WatchAlertManager.shared.isGloballySnoozed - snoozeUntil = WatchAlertManager.shared.globalSnoozeExpiryDate + isSnoozed = WatchAlertManager.shared.isGloballySnoozed + snoozeUntil = WatchAlertManager.shared.globalSnoozeExpiryDate hasActiveAlert = snapshot.map { WatchAlertManager.shared.hasActiveAlert(for: $0) } ?? false } func update(snapshot: GlucoseSnapshot) { self.snapshot = snapshot - selectedSlots = LAAppGroupSettings.watchSelectedSlots() + selectedSlots = LAAppGroupSettings.watchSelectedSlots() hasActiveAlert = WatchAlertManager.shared.hasActiveAlert(for: snapshot) } @@ -147,9 +150,11 @@ final class WatchViewModel: ObservableObject { struct GlucoseView: View { @ObservedObject var model: WatchViewModel + fileprivate static let staleThreshold: TimeInterval = 15 * 60 + var body: some View { Group { - if let s = model.snapshot, s.age < 900 { + if let s = model.snapshot, s.age < GlucoseView.staleThreshold { VStack(alignment: .leading, spacing: 6) { Text("\(WatchFormat.glucose(s)) \(WatchFormat.trendArrow(s))") .font(.system(size: 56, weight: .bold, design: .rounded)) @@ -276,9 +281,13 @@ struct MetricCell: View { struct SlotSelectionView: View { @ObservedObject var model: WatchViewModel + private var displayedOptions: [LiveActivitySlotOption] { + LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG } + } + var body: some View { List { - ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in + ForEach(displayedOptions, id: \.self) { option in Button(action: { model.toggleSlot(option) }) { HStack { Text(option.displayName) @@ -299,8 +308,6 @@ struct SlotSelectionView: View { } } -// MARK: - UIColor → SwiftUI Color bridge - private extension UIColor { var swiftUIColor: Color { Color(self) } } diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift index 2eaa10d61..5f5700b43 100644 --- a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -100,8 +100,4 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { userInfo: nil ) { _ in } } - - private func scheduleNextRefresh() { - WatchAppDelegate.scheduleNextRefresh() - } } \ No newline at end of file diff --git a/LoopFollowWatch Watch App/Remote/WatchOverridePickerView.swift b/LoopFollowWatch Watch App/Remote/WatchOverridePickerView.swift index 90ece84a2..8d7ba827a 100644 --- a/LoopFollowWatch Watch App/Remote/WatchOverridePickerView.swift +++ b/LoopFollowWatch Watch App/Remote/WatchOverridePickerView.swift @@ -9,7 +9,7 @@ import WatchKit struct WatchOverridePickerView: View { @State private var presets: [WatchOverridePreset] = [] - @State private var selectedPreset: WatchOverridePreset? = nil + @State private var selectedPreset: WatchOverridePreset? @State private var showConfirm = false @State private var isSending = false @State private var alertMessage = "" diff --git a/LoopFollowWatch Watch App/SnoozeView.swift b/LoopFollowWatch Watch App/SnoozeView.swift index c9ed5a51b..d82da094c 100644 --- a/LoopFollowWatch Watch App/SnoozeView.swift +++ b/LoopFollowWatch Watch App/SnoozeView.swift @@ -65,10 +65,6 @@ struct SnoozeView: View { } .padding(.horizontal, 4) } - .onAppear { - snoozeMinutes = Double(settings.defaultSnoozeMinutes) - snoozeAll = settings.snoozeAllByDefault - } } private var formattedDuration: String { diff --git a/LoopFollowWatch Watch App/WatchAlertSettingsView.swift b/LoopFollowWatch Watch App/WatchAlertSettingsView.swift index d275c43a6..42a7ce80b 100644 --- a/LoopFollowWatch Watch App/WatchAlertSettingsView.swift +++ b/LoopFollowWatch Watch App/WatchAlertSettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI struct WatchAlertSettingsView: View { @StateObject private var settings = WatchAppSettings.shared - private let snoozeOptions = stride(from: 30, through: 720, by: 30).map { $0 } + private let snoozeOptions = Array(stride(from: 30, through: 720, by: 30)) var body: some View { List { @@ -72,7 +72,6 @@ struct WatchAlertSettingsView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" } - private func formattedMinutes(_ mins: Int) -> String { mins < 60 ? "\(mins)m" : (mins % 60 == 0 ? "\(mins/60)h" : "\(mins/60)h \(mins%60)m") } From a3fb484ae1aa78c2c58eb035c51cede2c19701a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:41:28 +0000 Subject: [PATCH 2/3] SwiftFormat: fix lint errors in LoopFollowWatchApp and WatchAppSettings - LoopFollowWatchApp: fix file header, sort imports alphabetically, remove blank lines at start of scopes, wrap multiline if brace - WatchAppSettings: fix file header, sort imports, remove blank line at start of scope, collapse alignment spaces to single space https://claude.ai/code/session_01B2JCvJgyxUzsdzBhquj13R --- .../LoopFollowWatchApp.swift | 10 ++++----- .../WatchAppSettings.swift | 21 +++++++------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift index 5f5700b43..59a3181ed 100644 --- a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -1,11 +1,10 @@ +// LoopFollow // LoopFollowWatchApp.swift -// Philippe Achkar -// 2026-03-10 +import OSLog import SwiftUI import WatchConnectivity import WatchKit -import OSLog private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", @@ -14,7 +13,6 @@ private let logger = Logger( @main struct LoopFollowWatch_Watch_AppApp: App { - @WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate init() { @@ -31,7 +29,6 @@ struct LoopFollowWatch_Watch_AppApp: App { // MARK: - App delegate for background tasks final class WatchAppDelegate: NSObject, WKApplicationDelegate { - func applicationDidFinishLaunching() { WatchAlertManager.shared.setup() WatchAppDelegate.scheduleNextRefresh() @@ -63,7 +60,8 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { let storeSnapshot = GlucoseSnapshotStore.shared.load() if let ctx = contextSnapshot, - ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) { + ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) + { WatchAlertManager.shared.checkAndAlert(snapshot: ctx) GlucoseSnapshotStore.shared.save(ctx) { WatchSessionReceiver.shared.triggerComplicationReload() diff --git a/LoopFollowWatch Watch App/WatchAppSettings.swift b/LoopFollowWatch Watch App/WatchAppSettings.swift index 10a63b9f0..770ea5d00 100644 --- a/LoopFollowWatch Watch App/WatchAppSettings.swift +++ b/LoopFollowWatch Watch App/WatchAppSettings.swift @@ -1,15 +1,10 @@ +// LoopFollow // WatchAppSettings.swift -// LoopFollowWatch Watch App -// -// Single source of truth for all editable alert settings. -// Backed by the shared App Group container so settings survive -// Watch app reinstalls and are accessible from both app and extension. -import Foundation import Combine +import Foundation final class WatchAppSettings: ObservableObject { - static let shared = WatchAppSettings() private init() {} @@ -20,7 +15,7 @@ final class WatchAppSettings: ObservableObject { // MARK: - UserDefaults keys private enum Key { - static let snoozeAllByDefault = "watchSnoozeAllByDefault" + static let snoozeAllByDefault = "watchSnoozeAllByDefault" static let defaultSnoozeMinutes = "watchDefaultSnoozeMinutes" static func cooldown(_ type: WatchAlertType) -> String { "watchCooldown_\(type.rawValue)" } } @@ -41,11 +36,11 @@ final class WatchAppSettings: ObservableObject { /// Default cooldowns in seconds. Used when no persisted value exists. static let defaultCooldowns: [WatchAlertType: TimeInterval] = [ - .lowBG: 15 * 60, - .urgentLow: 5 * 60, - .highBG: 15 * 60, - .fastDrop: 10 * 60, - .fastRise: 10 * 60, + .lowBG: 15 * 60, + .urgentLow: 5 * 60, + .highBG: 15 * 60, + .fastDrop: 10 * 60, + .fastRise: 10 * 60, ] func cooldown(for type: WatchAlertType) -> TimeInterval { From 7b11569ebacc91e094a098200767991b111e30a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:54:46 +0000 Subject: [PATCH 3/3] SwiftFormat: fix all lint errors across Watch app and complication files https://claude.ai/code/session_01B2JCvJgyxUzsdzBhquj13R --- LoopFollow/Application/AppDelegate.swift | 2 +- .../Nightscout/ProfileManager.swift | 2 +- .../APNsCredentialValidator.swift | 3 + .../LiveActivity/LAAppGroupSettings.swift | 3 +- .../Remote/Watch/WatchCommandDispatcher.swift | 1 - .../ComplicationEntryBuilder.swift | 18 ++-- .../WatchComplicationProvider.swift | 10 +- .../WatchComplication/WatchFormat.swift | 58 ++++++------ .../WatchSessionReceiver.swift | 27 +++--- LoopFollowWatch Watch App/ContentView.swift | 13 +-- .../LoopFollowWatchApp.swift | 2 +- .../Remote/WatchBolusCommandView.swift | 7 +- .../Remote/WatchMealCommandView.swift | 7 +- .../Remote/WatchOverridePickerView.swift | 3 +- .../Remote/WatchRemoteCommandsView.swift | 1 - LoopFollowWatch Watch App/SnoozeView.swift | 16 ++-- .../WatchAlertManager.swift | 91 +++++++++---------- .../WatchAlertSettingsView.swift | 11 +-- WatchConnectivityManager.swift | 39 ++++---- 19 files changed, 144 insertions(+), 170 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 70b5a9dae..49795c039 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -40,7 +40,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ = BLEManager.shared // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared - + WatchConnectivityManager.shared.activate() // Register for remote notifications diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 0f8a5d82a..fe8a4563d 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -95,7 +95,7 @@ final class ProfileManager { } if let trioPresets = profileData.trioOverrides { - self.trioOverrides = trioPresets.map { entry in + trioOverrides = trioPresets.map { entry in let targetQuantity = entry.target != nil ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: entry.target!) : nil return TrioOverride( name: entry.name, diff --git a/LoopFollow/LiveActivity/APNsCredentialValidator.swift b/LoopFollow/LiveActivity/APNsCredentialValidator.swift index c080dbee8..3720e52b0 100644 --- a/LoopFollow/LiveActivity/APNsCredentialValidator.swift +++ b/LoopFollow/LiveActivity/APNsCredentialValidator.swift @@ -1,3 +1,6 @@ +// LoopFollow +// APNsCredentialValidator.swift + import Foundation enum APNsCredentialValidator { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 1293fcfa9..6fb418aff 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -142,7 +142,7 @@ struct WatchOverridePreset: Codable, Identifiable { var id: String { name } var name: String var symbol: String? - var durationSeconds: TimeInterval // 0 = indefinite + var durationSeconds: TimeInterval // 0 = indefinite } // MARK: - App Group settings @@ -255,7 +255,6 @@ enum LAAppGroupSettings { return raw.compactMap { LiveActivitySlotOption(rawValue: $0) } } - // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/LoopFollow/Remote/Watch/WatchCommandDispatcher.swift b/LoopFollow/Remote/Watch/WatchCommandDispatcher.swift index ac9cf433e..aefcddf86 100644 --- a/LoopFollow/Remote/Watch/WatchCommandDispatcher.swift +++ b/LoopFollow/Remote/Watch/WatchCommandDispatcher.swift @@ -1,6 +1,5 @@ // LoopFollow // WatchCommandDispatcher.swift -// Phone-side handler for Watch remote commands relayed via WCSession. import Foundation import HealthKit diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift index 8f045bf7a..754aaa38d 100644 --- a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -1,6 +1,5 @@ +// LoopFollow // ComplicationEntryBuilder.swift -// Philippe Achkar -// 2026-03-25 import ClockKit @@ -23,7 +22,6 @@ enum ComplicationID { // MARK: - Entry builder enum ComplicationEntryBuilder { - // MARK: - Live template static func template( @@ -36,9 +34,9 @@ enum ComplicationEntryBuilder { return graphicCircularTemplate(snapshot: snapshot) case .graphicCorner: switch identifier { - case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) - case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) - default: return graphicCornerGaugeTemplate(snapshot: snapshot) + case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + default: return graphicCornerGaugeTemplate(snapshot: snapshot) } default: return nil @@ -114,6 +112,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Circular + // BG (top, colored) + trend arrow (bottom). private static func graphicCircularTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { @@ -127,6 +126,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Gauge Text (Complication 1) + // Gauge arc fills from 0 (fresh) to 100% (15 min stale). // Outer text: BG (colored). Leading text: delta. // Stale / isNotLooping → "⚠" in yellow, gauge full. @@ -167,6 +167,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Stacked Text (Complication 2) + // Outer (top, large): BG value, colored. // Inner (bottom, small): "→ projected" (falls back to delta if no projection). // Stale / isNotLooping: outer = "--", inner = "". @@ -197,6 +198,7 @@ enum ComplicationEntryBuilder { } // MARK: - Graphic Corner — Debug (Complication 3) + // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. // @@ -206,7 +208,7 @@ enum ComplicationEntryBuilder { // inner stale → reloadTimeline is not being called or ClockKit is ignoring it private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { - let dataTime = WatchFormat.updateTime(snapshot) + let dataTime = WatchFormat.updateTime(snapshot) let buildTime = WatchFormat.currentTime() return CLKComplicationTemplateGraphicCornerStackText( @@ -220,7 +222,7 @@ enum ComplicationEntryBuilder { /// snapshot.glucose is always in mg/dL (builder stores canonical mg/dL). static func thresholdColor(for snapshot: GlucoseSnapshot) -> UIColor { let t = LAAppGroupSettings.thresholdsMgdl() - if snapshot.glucose < t.low { return .red } + if snapshot.glucose < t.low { return .red } if snapshot.glucose > t.high { return .orange } return .green } diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift index 3e59c9d97..f7df784b6 100644 --- a/LoopFollow/WatchComplication/WatchComplicationProvider.swift +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -1,6 +1,5 @@ +// LoopFollow // WatchComplicationProvider.swift -// Philippe Achkar -// 2026-03-10 import ClockKit import Foundation @@ -12,7 +11,6 @@ private let watchLog = OSLog( ) final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { - // MARK: - Complication Descriptors func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { @@ -34,7 +32,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { identifier: ComplicationID.debugCorner, displayName: "LoopFollow Debug", supportedFamilies: [.graphicCorner] - ) + ), ] handler(descriptors) } @@ -53,7 +51,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { // Prefer the file store (persists across launches); fall back to the in-memory // cache in case the file write hasn't completed or the store is unavailable. guard let snapshot = GlucoseSnapshotStore.shared.load() - ?? WatchSessionReceiver.shared.lastSnapshot + ?? WatchSessionReceiver.shared.lastSnapshot else { os_log("WatchComplicationProvider: no snapshot available (store and cache both nil)", log: watchLog, type: .error) handler(nil) @@ -94,7 +92,7 @@ final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { } func getPrivacyBehavior( - for complication: CLKComplication, + for _: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { // Glucose is sensitive — hide on locked watch face diff --git a/LoopFollow/WatchComplication/WatchFormat.swift b/LoopFollow/WatchComplication/WatchFormat.swift index acc7e9ec2..194857e38 100644 --- a/LoopFollow/WatchComplication/WatchFormat.swift +++ b/LoopFollow/WatchComplication/WatchFormat.swift @@ -1,6 +1,5 @@ +// LoopFollow // WatchFormat.swift -// Philippe Achkar -// 2026-03-25 import Foundation @@ -8,7 +7,6 @@ import Foundation /// All glucose values in GlucoseSnapshot are stored in mg/dL; this module /// converts to mmol/L for display when snapshot.unit == .mmol. enum WatchFormat { - // MARK: - Glucose static func glucose(_ s: GlucoseSnapshot) -> String { @@ -37,14 +35,14 @@ enum WatchFormat { static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { - case .upFast: return "↑↑" - case .up: return "↑" - case .upSlight: return "↗" - case .flat: return "→" + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" case .downSlight: return "↘" - case .down: return "↓" - case .downFast: return "↓↓" - case .unknown: return "–" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" } } @@ -159,28 +157,28 @@ enum WatchFormat { static func slotValue(option: LiveActivitySlotOption, snapshot s: GlucoseSnapshot) -> String { switch option { - case .none: return "" - case .delta: return delta(s) + case .none: return "" + case .delta: return delta(s) case .projectedBG: return projected(s) - case .minMax: return minMax(s) - case .iob: return iob(s) - case .cob: return cob(s) - case .recBolus: return recBolus(s) - case .autosens: return autosens(s) - case .tdd: return tdd(s) - case .basal: return basal(s) - case .pump: return pump(s) + case .minMax: return minMax(s) + case .iob: return iob(s) + case .cob: return cob(s) + case .recBolus: return recBolus(s) + case .autosens: return autosens(s) + case .tdd: return tdd(s) + case .basal: return basal(s) + case .pump: return pump(s) case .pumpBattery: return pumpBattery(s) - case .battery: return battery(s) - case .target: return target(s) - case .isf: return isf(s) - case .carbRatio: return carbRatio(s) - case .sage: return age(insertTime: s.sageInsertTime) - case .cage: return age(insertTime: s.cageInsertTime) - case .iage: return age(insertTime: s.iageInsertTime) - case .carbsToday: return carbsToday(s) - case .override: return override(s) - case .profile: return profileName(s) + case .battery: return battery(s) + case .target: return target(s) + case .isf: return isf(s) + case .carbRatio: return carbRatio(s) + case .sage: return age(insertTime: s.sageInsertTime) + case .cage: return age(insertTime: s.cageInsertTime) + case .iage: return age(insertTime: s.iageInsertTime) + case .carbsToday: return carbsToday(s) + case .override: return override(s) + case .profile: return profileName(s) } } diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift index 44795aa38..82edfe6e3 100644 --- a/LoopFollow/WatchComplication/WatchSessionReceiver.swift +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -1,13 +1,12 @@ +// LoopFollow // WatchSessionReceiver.swift -// Philippe Achkar -// 2026-03-10 +import ClockKit import Foundation +import os.log import UserNotifications import WatchConnectivity -import ClockKit import WatchKit -import os.log private let watchLog = OSLog( subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", @@ -15,7 +14,6 @@ private let watchLog = OSLog( ) final class WatchSessionReceiver: NSObject { - // MARK: - Shared Instance static let shared = WatchSessionReceiver() @@ -47,7 +45,7 @@ final class WatchSessionReceiver: NSObject { // MARK: - Init - private override init() { + override private init() { super.init() } @@ -74,7 +72,6 @@ final class WatchSessionReceiver: NSObject { // MARK: - WCSessionDelegate extension WatchSessionReceiver: WCSessionDelegate { - func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, @@ -114,7 +111,7 @@ extension WatchSessionReceiver: WCSessionDelegate { /// Handles immediate delivery when Watch app is in foreground (sendMessage path). func session( - _ session: WCSession, + _: WCSession, didReceiveMessage message: [String: Any] ) { process(payload: message, source: "sendMessage") @@ -122,22 +119,22 @@ extension WatchSessionReceiver: WCSessionDelegate { /// Handles queued background delivery (transferUserInfo path). func session( - _ session: WCSession, + _: WCSession, didReceiveUserInfo userInfo: [String: Any] ) { process(payload: userInfo, source: "userInfo") } - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { process(payload: applicationContext, source: "applicationContext") } - + // MARK: - Private private func process(payload: [String: Any], source: String) { if let _ = payload["watchLoopReturn"] { let title = payload["title"] as? String ?? "Loop Confirmed ✓" - let body = payload["body"] as? String ?? "Command processed by Loop" + let body = payload["body"] as? String ?? "Command processed by Loop" let task = pendingConnectivityTask pendingConnectivityTask = nil DispatchQueue.main.async { @@ -199,7 +196,7 @@ extension WatchSessionReceiver: WCSessionDelegate { } let content = UNMutableNotificationContent() content.title = title - content.body = body + content.body = body content.sound = .default let request = UNNotificationRequest( identifier: "loop-return-\(UUID().uuidString)", @@ -247,7 +244,9 @@ extension WatchSessionReceiver: WCSessionDelegate { return } - for complication in complications { server.reloadTimeline(for: complication) } + for complication in complications { + server.reloadTimeline(for: complication) + } os_log("WatchSessionReceiver: reloadTimeline called for %d complication(s)", log: watchLog, type: .info, complications.count) } diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift index bf3006b4b..df78726e7 100644 --- a/LoopFollowWatch Watch App/ContentView.swift +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -1,10 +1,5 @@ -// -// ContentView.swift -// LoopFollowWatch Watch App -// -// Created by Philippe Achkar on 2026-03-10. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// +// LoopFollow +// ContentView.swift import Combine import SwiftUI @@ -127,7 +122,7 @@ final class WatchViewModel: ObservableObject { var pages: [[LiveActivitySlotOption]] { guard !selectedSlots.isEmpty else { return [] } return stride(from: 0, to: selectedSlots.count, by: 4).map { - Array(selectedSlots[$0.. = 30...720 // 30 min – 12 hr + private let range: ClosedRange = 30...720 // 30 min – 12 hr init(isPresented: Binding, alertType: WatchAlertType?) { - self._isPresented = isPresented - self.alertType = alertType + _isPresented = isPresented + self.alertType = alertType let s = WatchAppSettings.shared - self._snoozeMinutes = State(initialValue: Double(s.defaultSnoozeMinutes)) - self._snoozeAll = State(initialValue: s.snoozeAllByDefault) + _snoozeMinutes = State(initialValue: Double(s.defaultSnoozeMinutes)) + _snoozeAll = State(initialValue: s.snoozeAllByDefault) } var body: some View { diff --git a/LoopFollowWatch Watch App/WatchAlertManager.swift b/LoopFollowWatch Watch App/WatchAlertManager.swift index ebd753e1d..11d734573 100644 --- a/LoopFollowWatch Watch App/WatchAlertManager.swift +++ b/LoopFollowWatch Watch App/WatchAlertManager.swift @@ -1,12 +1,9 @@ +// LoopFollow // WatchAlertManager.swift -// LoopFollowWatch Watch App -// -// Haptic alert manager for the Apple Watch extension. -// See DESIGN.md for rationale and CHANGES.md for injection points. import Foundation -import UserNotifications import os.log +import UserNotifications private let alertLog = OSLog( subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", @@ -24,21 +21,21 @@ enum WatchAlertType: String, CaseIterable { var title: String { switch self { - case .lowBG: return "⚠️ Low BG" + case .lowBG: return "⚠️ Low BG" case .urgentLow: return "🚨 Urgent Low" - case .highBG: return "⚠️ High BG" - case .fastDrop: return "⬇️ Dropping Fast" - case .fastRise: return "⬆️ Rising Fast" + case .highBG: return "⚠️ High BG" + case .fastDrop: return "⬇️ Dropping Fast" + case .fastRise: return "⬆️ Rising Fast" } } var displayName: String { switch self { - case .lowBG: return "Low BG" + case .lowBG: return "Low BG" case .urgentLow: return "Urgent Low" - case .highBG: return "High BG" - case .fastDrop: return "Fast Drop" - case .fastRise: return "Fast Rise" + case .highBG: return "High BG" + case .fastDrop: return "Fast Drop" + case .fastRise: return "Fast Rise" } } @@ -51,32 +48,32 @@ enum WatchAlertType: String, CaseIterable { } } - var cooldownKey: String { "watchAlertCooldown_\(rawValue)" } - var snoozeKey: String { "watchSnoozeUntil_\(rawValue)" } + var cooldownKey: String { "watchAlertCooldown_\(rawValue)" } + var snoozeKey: String { "watchSnoozeUntil_\(rawValue)" } var notificationID: String { "lf-alert-\(rawValue)" } } // MARK: - Thresholds + // TODO: Wire to LAAppGroupSettings or App Group shared container to mirror iPhone settings. private struct WatchThresholds { - var low: Double = 70 + var low: Double = 70 var urgentLow: Double = 55 - var high: Double = 180 - var dropRate: Double = 2.0 // mg/dL per minute - var riseRate: Double = 2.0 + var high: Double = 180 + var dropRate: Double = 2.0 // mg/dL per minute + var riseRate: Double = 2.0 } // MARK: - Manager final class WatchAlertManager: NSObject { - static let shared = WatchAlertManager() - private override init() { super.init() } + override private init() { super.init() } - private let thresholds = WatchThresholds() - private let settings = WatchAppSettings.shared - private let alertQueue = DispatchQueue(label: "com.loopfollow.watch.alertManager") + private let thresholds = WatchThresholds() + private let settings = WatchAppSettings.shared + private let alertQueue = DispatchQueue(label: "com.loopfollow.watch.alertManager") private let globalSnoozeKey = "watchGlobalSnoozeUntil" @@ -87,18 +84,18 @@ final class WatchAlertManager: NSObject { /// Returns true if this alert type is currently suppressed (global OR per-type snooze active). func isSnoozed(for type: WatchAlertType) -> Bool { - let now = Date().timeIntervalSince1970 - let global = defaults.double(forKey: globalSnoozeKey) + let now = Date().timeIntervalSince1970 + let global = defaults.double(forKey: globalSnoozeKey) let perType = defaults.double(forKey: type.snoozeKey) return now < global || now < perType } /// Returns the latest active snooze expiry for this type (global or per-type), or nil. func snoozeUntil(for type: WatchAlertType) -> Date? { - let now = Date().timeIntervalSince1970 - let global = defaults.double(forKey: globalSnoozeKey) + let now = Date().timeIntervalSince1970 + let global = defaults.double(forKey: globalSnoozeKey) let perType = defaults.double(forKey: type.snoozeKey) - let until = max(global, perType) + let until = max(global, perType) return until > now ? Date(timeIntervalSince1970: until) : nil } @@ -110,7 +107,7 @@ final class WatchAlertManager: NSObject { /// Expiry date of the global snooze, or nil if not active. var globalSnoozeExpiryDate: Date? { let until = defaults.double(forKey: globalSnoozeKey) - let now = Date().timeIntervalSince1970 + let now = Date().timeIntervalSince1970 return until > now ? Date(timeIntervalSince1970: until) : nil } @@ -186,7 +183,7 @@ final class WatchAlertManager: NSObject { /// regardless of cooldown or snooze state. Used to decide whether to show the Snooze button. func hasActiveAlert(for snapshot: GlucoseSnapshot) -> Bool { guard snapshot.updatedAt > Date(timeIntervalSinceNow: -15 * 60) else { return false } - let bg = snapshot.glucose + let bg = snapshot.glucose let delta = snapshot.deltaRate return bg <= thresholds.urgentLow || bg < thresholds.low @@ -205,20 +202,20 @@ final class WatchAlertManager: NSObject { } alertQueue.sync { - let bg = snapshot.glucose + let bg = snapshot.glucose let delta = snapshot.deltaRate // UrgentLow takes full priority — nothing else fires this cycle if triggered. - let urgentTriggered = bg <= thresholds.urgentLow // inclusive: BG == threshold fires + let urgentTriggered = bg <= thresholds.urgentLow // inclusive: BG == threshold fires evaluate(.urgentLow, bg: bg, delta: delta, triggered: urgentTriggered) guard !urgentTriggered else { return } // lowBG suppresses fastDrop — if lowBG threshold is met, fastDrop is skipped. let lowTriggered = bg < thresholds.low evaluate(.lowBG, bg: bg, delta: delta, triggered: lowTriggered) - guard !lowTriggered else { return } // lowBG suppresses fastDrop + guard !lowTriggered else { return } // lowBG suppresses fastDrop - evaluate(.highBG, bg: bg, delta: delta, triggered: bg > thresholds.high) + evaluate(.highBG, bg: bg, delta: delta, triggered: bg > thresholds.high) evaluate(.fastDrop, bg: bg, delta: delta, triggered: delta.map { $0 < -thresholds.dropRate } ?? false) evaluate(.fastRise, bg: bg, delta: delta, triggered: delta.map { $0 > thresholds.riseRate } ?? false) } @@ -229,7 +226,7 @@ final class WatchAlertManager: NSObject { private func evaluate(_ type: WatchAlertType, bg: Double, delta: Double?, triggered: Bool) { guard triggered, !isSnoozed(for: type) else { return } - let now = Date().timeIntervalSince1970 + let now = Date().timeIntervalSince1970 let lastFired = defaults.double(forKey: type.cooldownKey) guard now - lastFired >= settings.cooldown(for: type) else { return } @@ -239,16 +236,16 @@ final class WatchAlertManager: NSObject { private func fire(type: WatchAlertType, bg: Double, delta: Double?) { let content = UNMutableNotificationContent() - content.title = type.title - content.body = type.body(bg: bg, delta: delta) - content.sound = nil // haptic only — no audio - content.interruptionLevel = (type == .urgentLow) ? .timeSensitive : .active + content.title = type.title + content.body = type.body(bg: bg, delta: delta) + content.sound = nil // haptic only — no audio + content.interruptionLevel = (type == .urgentLow) ? .timeSensitive : .active content.categoryIdentifier = "GLUCOSE_ALERT" let request = UNNotificationRequest( - identifier: type.notificationID, // stable ID — replaces any pending same-type alert + identifier: type.notificationID, // stable ID — replaces any pending same-type alert content: content, - trigger: nil // immediate delivery + trigger: nil // immediate delivery ) UNUserNotificationCenter.current().add(request) { error in if let error = error { @@ -265,13 +262,12 @@ final class WatchAlertManager: NSObject { // MARK: - UNUserNotificationCenterDelegate extension WatchAlertManager: UNUserNotificationCenterDelegate { - func userNotificationCenter( - _ center: UNUserNotificationCenter, + _: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - let typeRaw = response.notification.request.identifier + let typeRaw = response.notification.request.identifier .replacingOccurrences(of: "lf-alert-", with: "") let alertType = WatchAlertType(rawValue: typeRaw) @@ -287,6 +283,7 @@ extension WatchAlertManager: UNUserNotificationCenterDelegate { userInfo: alertType.map { ["alertType": $0.rawValue] } ) } + default: break } @@ -294,8 +291,8 @@ extension WatchAlertManager: UNUserNotificationCenterDelegate { } func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, + _: UNUserNotificationCenter, + willPresent _: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .badge]) diff --git a/LoopFollowWatch Watch App/WatchAlertSettingsView.swift b/LoopFollowWatch Watch App/WatchAlertSettingsView.swift index 42a7ce80b..5bb382de5 100644 --- a/LoopFollowWatch Watch App/WatchAlertSettingsView.swift +++ b/LoopFollowWatch Watch App/WatchAlertSettingsView.swift @@ -1,8 +1,5 @@ +// LoopFollow // WatchAlertSettingsView.swift -// LoopFollowWatch Watch App -// -// Alert settings tab. Presented inside a NavigationStack as the last tab in ContentView. -// TODO: Add per-type enabled/disabled toggle when surfacing WatchAlertConfig.enabled. import SwiftUI @@ -73,7 +70,7 @@ struct WatchAlertSettingsView: View { } private func formattedMinutes(_ mins: Int) -> String { - mins < 60 ? "\(mins)m" : (mins % 60 == 0 ? "\(mins/60)h" : "\(mins/60)h \(mins%60)m") + mins < 60 ? "\(mins)m" : (mins % 60 == 0 ? "\(mins / 60)h" : "\(mins / 60)h \(mins % 60)m") } private func formattedSeconds(_ secs: TimeInterval) -> String { @@ -106,7 +103,7 @@ private struct SnoozeDefaultPickerView: View { } private func label(for mins: Int) -> String { - mins < 60 ? "\(mins) min" : (mins % 60 == 0 ? "\(mins/60) hr" : "\(mins/60)h \(mins%60)m") + mins < 60 ? "\(mins) min" : (mins % 60 == 0 ? "\(mins / 60) hr" : "\(mins / 60)h \(mins % 60)m") } } @@ -139,6 +136,6 @@ private struct CooldownPickerView: View { private func label(for secs: TimeInterval) -> String { let mins = Int(secs) / 60 - return mins < 60 ? "\(mins) min" : "\(mins/60) hr" + return mins < 60 ? "\(mins) min" : "\(mins / 60) hr" } } diff --git a/WatchConnectivityManager.swift b/WatchConnectivityManager.swift index b92248e68..e1d76653e 100644 --- a/WatchConnectivityManager.swift +++ b/WatchConnectivityManager.swift @@ -1,20 +1,11 @@ -// WatchConnectivityManager.swift -// LoopFollow -// -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - - +// LoopFollow // WatchConnectivityManager.swift -// Philippe Achkar -// 2026-03-10 import Combine import Foundation import WatchConnectivity final class WatchConnectivityManager: NSObject { - // MARK: - Shared Instance static let shared = WatchConnectivityManager() @@ -27,7 +18,7 @@ final class WatchConnectivityManager: NSObject { private var lastWatchCommandDate: Date = .distantPast private var cancellables = Set() - private override init() { + override private init() { super.init() } @@ -88,7 +79,7 @@ final class WatchConnectivityManager: NSObject { // transferUserInfo: guaranteed queued delivery for background wakes. session.transferUserInfo(payload) - + // applicationContext: latest-state mirror for next launch / scheduled refresh. do { try session.updateApplicationContext(payload) @@ -98,7 +89,7 @@ final class WatchConnectivityManager: NSObject { message: "WatchConnectivityManager: failed to update applicationContext — \(error)" ) } - + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo") } catch { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)") @@ -109,7 +100,6 @@ final class WatchConnectivityManager: NSObject { // MARK: - WCSessionDelegate extension WatchConnectivityManager: WCSessionDelegate { - func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, @@ -127,7 +117,7 @@ extension WatchConnectivityManager: WCSessionDelegate { /// When the Watch app comes to the foreground, send the latest snapshot immediately /// so the Watch app has fresh data without waiting for the next BG poll. /// Receives ACKs from the Watch (sent after each snapshot is saved). - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + func session(_: WCSession, didReceiveMessage message: [String: Any]) { if let ackTimestamp = message["watchAck"] as? TimeInterval { lastWatchAckTimestamp = ackTimestamp LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)") @@ -135,14 +125,17 @@ extension WatchConnectivityManager: WCSessionDelegate { } /// Handles remote command messages sent from the Watch with a reply handler. - func session(_ session: WCSession, didReceiveMessage message: [String: Any], - replyHandler: @escaping ([String: Any]) -> Void) { + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: received Watch command — \(message["watchCmd"] as? String ?? "unknown")") lastWatchCommandDate = Date() WatchCommandDispatcher.shared.handle(message: message, replyHandler: replyHandler) } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { if let ackTimestamp = userInfo["watchAck"] as? TimeInterval { lastWatchAckTimestamp = ackTimestamp LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)") @@ -159,11 +152,11 @@ extension WatchConnectivityManager: WCSessionDelegate { } } - func sessionDidBecomeInactive(_ session: WCSession) { + func sessionDidBecomeInactive(_: WCSession) { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive") } - func sessionDidDeactivate(_ session: WCSession) { + func sessionDidDeactivate(_: WCSession) { LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating") WCSession.default.activate() } @@ -207,8 +200,8 @@ extension WatchConnectivityManager: WCSessionDelegate { if let commandType = userInfo["command_type"] as? String { switch commandType.lowercased() { - case "bolus": title = "Bolus Confirmed ✓" - case "carbs": title = "Carbs Confirmed ✓" + case "bolus": title = "Bolus Confirmed ✓" + case "carbs": title = "Carbs Confirmed ✓" case "override": title = "Override Confirmed ✓" default: break } @@ -217,7 +210,7 @@ extension WatchConnectivityManager: WCSessionDelegate { if let aps = userInfo["aps"] as? [String: Any] { if let alert = aps["alert"] as? [String: Any] { title = alert["title"] as? String ?? title - body = alert["body"] as? String ?? body + body = alert["body"] as? String ?? body } else if let alertStr = aps["alert"] as? String { body = alertStr }