Просмотр исходного кода

Merge branch 'dev' into remote-apns-feedback

Daniel Mini Johansson 10 месяцев назад
Родитель
Сommit
59401e6e88

+ 1 - 1
Config.xcconfig

@@ -6,4 +6,4 @@
 unique_id = ${DEVELOPMENT_TEAM}
 
 //Version (DEFAULT)
-LOOP_FOLLOW_MARKETING_VERSION = 3.2.0
+LOOP_FOLLOW_MARKETING_VERSION = 3.2.7

+ 1 - 0
Gemfile

@@ -4,3 +4,4 @@ source "https://rubygems.org"
 
 # This branch uses fastlane 2.228.0 plus pr 29596
 gem "fastlane",  git: "https://github.com/loopandlearn/fastlane.git", ref: "a670d4b092b274d58ebb5497126e47fc6a84f533"
+gem "rexml", ">=3.4.2"

+ 17 - 14
Gemfile.lock

@@ -58,25 +58,27 @@ GEM
     artifactory (3.0.17)
     atomos (0.1.3)
     aws-eventstream (1.4.0)
-    aws-partitions (1.1116.0)
-    aws-sdk-core (3.225.2)
+    aws-partitions (1.1163.0)
+    aws-sdk-core (3.232.0)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
       base64
+      bigdecimal
       jmespath (~> 1, >= 1.6.1)
       logger
-    aws-sdk-kms (1.105.0)
-      aws-sdk-core (~> 3, >= 3.225.0)
+    aws-sdk-kms (1.112.0)
+      aws-sdk-core (~> 3, >= 3.231.0)
       aws-sigv4 (~> 1.5)
-    aws-sdk-s3 (1.189.1)
-      aws-sdk-core (~> 3, >= 3.225.0)
+    aws-sdk-s3 (1.199.0)
+      aws-sdk-core (~> 3, >= 3.231.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.5)
     aws-sigv4 (1.12.1)
       aws-eventstream (~> 1, >= 1.0.2)
     babosa (1.0.4)
     base64 (0.3.0)
+    bigdecimal (3.2.3)
     claide (1.1.0)
     colored (1.2)
     colored2 (3.1.2)
@@ -105,10 +107,10 @@ GEM
       faraday (>= 0.8.0)
       http-cookie (~> 1.0.0)
     faraday-em_http (1.0.0)
-    faraday-em_synchrony (1.0.0)
+    faraday-em_synchrony (1.0.1)
     faraday-excon (1.1.0)
     faraday-httpclient (1.0.1)
-    faraday-multipart (1.1.0)
+    faraday-multipart (1.1.1)
       multipart-post (~> 2.0)
     faraday-net_http (1.0.2)
     faraday-net_http_persistent (1.2.0)
@@ -163,13 +165,13 @@ GEM
     httpclient (2.9.0)
       mutex_m
     jmespath (1.6.2)
-    json (2.12.2)
-    jwt (2.10.1)
+    json (2.15.0)
+    jwt (2.10.2)
       base64
     logger (1.7.0)
     mini_magick (4.13.2)
     mini_mime (1.1.5)
-    multi_json (1.15.0)
+    multi_json (1.17.0)
     multipart-post (2.4.1)
     mutex_m (0.3.0)
     nanaimo (0.4.0)
@@ -185,15 +187,15 @@ GEM
       trailblazer-option (>= 0.1.1, < 0.2.0)
       uber (< 0.2.0)
     retriable (3.1.2)
-    rexml (3.4.1)
+    rexml (3.4.4)
     rouge (3.28.0)
     ruby2_keywords (0.0.5)
     rubyzip (2.4.1)
     security (0.1.5)
-    signet (0.20.0)
+    signet (0.21.0)
       addressable (~> 2.8)
       faraday (>= 0.17.5, < 3.a)
-      jwt (>= 1.5, < 3.0)
+      jwt (>= 1.5, < 4.0)
       multi_json (~> 1.10)
     simctl (1.6.10)
       CFPropertyList
@@ -233,6 +235,7 @@ PLATFORMS
 
 DEPENDENCIES
   fastlane!
+  rexml (>= 3.4.2)
 
 BUNDLED WITH
    2.6.2

+ 24 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -13,7 +13,12 @@
 		654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; };
 		6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; };
 		6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; };
+		656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; };
+		656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; };
+		656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; };
+		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
+		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
 		DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
@@ -79,6 +84,7 @@
 		DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE42ACF2383009A6922 /* Treatments.swift */; };
 		DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */; };
 		DD493AE92ACF2445009A6922 /* BGData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE82ACF2445009A6922 /* BGData.swift */; };
+		DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A407D2E6AFEE6007B318B /* AuthService.swift */; };
 		DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */; };
 		DD4AFB3D2DB55D2900BB593F /* AlarmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */; };
 		DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */; };
@@ -399,7 +405,12 @@
 		654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = "<group>"; };
 		654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = "<group>"; };
 		6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = "<group>"; };
+		656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
+		656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
+		656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
+		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
+		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
@@ -464,6 +475,7 @@
 		DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
 		DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
 		DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
+		DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
 		DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = "<group>"; };
 		DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = "<group>"; };
 		DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSettingsView.swift; sourceTree = "<group>"; };
@@ -916,6 +928,8 @@
 		DD4878062C7B2E9E0048F05C /* Settings */ = {
 			isa = PBXGroup;
 			children = (
+				65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */,
+				656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */,
 				DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */,
 				DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */,
 			);
@@ -1221,6 +1235,7 @@
 		DDF6999C2C5AAA4C0058A8D9 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */,
 				DDE75D2C2DE71401007C1FC1 /* TogglableSecureInput.swift */,
 				DDE75D222DE5E505007C1FC1 /* Glyph.swift */,
 				DD8316492DE4C504004467AA /* SettingsStepperRow.swift */,
@@ -1283,6 +1298,7 @@
 				FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */,
 				FCA2DDE52501095000254A8C /* Timers.swift */,
 				DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */,
+				65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */,
 			);
 			path = Controllers;
 			sourceTree = "<group>";
@@ -1489,6 +1505,8 @@
 		FCC688542489367300A0279D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
+				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
 				DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
 				DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
@@ -1881,10 +1899,12 @@
 				DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */,
 				DD7F4C0F2DD51EC200D449E9 /* TempTargetStartCondition.swift in Sources */,
 				DDBD19962DFB44B0005C2D69 /* Alarm+byPriorityThenSpec.swift in Sources */,
+				656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */,
 				DDC6CA3D2DD7C6090060EE25 /* TemporaryCondition.swift in Sources */,
 				DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */,
 				DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */,
 				DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */,
+				656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */,
 				6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */,
 				FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */,
 				DD7F4C092DD504A700D449E9 /* OverrideStartCondition.swift in Sources */,
@@ -1901,6 +1921,7 @@
 				DD7F4C072DD5042F00D449E9 /* OverrideStartAlarmEditor.swift in Sources */,
 				DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */,
 				DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */,
+				656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */,
 				DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */,
 				DD83164A2DE4C504004467AA /* SettingsStepperRow.swift in Sources */,
 				DD0650ED2DCE9371004D3B41 /* HighBgAlarmEditor.swift in Sources */,
@@ -1954,6 +1975,7 @@
 				DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
 				DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */,
+				65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */,
 				DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */,
 				DDE75D292DE5E56C007C1FC1 /* LinkRow.swift in Sources */,
 				DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */,
@@ -2020,6 +2042,7 @@
 				DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */,
 				DDB9FC7F2DDB584500EFAA76 /* BolusEntry.swift in Sources */,
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
+				DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */,
 				654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */,
 				DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
@@ -2052,6 +2075,7 @@
 				DD0650EF2DCE96FF004D3B41 /* HighBGCondition.swift in Sources */,
 				DDC6CA472DD8D9010060EE25 /* PumpChangeAlarmEditor.swift in Sources */,
 				DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */,
+				65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */,
 				DD0C0C682C48529400DBADDF /* Metric.swift in Sources */,
 				FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */,
 				DDC6CA432DD8CED20060EE25 /* SensorAgeCondition.swift in Sources */,

+ 3 - 1
LoopFollow/Alarm/AlarmConfiguration.swift

@@ -18,6 +18,7 @@ struct AlarmConfiguration: Codable, Equatable {
     var audioDuringCalls: Bool
     var ignoreZeroBG: Bool
     var autoSnoozeCGMStart: Bool
+    var enableVolumeButtonSnooze: Bool
 
     static let `default` = AlarmConfiguration(
         muteUntil: nil,
@@ -27,6 +28,7 @@ struct AlarmConfiguration: Codable, Equatable {
         forcedOutputVolume: 0.5,
         audioDuringCalls: true,
         ignoreZeroBG: true,
-        autoSnoozeCGMStart: false
+        autoSnoozeCGMStart: false,
+        enableVolumeButtonSnooze: false
     )
 }

+ 1 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -163,6 +163,7 @@ class AlarmManager {
                 alarms[idx].snoozedUntil = Date().addingTimeInterval(snoozeSeconds)
                 Storage.shared.alarms.value = alarms
             }
+            Observable.shared.alarmSoundPlaying.value = false
             stopAlarm()
         }
     }

+ 9 - 1
LoopFollow/Alarm/AlarmSettingsView.swift

@@ -177,12 +177,20 @@ struct AlarmSettingsView: View {
                     )
 
                     Toggle(
-                        "Auto‑Snooze CGM Start",
+                        "Auto‑Snooze CGM Start",
                         isOn: Binding(
                             get: { cfgStore.value.autoSnoozeCGMStart },
                             set: { cfgStore.value.autoSnoozeCGMStart = $0 }
                         )
                     )
+
+                    Toggle(
+                        "Volume Buttons Snooze Alarms",
+                        isOn: Binding(
+                            get: { cfgStore.value.enableVolumeButtonSnooze },
+                            set: { cfgStore.value.enableVolumeButtonSnooze = $0 }
+                        )
+                    )
                 }
             }
         }

+ 2 - 1
LoopFollow/Application/AppDelegate.swift

@@ -38,12 +38,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         UNUserNotificationCenter.current().delegate = self
 
         _ = BLEManager.shared
+        // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
+        _ = VolumeButtonHandler.shared
 
         // Register for remote notifications
         DispatchQueue.main.async {
             UIApplication.shared.registerForRemoteNotifications()
         }
-
         return true
     }
 

+ 6 - 4
LoopFollow/Controllers/AlarmSound.swift

@@ -24,8 +24,6 @@ class AlarmSound {
 
     fileprivate static var systemOutputVolumeBeforeOverride: Float?
 
-    fileprivate static var playingTimer: Timer?
-
     fileprivate static var soundURL = Bundle.main.url(forResource: "Indeed", withExtension: "caf")!
     fileprivate static var audioPlayer: AVAudioPlayer?
     fileprivate static let audioPlayerDelegate = AudioPlayerDelegate()
@@ -70,8 +68,7 @@ class AlarmSound {
     }
 
     static func stop() {
-        playingTimer?.invalidate()
-        playingTimer = nil
+        Observable.shared.alarmSoundPlaying.value = false
 
         audioPlayer?.stop()
         audioPlayer = nil
@@ -140,6 +137,8 @@ class AlarmSound {
                 if !isPlaying {
                     LogManager.shared.log(category: .alarm, message: "AlarmSound - not playing after calling play")
                     LogManager.shared.log(category: .alarm, message: "AlarmSound - rate value: \(audioPlayer!.rate)")
+                } else {
+                    Observable.shared.alarmSoundPlaying.value = true
                 }
             } else {
                 LogManager.shared.log(category: .alarm, message: "AlarmSound - audio player failed to play")
@@ -223,6 +222,7 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
     /* audioPlayerDidFinishPlaying:successfully: is called when a sound has finished playing. This method is NOT called if the player is stopped due to an interruption. */
     func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerDidFinishPlaying (\(flag))", isDebug: true)
+        Observable.shared.alarmSoundPlaying.value = false
     }
 
     /* if an error occurs while decoding it will be reported to the delegate. */
@@ -239,12 +239,14 @@ class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate {
     /* audioPlayerBeginInterruption: is called when the audio session has been interrupted while the player was playing. The player will have been paused. */
     func audioPlayerBeginInterruption(_: AVAudioPlayer) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerBeginInterruption")
+        Observable.shared.alarmSoundPlaying.value = false
     }
 
     /* audioPlayerEndInterruption:withOptions: is called when the audio session interruption has ended and this player had been interrupted while playing. */
     /* Currently the only flag is AVAudioSessionInterruptionFlags_ShouldResume. */
     func audioPlayerEndInterruption(_: AVAudioPlayer, withOptions flags: Int) {
         LogManager.shared.log(category: .alarm, message: "AlarmRule - audioPlayerEndInterruption withOptions: \(flags)")
+        Observable.shared.alarmSoundPlaying.value = false
     }
 }
 

+ 182 - 0
LoopFollow/Controllers/VolumeButtonHandler.swift

@@ -0,0 +1,182 @@
+// LoopFollow
+// VolumeButtonHandler.swift
+
+import AVFoundation
+import Combine
+import Foundation
+import UIKit
+
+class VolumeButtonHandler: NSObject {
+    static let shared = VolumeButtonHandler()
+
+    // Volume button snoozer activation delay in seconds
+    private let volumeButtonActivationDelay: TimeInterval = 0.9
+
+    // Volume button detection parameters
+    private let volumeButtonPressThreshold: Float = 0.02
+    private let volumeButtonPressTimeWindow: TimeInterval = 0.3
+    private let volumeButtonCooldown: TimeInterval = 0.5
+
+    // KVO observer for system volume
+    private var volumeObserver: NSKeyValueObservation?
+
+    private var lastVolume: Float = 0.0
+    private var isMonitoring = false
+    private var alarmStartTime: Date?
+    private var lastVolumeButtonPressTime: Date?
+
+    // Button press detection
+    private var recentVolumeChanges: [(volume: Float, timestamp: Date)] = []
+    private var lastSignificantVolumeChange: Date?
+    private var volumeChangePattern: [TimeInterval] = []
+
+    private var cancellables = Set<AnyCancellable>()
+
+    override private init() {
+        super.init()
+
+        Observable.shared.alarmSoundPlaying.$value
+            .removeDuplicates()
+            .sink { [weak self] alarmSoundPlaying in
+                guard let self = self else { return }
+                if alarmSoundPlaying {
+                    self.alarmStarted()
+                } else {
+                    self.alarmStopped()
+                }
+            }
+            .store(in: &cancellables)
+    }
+
+    private func recordVolumeChange(currentVolume: Float, timestamp: Date) {
+        recentVolumeChanges.append((volume: currentVolume, timestamp: timestamp))
+
+        let cutoffTime = timestamp.timeIntervalSinceReferenceDate - volumeButtonPressTimeWindow
+        recentVolumeChanges = recentVolumeChanges.filter { $0.timestamp.timeIntervalSinceReferenceDate > cutoffTime }
+
+        if let lastChange = lastSignificantVolumeChange {
+            let timeSinceLastChange = timestamp.timeIntervalSince(lastChange)
+            volumeChangePattern.append(timeSinceLastChange)
+
+            if volumeChangePattern.count > 5 {
+                volumeChangePattern.removeFirst()
+            }
+        }
+        lastSignificantVolumeChange = timestamp
+    }
+
+    private func isLikelyVolumeButtonPress(volumeDifference: Float, timestamp: Date) -> Bool {
+        let isReasonableChange = volumeDifference >= 0.03 && volumeDifference <= 0.12
+        let isDiscreteChange = recentVolumeChanges.count <= 2
+        let hasConsistentTiming = volumeChangePattern.isEmpty || volumeChangePattern.last! >= 0.15
+        let isNotRapidSequence = recentVolumeChanges.count < 3 ||
+            (recentVolumeChanges.count >= 3 &&
+                recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }.enumerated().dropFirst().allSatisfy { index, timestamp in
+                    let previousTimestamp = recentVolumeChanges.suffix(3).map { $0.timestamp.timeIntervalSinceReferenceDate }[index - 1]
+                    return timestamp - previousTimestamp > 0.08
+                })
+
+        return isReasonableChange && isDiscreteChange && hasConsistentTiming && isNotRapidSequence
+    }
+
+    private func snoozeActiveAlarm() {
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Snoozing alarm")
+
+        lastVolumeButtonPressTime = Date()
+        AlarmManager.shared.performSnooze()
+
+        let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
+        impactFeedback.impactOccurred()
+    }
+
+    private func alarmStarted() {
+        guard Storage.shared.alarmConfiguration.value.enableVolumeButtonSnooze else { return }
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm start detected, setting up volume observer.")
+
+        alarmStartTime = Date()
+        recentVolumeChanges.removeAll()
+        lastSignificantVolumeChange = nil
+        volumeChangePattern.removeAll()
+
+        startMonitoring()
+    }
+
+    private func alarmStopped() {
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Alarm stop detected")
+
+        alarmStartTime = nil
+        stopMonitoring()
+
+        recentVolumeChanges.removeAll()
+        lastSignificantVolumeChange = nil
+        volumeChangePattern.removeAll()
+    }
+
+    func startMonitoring() {
+        guard !isMonitoring else { return }
+
+        isMonitoring = true
+
+        volumeObserver = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] session, _ in
+            guard let self = self, let alarmStartTime = self.alarmStartTime else { return }
+
+            let currentVolume = session.outputVolume
+            let now = Date()
+
+            // On the first observation, capture the initial volume when the audio session
+            // becomes active. This solves the race condition. We then return to avoid
+            // treating this initial setup as a user-initiated button press.
+            if self.lastVolume == 0.0, currentVolume > 0.0 {
+                LogManager.shared.log(category: .volumeButtonSnooze, message: "Observer received initial valid volume: \(currentVolume)")
+                self.lastVolume = currentVolume
+                return
+            }
+
+            guard self.lastVolume > 0.0 else { return }
+
+            let volumeDifference = abs(currentVolume - self.lastVolume)
+
+            if volumeDifference > self.volumeButtonPressThreshold {
+                let timeSinceAlarmStart = now.timeIntervalSince(alarmStartTime)
+
+                // Ignore volume changes from the alarm system's own ramp-up.
+                if timeSinceAlarmStart < 2.0, currentVolume > self.lastVolume {
+                    if volumeDifference <= 0.15, timeSinceAlarmStart < 1.5 {
+                        self.lastVolume = currentVolume
+                        return
+                    }
+                }
+
+                self.recordVolumeChange(currentVolume: currentVolume, timestamp: now)
+
+                if timeSinceAlarmStart > self.volumeButtonActivationDelay {
+                    if let lastPress = self.lastVolumeButtonPressTime {
+                        let timeSinceLastPress = now.timeIntervalSince(lastPress)
+                        if timeSinceLastPress < self.volumeButtonCooldown {
+                            self.lastVolume = currentVolume
+                            return
+                        }
+                    }
+
+                    if self.isLikelyVolumeButtonPress(volumeDifference: volumeDifference, timestamp: now) {
+                        self.snoozeActiveAlarm()
+                    }
+                }
+            }
+            self.lastVolume = currentVolume
+        }
+    }
+
+    func stopMonitoring() {
+        guard isMonitoring else { return }
+
+        LogManager.shared.log(category: .volumeButtonSnooze, message: "Invalidating volume observer.")
+
+        // Invalidate the observer to stop receiving notifications and prevent memory leaks.
+        volumeObserver?.invalidate()
+        volumeObserver = nil
+
+        isMonitoring = false
+        lastVolume = 0.0 // Reset for the next alarm.
+    }
+}

+ 55 - 0
LoopFollow/Helpers/AuthService.swift

@@ -0,0 +1,55 @@
+// LoopFollow
+// AuthService.swift
+
+import Foundation
+import LocalAuthentication
+
+public enum AuthResult {
+    case success
+    case canceled
+    case unavailable
+    case failed
+}
+
+public enum AuthService {
+    /// Unified authentication that prefers biometrics and falls back to passcode automatically.
+    /// - Parameters:
+    ///   - reason: Shown in the system auth prompt.
+    ///   - reuseDuration: Optional Touch ID/Face ID reuse window (seconds). 0 disables reuse.
+    ///   - completion: Returns an `AuthResult` representing the outcome.
+    public static func authenticate(reason: String,
+                                    reuseDuration: TimeInterval = 0,
+                                    completion: @escaping (AuthResult) -> Void)
+    {
+        let context = LAContext()
+        context.localizedFallbackTitle = "Enter Passcode"
+        if reuseDuration > 0 {
+            context.touchIDAuthenticationAllowableReuseDuration = reuseDuration
+        }
+
+        var error: NSError?
+        guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
+            completion(.unavailable)
+            return
+        }
+
+        context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, err in
+            DispatchQueue.main.async {
+                if success {
+                    completion(.success)
+                    return
+                }
+                if let e = err as? LAError {
+                    switch e.code {
+                    case .userCancel, .systemCancel, .appCancel:
+                        completion(.canceled)
+                    default:
+                        completion(.failed)
+                    }
+                } else {
+                    completion(.failed)
+                }
+            }
+        }
+    }
+}

+ 94 - 0
LoopFollow/Helpers/QRCodeGenerator.swift

@@ -0,0 +1,94 @@
+// LoopFollow
+// QRCodeGenerator.swift
+
+import CoreImage
+import UIKit
+
+enum QRCodeGenerator {
+    /// Generates a QR code image from a string
+    /// - Parameters:
+    ///   - string: The string to encode in the QR code
+    ///   - size: The size of the generated image (default: 200x200)
+    ///   - correctionLevel: The error correction level (default: .M)
+    /// - Returns: A UIImage containing the QR code, or nil if generation fails
+    static func generateQRCode(
+        from string: String,
+        size: CGSize = CGSize(width: 200, height: 200),
+        correctionLevel: String = "M"
+    ) -> UIImage? {
+        // Create a CIFilter for QR code generation
+        guard let filter = CIFilter(name: "CIQRCodeGenerator") else {
+            return nil
+        }
+
+        // Set the input data (the string to encode)
+        let data = string.data(using: .utf8)
+        filter.setValue(data, forKey: "inputMessage")
+
+        // Set the error correction level
+        filter.setValue(correctionLevel, forKey: "inputCorrectionLevel")
+
+        // Get the output image
+        guard let outputImage = filter.outputImage else {
+            return nil
+        }
+
+        // Scale the image to the desired size
+        let scaleX = size.width / outputImage.extent.size.width
+        let scaleY = size.height / outputImage.extent.size.height
+        let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
+
+        // Convert CIImage to UIImage
+        let context = CIContext()
+        guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
+            return nil
+        }
+
+        return UIImage(cgImage: cgImage)
+    }
+
+    /// Generates a QR code image with custom colors
+    /// - Parameters:
+    ///   - string: The string to encode in the QR code
+    ///   - size: The size of the generated image (default: 200x200)
+    ///   - foregroundColor: The color of the QR code (default: black)
+    ///   - backgroundColor: The background color (default: white)
+    ///   - correctionLevel: The error correction level (default: .M)
+    /// - Returns: A UIImage containing the QR code, or nil if generation fails
+    static func generateQRCode(
+        from string: String,
+        size: CGSize = CGSize(width: 200, height: 200),
+        foregroundColor: UIColor = .black,
+        backgroundColor: UIColor = .white,
+        correctionLevel: String = "M"
+    ) -> UIImage? {
+        // First generate the basic QR code
+        guard let qrCodeImage = generateQRCode(from: string, size: size, correctionLevel: correctionLevel) else {
+            return nil
+        }
+
+        // Create a new image context with the desired size
+        UIGraphicsBeginImageContextWithOptions(size, false, 0)
+        defer { UIGraphicsEndImageContext() }
+
+        guard let context = UIGraphicsGetCurrentContext() else {
+            return nil
+        }
+
+        // Fill the background
+        backgroundColor.setFill()
+        context.fill(CGRect(origin: .zero, size: size))
+
+        // Draw the QR code with the foreground color
+        context.setFillColor(foregroundColor.cgColor)
+        context.setBlendMode(.sourceIn)
+
+        // Create a mask from the original QR code
+        if let cgImage = qrCodeImage.cgImage {
+            let maskImage = UIImage(cgImage: cgImage)
+            maskImage.draw(in: CGRect(origin: .zero, size: size))
+        }
+
+        return UIGraphicsGetImageFromCurrentImageContext()
+    }
+}

+ 76 - 0
LoopFollow/Helpers/Views/QRCodeDisplayView.swift

@@ -0,0 +1,76 @@
+// LoopFollow
+// QRCodeDisplayView.swift
+
+import SwiftUI
+import UIKit
+
+struct QRCodeDisplayView: View {
+    let qrCodeString: String
+    let size: CGSize
+    let foregroundColor: UIColor
+    let backgroundColor: UIColor
+
+    @State private var qrCodeImage: UIImage?
+
+    init(
+        qrCodeString: String,
+        size: CGSize = CGSize(width: 250, height: 250),
+        foregroundColor: UIColor = .black,
+        backgroundColor: UIColor = .white
+    ) {
+        self.qrCodeString = qrCodeString
+        self.size = size
+        self.foregroundColor = foregroundColor
+        self.backgroundColor = backgroundColor
+    }
+
+    var body: some View {
+        VStack(spacing: 16) {
+            if let qrCodeImage = qrCodeImage {
+                Image(uiImage: qrCodeImage)
+                    .resizable()
+                    .aspectRatio(contentMode: .fit)
+                    .frame(width: size.width, height: size.height)
+                    .cornerRadius(12)
+                    .shadow(radius: 4)
+            } else {
+                RoundedRectangle(cornerRadius: 12)
+                    .fill(Color.gray.opacity(0.3))
+                    .frame(width: size.width, height: size.height)
+                    .overlay(
+                        ProgressView()
+                            .scaleEffect(1.5)
+                    )
+            }
+
+            Text("Scan this QR code with another LoopFollow app to import remote command settings")
+                .font(.caption)
+                .foregroundColor(.secondary)
+                .multilineTextAlignment(.center)
+                .padding(.horizontal, 20)
+        }
+        .onAppear {
+            generateQRCode()
+        }
+    }
+
+    private func generateQRCode() {
+        DispatchQueue.global(qos: .userInitiated).async {
+            let image = QRCodeGenerator.generateQRCode(
+                from: qrCodeString,
+                size: size,
+                foregroundColor: foregroundColor,
+                backgroundColor: backgroundColor
+            )
+
+            DispatchQueue.main.async {
+                self.qrCodeImage = image
+            }
+        }
+    }
+}
+
+#Preview {
+    QRCodeDisplayView(qrCodeString: "https://example.com/test")
+        .padding()
+}

+ 35 - 27
LoopFollow/Helpers/Views/TogglableSecureInput.swift

@@ -20,18 +20,22 @@ struct TogglableSecureInput: View {
             Group {
                 switch style {
                 case .singleLine:
-                    if isVisible {
-                        TextField(placeholder, text: $text)
-                            .multilineTextAlignment(.trailing)
-                            .textContentType(textContentType)
-                            .submitLabel(.done)
-                            .focused($isFocused)
-                    } else {
-                        SecureField(placeholder, text: $text)
-                            .multilineTextAlignment(.trailing)
-                            .textContentType(textContentType)
-                            .submitLabel(.done)
-                            .focused($isFocused)
+                    ZStack(alignment: .trailing) {
+                        if isVisible {
+                            TextField(placeholder, text: $text)
+                                .multilineTextAlignment(.trailing)
+                                .textContentType(textContentType)
+                                .submitLabel(.done)
+                                .focused($isFocused)
+                        } else {
+                            HStack {
+                                Spacer()
+                                Text(maskString)
+                                    .font(.body.monospaced())
+                                    .foregroundColor(.primary)
+                                    .allowsHitTesting(false)
+                            }
+                        }
                     }
 
                 case .multiLine:
@@ -53,15 +57,13 @@ struct TogglableSecureInput: View {
                             }
 
                         if !isVisible {
-                            Text(maskString)
-                                .font(.body.monospaced())
-                                .foregroundColor(.primary)
-                                .frame(maxWidth: .infinity,
-                                       maxHeight: .infinity,
-                                       alignment: .topLeading)
-                                .padding(.top, 8)
-                                .padding(.leading, 5)
-                                .allowsHitTesting(false)
+                            HStack {
+                                Spacer()
+                                Text(maskString)
+                                    .font(.body.monospaced())
+                                    .foregroundColor(.primary)
+                                    .allowsHitTesting(false)
+                            }
                         }
                     }
                     .frame(minHeight: 100)
@@ -80,13 +82,19 @@ struct TogglableSecureInput: View {
         }
         .contentShape(Rectangle())
         .onTapGesture {
-            if style == .multiLine && !isVisible {
+            if !isVisible {
                 isVisible = true
-                isMultilineFocused = true
-            } else if style == .singleLine {
-                isFocused = true
-            } else if style == .multiLine && isVisible {
-                isMultilineFocused = true
+                if style == .singleLine {
+                    isFocused = true
+                } else if style == .multiLine {
+                    isMultilineFocused = true
+                }
+            } else {
+                if style == .singleLine {
+                    isFocused = true
+                } else if style == .multiLine {
+                    isMultilineFocused = true
+                }
             }
         }
     }

+ 2 - 0
LoopFollow/Log/LogManager.swift

@@ -25,8 +25,10 @@ class LogManager {
         case taskScheduler = "Task Scheduler"
         case dexcom = "Dexcom"
         case alarm = "Alarm"
+        case volumeButtonSnooze = "Volume Button Snooze"
         case calendar = "Calendar"
         case deviceStatus = "Device Status"
+        case remote = "Remote"
     }
 
     init() {

+ 15 - 32
LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift

@@ -315,39 +315,22 @@ struct LoopAPNSBolusView: View {
     }
 
     private func authenticateAndSendInsulin() {
-        let context = LAContext()
-        var error: NSError?
-
-        let reason = "Confirm your identity to send insulin."
-
-        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    if success {
-                        sendInsulinConfirmed()
-                    } else {
-                        alertMessage = "Authentication failed"
-                        alertType = .error
-                        showAlert = true
-                    }
-                }
+        AuthService.authenticate(reason: "Confirm your identity to send insulin.") { result in
+            switch result {
+            case .success:
+                sendInsulinConfirmed()
+            case .unavailable:
+                alertMessage = "Authentication not available"
+                alertType = .error
+                showAlert = true
+            case .failed:
+                alertMessage = "Authentication failed"
+                alertType = .error
+                showAlert = true
+            case .canceled:
+                // User canceled: no alert to avoid spammy UX
+                break
             }
-        } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    if success {
-                        sendInsulinConfirmed()
-                    } else {
-                        alertMessage = "Authentication failed"
-                        alertType = .error
-                        showAlert = true
-                    }
-                }
-            }
-        } else {
-            alertMessage = "Biometric authentication not available"
-            alertType = .error
-            showAlert = true
         }
     }
 

+ 191 - 24
LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift

@@ -67,8 +67,7 @@ struct OverridePresetsView: View {
                                     isActivating: viewModel.isActivating && viewModel.selectedPreset?.name == preset.name,
                                     onActivate: {
                                         viewModel.selectedPreset = preset
-                                        viewModel.alertType = .confirmActivation
-                                        viewModel.showAlert = true
+                                        viewModel.showOverrideModal = true
                                     }
                                 )
                             }
@@ -87,21 +86,24 @@ struct OverridePresetsView: View {
                     await viewModel.loadOverridePresets()
                 }
             }
-            .alert(isPresented: $viewModel.showAlert) {
-                switch viewModel.alertType {
-                case .confirmActivation:
-                    return Alert(
-                        title: Text("Activate Override"),
-                        message: Text("Do you want to activate the override '\(viewModel.selectedPreset?.name ?? "")'?"),
-                        primaryButton: .default(Text("Confirm"), action: {
-                            if let preset = viewModel.selectedPreset {
-                                Task {
-                                    await viewModel.activateOverride(preset: preset)
-                                }
+            .sheet(isPresented: $viewModel.showOverrideModal) {
+                if let preset = viewModel.selectedPreset {
+                    OverrideActivationModal(
+                        preset: preset,
+                        onActivate: { duration in
+                            viewModel.showOverrideModal = false
+                            Task {
+                                await viewModel.activateOverride(preset: preset, duration: duration)
                             }
-                        }),
-                        secondaryButton: .cancel()
+                        },
+                        onCancel: {
+                            viewModel.showOverrideModal = false
+                        }
                     )
+                }
+            }
+            .alert(isPresented: $viewModel.showAlert) {
+                switch viewModel.alertType {
                 case .confirmCancellation:
                     return Alert(
                         title: Text("Cancel Override"),
@@ -155,7 +157,7 @@ struct OverridePresetRow: View {
 
                     HStack(spacing: 8) {
                         if let targetRange = preset.targetRange {
-                            Text("Target: \(Int(targetRange.lowerBound))-\(Int(targetRange.upperBound))")
+                            Text("Target: \(Localizer.formatLocalDouble(targetRange.lowerBound))-\(Localizer.formatLocalDouble(targetRange.upperBound))")
                                 .font(.caption)
                                 .foregroundColor(.secondary)
                         }
@@ -191,6 +193,172 @@ struct OverridePresetRow: View {
     }
 }
 
+struct OverrideActivationModal: View {
+    let preset: OverridePreset
+    let onActivate: (TimeInterval?) -> Void
+    let onCancel: () -> Void
+
+    @State private var enableIndefinitely: Bool
+    @State private var durationHours: Double = 1.0
+
+    init(preset: OverridePreset, onActivate: @escaping (TimeInterval?) -> Void, onCancel: @escaping () -> Void) {
+        self.preset = preset
+        self.onActivate = onActivate
+        self.onCancel = onCancel
+
+        // Initialize state based on preset duration
+        if preset.duration == 0 {
+            // Indefinite override - allow user to choose
+            _enableIndefinitely = State(initialValue: true)
+        } else {
+            // Override with predefined duration - use preset duration
+            _enableIndefinitely = State(initialValue: false)
+            _durationHours = State(initialValue: preset.duration / 3600)
+        }
+    }
+
+    var body: some View {
+        NavigationView {
+            VStack(spacing: 20) {
+                // Preset Info
+                VStack(spacing: 12) {
+                    if let symbol = preset.symbol {
+                        Text(symbol)
+                            .font(.largeTitle)
+                    }
+
+                    Text(preset.name)
+                        .font(.title2)
+                        .fontWeight(.semibold)
+                        .multilineTextAlignment(.center)
+
+                    if let targetRange = preset.targetRange {
+                        Text("Target: \(Localizer.formatLocalDouble(targetRange.lowerBound))-\(Localizer.formatLocalDouble(targetRange.upperBound))")
+                            .font(.subheadline)
+                            .foregroundColor(.secondary)
+                    }
+
+                    if let insulinNeedsScaleFactor = preset.insulinNeedsScaleFactor {
+                        Text("Insulin: \(Int(insulinNeedsScaleFactor * 100))%")
+                            .font(.subheadline)
+                            .foregroundColor(.secondary)
+                    }
+
+                    // Only show duration for overrides with predefined duration
+                    if preset.duration != 0 {
+                        Text("Duration: \(preset.durationDescription)")
+                            .font(.subheadline)
+                            .foregroundColor(.secondary)
+                    }
+                }
+                .padding(.top)
+
+                Spacer()
+
+                // Duration Settings (only show for overrides without predefined duration)
+                if preset.duration == 0 {
+                    VStack(spacing: 16) {
+                        // Duration Input (only show when not indefinite)
+                        if !enableIndefinitely {
+                            VStack(spacing: 8) {
+                                HStack {
+                                    Text("Duration")
+                                        .font(.headline)
+                                    Spacer()
+                                    Text(formatDuration(durationHours))
+                                        .font(.headline)
+                                        .foregroundColor(.blue)
+                                }
+
+                                Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25)
+                                    .accentColor(.blue)
+                                HStack {
+                                    Text("15m")
+                                        .font(.caption)
+                                        .foregroundColor(.secondary)
+                                        .frame(width: 80, alignment: .leading)
+                                    Spacer()
+                                    Text("24h")
+                                        .font(.caption)
+                                        .foregroundColor(.secondary)
+                                        .frame(width: 80, alignment: .trailing)
+                                }
+                            }
+                            .padding(.horizontal)
+                        }
+
+                        // Indefinitely Toggle
+                        HStack {
+                            Toggle("Enable indefinitely", isOn: $enableIndefinitely)
+                            Spacer()
+                        }
+                        .padding(.horizontal)
+                    }
+                }
+
+                // Action Buttons
+                VStack(spacing: 12) {
+                    Button(action: {
+                        let duration: TimeInterval?
+                        if preset.duration == 0 {
+                            // For indefinite overrides, use user selection
+                            duration = enableIndefinitely ? nil : (durationHours * 3600)
+                        } else {
+                            // For overrides with predefined duration, use preset duration
+                            duration = preset.duration
+                        }
+                        onActivate(duration)
+                    }) {
+                        Text("Activate Override")
+                            .font(.headline)
+                            .foregroundColor(.white)
+                            .frame(maxWidth: .infinity)
+                            .padding()
+                            .background(Color.blue)
+                            .cornerRadius(10)
+                    }
+
+                    Button(action: onCancel) {
+                        Text("Cancel")
+                            .font(.headline)
+                            .foregroundColor(.secondary)
+                            .frame(maxWidth: .infinity)
+                            .padding()
+                            .background(Color.gray.opacity(0.2))
+                            .cornerRadius(10)
+                    }
+                }
+                .padding(.horizontal)
+                .padding(.bottom)
+            }
+            .navigationBarTitle("Activate Override", displayMode: .inline)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Cancel") {
+                        onCancel()
+                    }
+                }
+            }
+        }
+    }
+
+    // Helper function to format duration in hours and minutes
+    private func formatDuration(_ hours: Double) -> String {
+        let totalMinutes = Int(hours * 60)
+        let hours = totalMinutes / 60
+        let minutes = totalMinutes % 60
+
+        if hours > 0 && minutes > 0 {
+            return "\(hours)h \(minutes)m"
+        } else if hours > 0 {
+            return "\(hours)h"
+        } else {
+            return "\(minutes)m"
+        }
+    }
+}
+
 class OverridePresetsViewModel: ObservableObject {
     @Published var overridePresets: [OverridePreset] = []
     @Published var isLoading = false
@@ -199,9 +367,9 @@ class OverridePresetsViewModel: ObservableObject {
     @Published var alertType: AlertType? = nil
     @Published var statusMessage: String? = nil
     @Published var selectedPreset: OverridePreset? = nil
+    @Published var showOverrideModal = false
 
     enum AlertType {
-        case confirmActivation
         case confirmCancellation
         case statusSuccess
         case statusFailure
@@ -213,7 +381,7 @@ class OverridePresetsViewModel: ObservableObject {
         }
 
         do {
-            let presets = try await fetchOverridePresetsFromNightscout()
+            let presets = try await fetchOverridePresetsFromStorage()
             await MainActor.run {
                 self.overridePresets = presets
                 self.isLoading = false
@@ -228,13 +396,13 @@ class OverridePresetsViewModel: ObservableObject {
         }
     }
 
-    func activateOverride(preset: OverridePreset) async {
+    func activateOverride(preset: OverridePreset, duration: TimeInterval?) async {
         await MainActor.run {
             isActivating = true
         }
 
         do {
-            try await sendOverrideNotification(preset: preset)
+            try await sendOverrideNotification(preset: preset, duration: duration)
             await MainActor.run {
                 self.isActivating = false
                 self.statusMessage = "\(preset.name) override activated successfully."
@@ -274,8 +442,7 @@ class OverridePresetsViewModel: ObservableObject {
         }
     }
 
-    private func fetchOverridePresetsFromNightscout() async throws -> [OverridePreset] {
-        // Use ProfileManager's already loaded overrides instead of fetching from Nightscout
+    private func fetchOverridePresetsFromStorage() async throws -> [OverridePreset] {
         let loopOverrides = ProfileManager.shared.loopOverrides
 
         return loopOverrides.map { override in
@@ -298,11 +465,11 @@ class OverridePresetsViewModel: ObservableObject {
         }
     }
 
-    private func sendOverrideNotification(preset: OverridePreset) async throws {
+    private func sendOverrideNotification(preset: OverridePreset, duration: TimeInterval?) async throws {
         let apnsService = LoopAPNSService()
         try await apnsService.sendOverrideNotification(
             presetName: preset.name,
-            duration: preset.duration
+            duration: duration
         )
     }
 

+ 216 - 0
LoopFollow/Remote/Settings/RemoteCommandSettings.swift

@@ -0,0 +1,216 @@
+// LoopFollow
+// RemoteCommandSettings.swift
+
+import Foundation
+import HealthKit
+
+struct RemoteCommandSettings: Codable {
+    let remoteType: RemoteType
+    let user: String
+    let sharedSecret: String
+    let apnsKey: String
+    let keyId: String
+    let teamId: String?
+    let maxBolus: Double
+    let maxCarbs: Double
+    let maxProtein: Double
+    let maxFat: Double
+    let mealWithBolus: Bool
+    let mealWithFatProtein: Bool
+    let productionEnvironment: Bool
+    let loopAPNSQrCodeURL: String
+    let url: String
+    let token: String
+    let version: String
+
+    init(
+        remoteType: RemoteType,
+        user: String,
+        sharedSecret: String,
+        apnsKey: String,
+        keyId: String,
+        teamId: String?,
+        maxBolus: Double,
+        maxCarbs: Double,
+        maxProtein: Double,
+        maxFat: Double,
+        mealWithBolus: Bool,
+        mealWithFatProtein: Bool,
+        productionEnvironment: Bool,
+        loopAPNSQrCodeURL: String,
+        url: String,
+        token: String
+    ) {
+        self.remoteType = remoteType
+        self.user = user
+        self.sharedSecret = sharedSecret
+        self.apnsKey = apnsKey
+        self.keyId = keyId
+        self.teamId = teamId
+        self.maxBolus = maxBolus
+        self.maxCarbs = maxCarbs
+        self.maxProtein = maxProtein
+        self.maxFat = maxFat
+        self.mealWithBolus = mealWithBolus
+        self.mealWithFatProtein = mealWithFatProtein
+        self.productionEnvironment = productionEnvironment
+        self.loopAPNSQrCodeURL = loopAPNSQrCodeURL
+        self.url = url
+        self.token = token
+        version = "1.0"
+    }
+
+    /// Creates RemoteCommandSettings from the current Storage values
+    static func fromCurrentStorage() -> RemoteCommandSettings {
+        let storage = Storage.shared
+
+        return RemoteCommandSettings(
+            remoteType: storage.remoteType.value,
+            user: storage.user.value,
+            sharedSecret: storage.sharedSecret.value,
+            apnsKey: storage.apnsKey.value,
+            keyId: storage.keyId.value,
+            teamId: storage.teamId.value,
+            maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()),
+            maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()),
+            maxProtein: storage.maxProtein.value.doubleValue(for: .gram()),
+            maxFat: storage.maxFat.value.doubleValue(for: .gram()),
+            mealWithBolus: storage.mealWithBolus.value,
+            mealWithFatProtein: storage.mealWithFatProtein.value,
+            productionEnvironment: storage.productionEnvironment.value,
+            loopAPNSQrCodeURL: storage.loopAPNSQrCodeURL.value,
+            url: storage.url.value,
+            token: storage.token.value
+        )
+    }
+
+    /// Applies the settings to the current Storage
+    func applyToStorage() {
+        let storage = Storage.shared
+
+        storage.remoteType.value = remoteType
+        storage.user.value = user
+        storage.sharedSecret.value = sharedSecret
+        storage.apnsKey.value = apnsKey
+        storage.keyId.value = keyId
+        storage.teamId.value = teamId
+        storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus)
+        storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs)
+        storage.maxProtein.value = HKQuantity(unit: .gram(), doubleValue: maxProtein)
+        storage.maxFat.value = HKQuantity(unit: .gram(), doubleValue: maxFat)
+        storage.mealWithBolus.value = mealWithBolus
+        storage.mealWithFatProtein.value = mealWithFatProtein
+        storage.productionEnvironment.value = productionEnvironment
+        storage.loopAPNSQrCodeURL.value = loopAPNSQrCodeURL
+        storage.url.value = url
+        storage.token.value = token
+
+        // Automatically set device type based on remote type
+        switch remoteType {
+        case .loopAPNS:
+            storage.device.value = "Loop"
+        case .trc:
+            storage.device.value = "Trio"
+        case .nightscout:
+            // For Nightscout, we don't automatically set device type
+            // as it should be determined by the actual connection
+            break
+        case .none:
+            // For none, we don't change the device type
+            break
+        }
+    }
+
+    /// Encodes the settings to a JSON string for QR code generation
+    func encodeToJSON() -> String? {
+        do {
+            let data = try JSONEncoder().encode(self)
+            return String(data: data, encoding: .utf8)
+        } catch {
+            return nil
+        }
+    }
+
+    /// Decodes settings from a JSON string
+    static func decodeFromJSON(_ jsonString: String) -> RemoteCommandSettings? {
+        guard let data = jsonString.data(using: .utf8) else {
+            return nil
+        }
+
+        do {
+            return try JSONDecoder().decode(RemoteCommandSettings.self, from: data)
+        } catch {
+            return nil
+        }
+    }
+
+    /// Checks if the settings are valid for the given remote type
+    func isValid() -> Bool {
+        switch remoteType {
+        case .none:
+            return true
+        case .nightscout:
+            return !user.isEmpty
+        case .trc:
+            return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty
+        case .loopAPNS:
+            return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
+        }
+    }
+
+    /// Validates URL and token compatibility with current storage
+    /// Returns a tuple with (isCompatible, shouldPromptForURL, shouldPromptForToken, message)
+    func validateCompatibilityWithCurrentStorage() -> (isCompatible: Bool, shouldPromptForURL: Bool, shouldPromptForToken: Bool, message: String) {
+        let storage = Storage.shared
+        let currentURL = storage.url.value
+        let currentToken = storage.token.value
+
+        var shouldPromptForURL = false
+        var shouldPromptForToken = false
+        var message = ""
+
+        // Check if current user has URL set
+        let hasCurrentURL = !currentURL.isEmpty
+        let hasCurrentToken = !currentToken.isEmpty
+
+        // Check if scanned settings have URL/token
+        let hasScannedURL = !url.isEmpty
+        let hasScannedToken = !token.isEmpty
+
+        // If current user doesn't have URL but scanned settings do, prompt to set it
+        if !hasCurrentURL, hasScannedURL {
+            shouldPromptForURL = true
+            message = "The scanned settings include a Nightscout URL. Would you like to set this as your Nightscout address?"
+        }
+
+        // If current user doesn't have token but scanned settings do, prompt to set it
+        if !hasCurrentToken, hasScannedToken {
+            shouldPromptForToken = true
+            if !message.isEmpty {
+                message += "\n\nThe scanned settings also include a token. Would you like to set this as your access token?"
+            } else {
+                message = "The scanned settings include a token. Would you like to set this as your access token?"
+            }
+        }
+
+        // If both have URLs but they don't match, show warning
+        if hasCurrentURL, hasScannedURL, currentURL != url {
+            shouldPromptForURL = true
+            message = "The scanned Nightscout URL (\(url)) doesn't match your current Nightscout address (\(currentURL)). Would you like to change your Nightscout address to match the scanned settings?"
+        }
+
+        // If both have tokens but they don't match, show warning
+        if hasCurrentToken, hasScannedToken, currentToken != token {
+            shouldPromptForToken = true
+            if !message.isEmpty {
+                message += "\n\nThe scanned token doesn't match your current access token. Would you like to update your token?"
+            } else {
+                message = "The scanned token doesn't match your current access token. Would you like to update your token?"
+            }
+        }
+
+        let isCompatible = !shouldPromptForURL && !shouldPromptForToken
+
+        return (isCompatible, shouldPromptForURL, shouldPromptForToken, message)
+    }
+}

+ 125 - 0
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -11,12 +11,16 @@ struct RemoteSettingsView: View {
     @State private var showAlert: Bool = false
     @State private var alertType: AlertType? = nil
     @State private var alertMessage: String? = nil
+
     @State private var otpTimeRemaining: Int? = nil
     private let otpPeriod: TimeInterval = 30
     private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
 
     enum AlertType {
         case validation
+        case qrCodeError
+        case urlTokenValidation
+        case urlTokenUpdate
     }
 
     init(viewModel: RemoteSettingsViewModel) {
@@ -57,6 +61,36 @@ struct RemoteSettingsView: View {
                     .foregroundColor(.secondary)
             }
 
+            // MARK: - QR Code Sharing Section
+
+            Section {
+                if viewModel.remoteType == .none {
+                    Button(action: {
+                        viewModel.isShowingQRCodeScanner = true
+                    }) {
+                        HStack {
+                            Image(systemName: "qrcode.viewfinder")
+                            Text("Import Remote Settings from QR Code")
+                        }
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, 10)
+                } else {
+                    Button(action: {
+                        viewModel.isShowingQRCodeDisplay = true
+                    }) {
+                        HStack {
+                            Image(systemName: "qrcode")
+                            Text("Export Remote Settings as QR Code")
+                        }
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, 10)
+                }
+            }
+
             // MARK: - Meal Section (for TRC only)
 
             if viewModel.remoteType == .trc {
@@ -260,6 +294,28 @@ struct RemoteSettingsView: View {
                     message: Text(alertMessage ?? "Invalid input."),
                     dismissButton: .default(Text("OK"))
                 )
+            case .qrCodeError:
+                return Alert(
+                    title: Text("QR Code Error"),
+                    message: Text(alertMessage ?? "An error occurred while processing the QR code."),
+                    dismissButton: .default(Text("OK"))
+                )
+            case .urlTokenValidation:
+                return Alert(
+                    title: Text("URL/Token Validation"),
+                    message: Text(viewModel.validationMessage),
+                    dismissButton: .default(Text("OK")) {
+                        viewModel.showURLTokenValidation = false
+                    }
+                )
+            case .urlTokenUpdate:
+                return Alert(
+                    title: Text("URL/Token Update"),
+                    message: Text(viewModel.validationMessage),
+                    dismissButton: .default(Text("OK")) {
+                        viewModel.showURLTokenValidation = false
+                    }
+                )
             case .none:
                 return Alert(title: Text("Unknown Alert"))
             }
@@ -269,14 +325,75 @@ struct RemoteSettingsView: View {
                 viewModel.handleLoopAPNSQRCodeScanResult(result)
             }
         }
+        .sheet(isPresented: $viewModel.isShowingQRCodeScanner) {
+            SimpleQRCodeScannerView { result in
+                viewModel.handleRemoteCommandQRCodeScanResult(result)
+            }
+        }
+        .sheet(isPresented: $viewModel.isShowingQRCodeDisplay) {
+            NavigationView {
+                VStack {
+                    if let qrCodeString = viewModel.generateQRCodeForCurrentSettings() {
+                        QRCodeDisplayView(qrCodeString: qrCodeString)
+                            .padding()
+                    } else {
+                        Text("Failed to generate QR code")
+                            .foregroundColor(.red)
+                            .padding()
+                    }
+                }
+                .navigationTitle("Share Remote Settings")
+                .navigationBarTitleDisplayMode(.inline)
+                .navigationBarItems(trailing: Button("Done") {
+                    viewModel.isShowingQRCodeDisplay = false
+                })
+            }
+        }
+        .sheet(isPresented: $viewModel.showURLTokenValidation) {
+            NavigationView {
+                URLTokenValidationView(
+                    settings: viewModel.pendingSettings!,
+                    shouldPromptForURL: viewModel.shouldPromptForURL,
+                    shouldPromptForToken: viewModel.shouldPromptForToken,
+                    message: viewModel.validationMessage,
+                    onConfirm: { confirmedSettings in
+                        confirmedSettings.applyToStorage()
+                        viewModel.updateViewModelFromStorage()
+                        viewModel.showURLTokenValidation = false
+                        viewModel.pendingSettings = nil
+                        LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code with URL/token updates")
+                    },
+                    onCancel: {
+                        viewModel.showURLTokenValidation = false
+                        viewModel.pendingSettings = nil
+                    }
+                )
+            }
+        }
         .onAppear {
             // Reset timer state so it shows '-' until first tick
             otpTimeRemaining = nil
+            // Update view model from storage to ensure UI is current
+            viewModel.updateViewModelFromStorage()
         }
         .onReceive(otpTimer) { _ in
             let now = Date().timeIntervalSince1970
             otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
         }
+        .onReceive(viewModel.$qrCodeErrorMessage) { errorMessage in
+            if let errorMessage = errorMessage, !errorMessage.isEmpty {
+                handleQRCodeError(errorMessage)
+                // Clear the error message after showing the alert
+                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+                    viewModel.qrCodeErrorMessage = nil
+                }
+            }
+        }
+        .onReceive(viewModel.$showURLTokenValidation) { showValidation in
+            if showValidation {
+                // The sheet will be shown automatically due to the binding
+            }
+        }
         .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
         .navigationTitle("Remote Settings")
         .navigationBarTitleDisplayMode(.inline)
@@ -312,6 +429,14 @@ struct RemoteSettingsView: View {
         showAlert = true
     }
 
+    // MARK: - QR Code Error Handler
+
+    private func handleQRCodeError(_ message: String) {
+        alertMessage = message
+        alertType = .qrCodeError
+        showAlert = true
+    }
+
     private var guardrailsSection: some View {
         Section(header: Text("Guardrails")) {
             HStack {

+ 88 - 0
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -33,6 +33,20 @@ class RemoteSettingsViewModel: ObservableObject {
     @Published var productionEnvironment: Bool
     @Published var isShowingLoopAPNSScanner: Bool = false
     @Published var loopAPNSErrorMessage: String?
+  
+      // MARK: - QR Code Sharing Properties
+
+    @Published var isShowingQRCodeScanner: Bool = false
+    @Published var isShowingQRCodeDisplay: Bool = false
+    @Published var qrCodeErrorMessage: String?
+
+    // MARK: - URL/Token Validation Properties
+
+    @Published var pendingSettings: RemoteCommandSettings?
+    @Published var showURLTokenValidation: Bool = false
+    @Published var validationMessage: String = ""
+    @Published var shouldPromptForURL: Bool = false
+    @Published var shouldPromptForToken: Bool = false
 
     let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown"
 
@@ -205,6 +219,13 @@ class RemoteSettingsViewModel: ObservableObject {
             switch result {
             case let .success(code):
                 self.loopAPNSQrCodeURL = code
+                // Set device type and remote type for Loop APNS
+                Storage.shared.device.value = "Loop"
+                Storage.shared.remoteType.value = .loopAPNS
+                // Update view model properties
+                self.remoteType = .loopAPNS
+                self.isLoopDevice = true
+                self.isTrioDevice = false
                 LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)")
             case let .failure(error):
                 self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
@@ -212,4 +233,71 @@ class RemoteSettingsViewModel: ObservableObject {
             self.isShowingLoopAPNSScanner = false
         }
     }
+
+    // MARK: - QR Code Sharing Methods
+
+    func handleRemoteCommandQRCodeScanResult(_ result: Result<String, Error>) {
+        DispatchQueue.main.async {
+            switch result {
+            case let .success(jsonString):
+                if let settings = RemoteCommandSettings.decodeFromJSON(jsonString) {
+                    if settings.isValid() {
+                        // Check URL and token compatibility
+                        let validation = settings.validateCompatibilityWithCurrentStorage()
+
+                        if validation.isCompatible {
+                            // No conflicts, apply settings directly
+                            settings.applyToStorage()
+                            self.updateViewModelFromStorage()
+                            LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code")
+                        } else {
+                            // Conflicts detected, show validation view
+                            self.pendingSettings = settings
+                            self.validationMessage = validation.message
+                            self.shouldPromptForURL = validation.shouldPromptForURL
+                            self.shouldPromptForToken = validation.shouldPromptForToken
+                            self.showURLTokenValidation = true
+                        }
+                    } else {
+                        self.qrCodeErrorMessage = "Invalid remote command settings in QR code"
+                    }
+                } else {
+                    self.qrCodeErrorMessage = "Failed to decode remote command settings from QR code"
+                }
+            case let .failure(error):
+                self.qrCodeErrorMessage = "Scanning failed: \(error.localizedDescription)"
+            }
+            self.isShowingQRCodeScanner = false
+        }
+    }
+
+    func generateQRCodeForCurrentSettings() -> String? {
+        let settings = RemoteCommandSettings.fromCurrentStorage()
+        return settings.encodeToJSON()
+    }
+
+    // MARK: - Public Methods for View Access
+
+    /// Updates the view model properties from storage (accessible from view)
+    func updateViewModelFromStorage() {
+        let storage = Storage.shared
+        remoteType = storage.remoteType.value
+        user = storage.user.value
+        sharedSecret = storage.sharedSecret.value
+        apnsKey = storage.apnsKey.value
+        keyId = storage.keyId.value
+        maxBolus = storage.maxBolus.value
+        maxCarbs = storage.maxCarbs.value
+        maxProtein = storage.maxProtein.value
+        maxFat = storage.maxFat.value
+        mealWithBolus = storage.mealWithBolus.value
+        mealWithFatProtein = storage.mealWithFatProtein.value
+        loopDeveloperTeamId = storage.teamId.value ?? ""
+        loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
+        productionEnvironment = storage.productionEnvironment.value
+
+        // Update device-related properties
+        isTrioDevice = (storage.device.value == "Trio")
+        isLoopDevice = (storage.device.value == "Loop")
+    }
 }

+ 98 - 0
LoopFollow/Remote/Settings/URLTokenValidationView.swift

@@ -0,0 +1,98 @@
+// LoopFollow
+// URLTokenValidationView.swift
+
+import SwiftUI
+
+struct URLTokenValidationView: View {
+    let settings: RemoteCommandSettings
+    let shouldPromptForURL: Bool
+    let shouldPromptForToken: Bool
+    let message: String
+    let onConfirm: (RemoteCommandSettings) -> Void
+    let onCancel: () -> Void
+
+    init(
+        settings: RemoteCommandSettings,
+        shouldPromptForURL: Bool,
+        shouldPromptForToken: Bool,
+        message: String,
+        onConfirm: @escaping (RemoteCommandSettings) -> Void,
+        onCancel: @escaping () -> Void
+    ) {
+        self.settings = settings
+        self.shouldPromptForURL = shouldPromptForURL
+        self.shouldPromptForToken = shouldPromptForToken
+        self.message = message
+        self.onConfirm = onConfirm
+        self.onCancel = onCancel
+    }
+
+    var body: some View {
+        Form {
+            Section {
+                Text(message)
+                    .font(.body)
+                    .foregroundColor(.primary)
+            }
+
+            // Show URL section if we have URL data
+            if !settings.url.isEmpty {
+                Section(header: Text("Nightscout URL")) {
+                    HStack {
+                        Text("Current URL:")
+                        Spacer()
+                        Text(Storage.shared.url.value.isEmpty ? "Not set" : Storage.shared.url.value)
+                            .foregroundColor(.secondary)
+                    }
+
+                    HStack {
+                        Text("Scanned URL:")
+                        Spacer()
+                        Text(settings.url)
+                            .foregroundColor(.primary)
+                    }
+                }
+            }
+
+            // Show token section if we have token data
+            if !settings.token.isEmpty {
+                Section(header: Text("Access Token")) {
+                    HStack {
+                        Text("Current Token:")
+                        Spacer()
+                        Text(Storage.shared.token.value.isEmpty ? "Not set" : "••••••••")
+                            .foregroundColor(.secondary)
+                    }
+
+                    HStack {
+                        Text("Scanned Token:")
+                        Spacer()
+                        Text("••••••••")
+                            .foregroundColor(.primary)
+                    }
+                }
+            }
+
+            Section {
+                HStack {
+                    Button("Cancel") {
+                        onCancel()
+                    }
+                    .buttonStyle(.bordered)
+
+                    Spacer()
+
+                    Button("Confirm & Import") {
+                        onConfirm(settings)
+                    }
+                    .buttonStyle(.borderedProminent)
+                }
+            }
+        }
+        .navigationTitle("URL/Token Validation")
+        .navigationBarTitleDisplayMode(.inline)
+        .navigationBarItems(trailing: Button("Cancel") {
+            onCancel()
+        })
+    }
+}

+ 2 - 27
LoopFollow/Remote/TRC/BolusView.swift

@@ -71,8 +71,8 @@ struct BolusView: View {
                         title: Text("Confirm Bolus"),
                         message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: HKUnit.internationalUnit()), specifier: "%.2f") U?"),
                         primaryButton: .default(Text("Confirm"), action: {
-                            authenticateUser { success in
-                                if success {
+                            AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
+                                if case .success = result {
                                     sendBolus()
                                 }
                             }
@@ -127,31 +127,6 @@ struct BolusView: View {
         }
     }
 
-    private func authenticateUser(completion: @escaping (Bool) -> Void) {
-        let context = LAContext()
-        var error: NSError?
-
-        let reason = "Confirm your identity to send bolus."
-
-        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    completion(success)
-                }
-            }
-        } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    completion(success)
-                }
-            }
-        } else {
-            DispatchQueue.main.async {
-                completion(false)
-            }
-        }
-    }
-
     private func handleValidationError(_ message: String) {
         alertMessage = message
         alertType = .validation

+ 2 - 27
LoopFollow/Remote/TRC/MealView.swift

@@ -195,8 +195,8 @@ struct MealView: View {
                         primaryButton: .default(Text("Confirm"), action: {
                             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                 if bolusAmount > 0 {
-                                    authenticateUser { success in
-                                        if success {
+                                    AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
+                                        if case .success = result {
                                             sendMealCommand()
                                         }
                                     }
@@ -300,29 +300,4 @@ struct MealView: View {
         alertType = .validationError
         showAlert = true
     }
-
-    private func authenticateUser(completion: @escaping (Bool) -> Void) {
-        let context = LAContext()
-        var error: NSError?
-
-        let reason = "Confirm your identity to send bolus."
-
-        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    completion(success)
-                }
-            }
-        } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
-            context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
-                DispatchQueue.main.async {
-                    completion(success)
-                }
-            }
-        } else {
-            DispatchQueue.main.async {
-                completion(false)
-            }
-        }
-    }
 }

+ 6 - 0
LoopFollow/Settings/SettingsMenuView.swift

@@ -66,6 +66,12 @@ struct SettingsMenuView: View {
                         {
                             settingsPath.value.append(Sheet.remote)
                         }
+                    } else {
+                        NavigationRow(title: "Import Settings",
+                                      icon: "square.and.arrow.down")
+                        {
+                            settingsPath.value.append(Sheet.remote)
+                        }
                     }
                 }
 

+ 437 - 34
LoopFollow/Snoozer/SnoozerView.swift

@@ -16,36 +16,100 @@ struct SnoozerView: View {
     @ObservedObject var bg = Observable.shared.bg
     @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji
 
+    @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration
+
+    // Snoozer Bar state
+    @State private var showSnoozerBar: Bool = false
+    @State private var showDatePickerDate: Bool = false
+    @State private var showDatePickerTime: Bool = false
+    @State private var autoHideTask: DispatchWorkItem? = nil
+    @State private var lastActiveState: Bool = false
+
+    private var isGlobalSnoozeActive: Bool {
+        if let until = cfgStore.value.snoozeUntil { return until > Date() }
+        return false
+    }
+
     var body: some View {
         GeometryReader { geo in
-            ZStack {
-                Color.black
-                    .edgesIgnoringSafeArea(.all)
+            let isLandscape = geo.size.width > geo.size.height
+            let barShowing = showSnoozerBar || isGlobalSnoozeActive
+            let landscapeScale: CGFloat = 0.8
 
-                let isLandscape = geo.size.width > geo.size.height
+            ZStack {
+                Color.black.ignoresSafeArea()
 
-                Group {
+                VStack(spacing: 0) {
                     if isLandscape {
                         HStack(spacing: 0) {
-                            leftColumn(isLandscape: true)
+                            leftColumn(isLandscape: true, barShowing: barShowing)
                             rightColumn(isLandscape: true)
                         }
                     } else {
                         VStack(spacing: 0) {
-                            leftColumn(isLandscape: false)
+                            leftColumn(isLandscape: false, barShowing: barShowing)
                             rightColumn(isLandscape: false)
                         }
                     }
                 }
-                .frame(width: geo.size.width, height: geo.size.height)
+                .contentShape(Rectangle())
+                .onTapGesture { presentSnoozerBar() }
+                .onAppear {
+                    presentSnoozerBar()
+                    lastActiveState = isGlobalSnoozeActive
+                }
+                .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
+                    let active = isGlobalSnoozeActive
+                    if lastActiveState != active {
+                        lastActiveState = active
+                        if active {
+                            showSnoozerBar = true
+                            cancelAutoHide()
+                        } else {
+                            scheduleAutoHide()
+                        }
+                    }
+                }
+                .onReceive(vm.$activeAlarm) { alarm in
+                    if alarm != nil {
+                        showSnoozerBar = true
+                        cancelAutoHide()
+                    } else if !isGlobalSnoozeActive {
+                        scheduleAutoHide()
+                    }
+                }
+                .onChange(of: isGlobalSnoozeActive) { active in
+                    if active {
+                        showSnoozerBar = true
+                        cancelAutoHide()
+                    } else {
+                        scheduleAutoHide()
+                    }
+                }
+                .scaleEffect((isLandscape && barShowing) ? landscapeScale : 1.0, anchor: .top)
+                .animation(.easeOut(duration: 0.18), value: barShowing)
             }
+            .safeAreaInset(edge: .top) {
+                if showSnoozerBar || isGlobalSnoozeActive {
+                    snoozerBar(compact: isLandscape)
+                        .transition(.move(edge: .top).combined(with: .opacity))
+                }
+            }
+            .sheet(isPresented: $showDatePickerDate) { datePickerSheetDate() }
+            .sheet(isPresented: $showDatePickerTime) { datePickerSheetTime() }
         }
     }
 
-    // MARK: - Left Column (BG / Direction / Delta / Age)
+    // MARK: - Columns
 
-    private func leftColumn(isLandscape: Bool) -> some View {
-        VStack(spacing: 0) {
+    private func leftColumn(isLandscape: Bool, barShowing: Bool) -> some View {
+        let topPad: CGFloat = barShowing ? 0 : 16
+        let bigMaxH: CGFloat = barShowing ? (isLandscape ? 210 : 220) : 240
+        let dirMaxH: CGFloat = barShowing ? (isLandscape ? 72 : 72) : 80
+        let deltaMaxH: CGFloat = barShowing ? (isLandscape ? 60 : 60) : 68
+        let ageMaxH: CGFloat = barShowing ? 36 : 40
+
+        return VStack(spacing: 0) {
             if !isLandscape && showDisplayName.value {
                 Text(Bundle.main.displayName)
                     .font(.system(size: 50, weight: .bold))
@@ -61,46 +125,42 @@ struct SnoozerView: View {
                     pattern: .solid,
                     color: bgStale.value ? .red : .clear
                 )
-                .frame(maxWidth: .infinity, maxHeight: 240)
+                .frame(maxWidth: .infinity, maxHeight: bigMaxH)
 
             if isLandscape {
                 HStack(alignment: .firstTextBaseline, spacing: 20) {
                     Text(directionText.value)
                         .font(.system(size: 90, weight: .black))
-
                     Text(deltaText.value)
                         .font(.system(size: 70))
                 }
                 .minimumScaleFactor(0.5)
                 .foregroundColor(.white)
-                .frame(maxWidth: .infinity, maxHeight: 80)
-
+                .frame(maxWidth: .infinity, maxHeight: dirMaxH)
             } else {
                 Text(directionText.value)
                     .font(.system(size: 110, weight: .black))
                     .minimumScaleFactor(0.5)
                     .foregroundColor(.white)
-                    .frame(maxWidth: .infinity, maxHeight: 80)
+                    .frame(maxWidth: .infinity, maxHeight: dirMaxH)
 
                 Text(deltaText.value)
                     .font(.system(size: 70))
                     .minimumScaleFactor(0.5)
                     .foregroundColor(.white.opacity(0.8))
-                    .frame(maxWidth: .infinity, maxHeight: 68)
+                    .frame(maxWidth: .infinity, maxHeight: deltaMaxH)
             }
 
             Text(minAgoText.value)
                 .font(.system(size: 60))
                 .minimumScaleFactor(0.5)
                 .foregroundColor(.white.opacity(0.6))
-                .frame(maxWidth: .infinity, maxHeight: 40)
+                .frame(maxWidth: .infinity, maxHeight: ageMaxH)
         }
-        .padding(.top, 16)
+        .padding(.top, topPad)
         .padding(.horizontal, 16)
     }
 
-    // MARK: - Right Column (Clock/Alert + Snooze Controls)
-
     private func rightColumn(isLandscape: Bool) -> some View {
         VStack(spacing: 0) {
             Spacer()
@@ -121,7 +181,6 @@ struct SnoozerView: View {
                         .padding(.top, 20)
                     Divider()
 
-                    // snooze controls
                     if alarm.type.snoozeTimeUnit != .none {
                         HStack {
                             VStack(alignment: .leading, spacing: 4) {
@@ -176,18 +235,10 @@ struct SnoozerView: View {
     }
 
     private var bgEmoji: String {
-        guard let bg = bg.value, !bgStale.value else {
-            return "🤷"
-        }
-
-        if Localizer.getPreferredUnit() == .millimolesPerLiter, Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" {
-            return "🦄"
-        }
-
-        if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 {
-            return "🦄"
-        }
-
+        guard let bg = bg.value, !bgStale.value else { return "🤷" }
+        if Localizer.getPreferredUnit() == .millimolesPerLiter,
+           Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" { return "🦄" }
+        if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 { return "🦄" }
         switch bg {
         case ..<40: return "❌"
         case ..<55: return "🥶"
@@ -212,6 +263,358 @@ struct SnoozerView: View {
         default: return "👿"
         }
     }
+
+    // MARK: - Snoozer Bar
+
+    private func snoozerBar(compact: Bool) -> some View {
+        let active = isGlobalSnoozeActive
+        let until = cfgStore.value.snoozeUntil
+        let vPad: CGFloat = compact ? 6 : 10
+        let controlH: CGFloat = compact ? 40 : 44
+        let primaryH: CGFloat = compact ? 48 : 54
+        let primaryMinW: CGFloat = compact ? 210 : 230
+
+        return VStack(spacing: compact ? 6 : 10) {
+            if active {
+                if compact {
+                    HStack(spacing: 10) {
+                        Image(systemName: "bell.slash.fill")
+                            .font(.system(size: 22, weight: .bold))
+                            .foregroundColor(.red)
+
+                        Text("All alerts snoozed")
+                            .font(.headline)
+                            .foregroundColor(.white)
+                            .lineLimit(1)
+                            .minimumScaleFactor(0.8)
+                            .layoutPriority(1)
+
+                        Spacer(minLength: 6)
+
+                        Button(action: { showDatePickerDate = true }) {
+                            HStack(spacing: 6) {
+                                Image(systemName: "calendar").font(.system(size: 12, weight: .semibold))
+                                Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted))
+                                    .font(.footnote)
+                            }
+                            .foregroundColor(.white.opacity(0.9))
+                            .padding(.vertical, 6).padding(.horizontal, 10)
+                            .background(Color.white.opacity(0.08))
+                            .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(action: { showDatePickerTime = true }) {
+                            HStack(spacing: 6) {
+                                Image(systemName: "clock").font(.system(size: 12, weight: .semibold))
+                                Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened))
+                                    .font(.footnote)
+                            }
+                            .foregroundColor(.white.opacity(0.9))
+                            .padding(.vertical, 6).padding(.horizontal, 10)
+                            .background(Color.white.opacity(0.08))
+                            .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(action: { adjustSnooze(byMinutes: -30) }) {
+                            Text("− 30m").bold()
+                                .frame(minWidth: 76, minHeight: controlH)
+                                .background(Color.white.opacity(0.12))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(action: { adjustSnooze(byMinutes: +30) }) {
+                            Text("+ 30m").bold()
+                                .frame(minWidth: 76, minHeight: controlH)
+                                .background(Color.white.opacity(0.12))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(role: .destructive, action: { endSnooze() }) {
+                            Text("End now").bold()
+                                .frame(minWidth: 96, minHeight: controlH)
+                                .background(Color.red.opacity(0.6))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Image(systemName: phaseIconName())
+                            .font(.system(size: 18, weight: .semibold))
+                            .foregroundColor(.white.opacity(0.9))
+                    }
+                    .padding(.bottom, 2)
+                } else {
+                    HStack(alignment: .center, spacing: 14) {
+                        Image(systemName: "bell.slash.fill")
+                            .font(.system(size: 24, weight: .bold))
+                            .foregroundColor(.red)
+
+                        VStack(alignment: .leading, spacing: 2) {
+                            Text("All alerts snoozed")
+                                .font(.headline)
+                                .foregroundColor(.white)
+
+                            HStack(spacing: 8) {
+                                Button(action: { showDatePickerDate = true }) {
+                                    HStack(spacing: 6) {
+                                        Image(systemName: "calendar").font(.system(size: 12, weight: .semibold))
+                                        Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted))
+                                            .font(.subheadline)
+                                    }
+                                    .foregroundColor(.white.opacity(0.9))
+                                    .padding(.vertical, 6).padding(.horizontal, 10)
+                                    .background(Color.white.opacity(0.08))
+                                    .clipShape(Capsule())
+                                }.buttonStyle(.plain)
+
+                                Button(action: { showDatePickerTime = true }) {
+                                    HStack(spacing: 6) {
+                                        Image(systemName: "clock").font(.system(size: 12, weight: .semibold))
+                                        Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened))
+                                            .font(.subheadline)
+                                    }
+                                    .foregroundColor(.white.opacity(0.9))
+                                    .padding(.vertical, 6).padding(.horizontal, 10)
+                                    .background(Color.white.opacity(0.08))
+                                    .clipShape(Capsule())
+                                }.buttonStyle(.plain)
+                            }
+                        }
+
+                        Spacer()
+
+                        Image(systemName: phaseIconName())
+                            .font(.system(size: 18, weight: .semibold))
+                            .foregroundColor(.white.opacity(0.9))
+                    }
+
+                    HStack(spacing: 12) {
+                        Button(action: { adjustSnooze(byMinutes: -30) }) {
+                            Text("− 30m").font(.title3).bold()
+                                .frame(minWidth: 90, minHeight: 44)
+                                .background(Color.white.opacity(0.12))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(action: { adjustSnooze(byMinutes: +30) }) {
+                            Text("+ 30m").font(.title3).bold()
+                                .frame(minWidth: 90, minHeight: 44)
+                                .background(Color.white.opacity(0.12))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+
+                        Button(role: .destructive, action: { endSnooze() }) {
+                            Text("End now").font(.title3).bold()
+                                .frame(minWidth: 110, minHeight: 44)
+                                .background(Color.red.opacity(0.6))
+                                .foregroundColor(.white)
+                                .clipShape(Capsule())
+                        }.buttonStyle(.plain)
+                    }
+                    .padding(.bottom, 8)
+                }
+            } else {
+                HStack(spacing: 12) {
+                    Button(action: { activateSnooze1h() }) {
+                        HStack(spacing: 10) {
+                            Image(systemName: "bell.slash")
+                            Text("Snooze all · 1h").bold()
+                                .lineLimit(1)
+                                .minimumScaleFactor(0.9)
+                        }
+                        .font(.title3)
+                        .frame(minWidth: primaryMinW, minHeight: primaryH)
+                        .padding(.horizontal, 6)
+                        .background(Color.orange)
+                        .foregroundColor(.white)
+                        .clipShape(Capsule())
+                        .overlay(Capsule().stroke(Color.white.opacity(0.15), lineWidth: 1))
+                        .shadow(radius: 3)
+                    }.buttonStyle(.plain)
+
+                    Spacer()
+
+                    Image(systemName: phaseIconName())
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundColor(.white.opacity(0.9))
+                }
+                .padding(.bottom, compact ? 6 : 8)
+            }
+        }
+        .padding(.horizontal, 16)
+        .padding(.vertical, vPad)
+        .background(
+            Color.white.opacity(0.08)
+                .cornerRadius(18, corners: [.bottomLeft, .bottomRight])
+        )
+        .onTapGesture { resetAutoHide() }
+    }
+
+    // MARK: - Snoozer Bar helpers
+
+    private func presentSnoozerBar() {
+        showSnoozerBar = true
+        if isGlobalSnoozeActive || vm.activeAlarm != nil {
+            cancelAutoHide()
+        } else {
+            scheduleAutoHide()
+        }
+    }
+
+    private func cancelAutoHide() {
+        autoHideTask?.cancel()
+        autoHideTask = nil
+    }
+
+    private func scheduleAutoHide() {
+        cancelAutoHide()
+        if isGlobalSnoozeActive || vm.activeAlarm != nil { return }
+        let task = DispatchWorkItem {
+            if !isGlobalSnoozeActive && vm.activeAlarm == nil {
+                withAnimation { showSnoozerBar = false }
+            }
+        }
+        autoHideTask = task
+        DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: task)
+    }
+
+    private func resetAutoHide() {
+        if !isGlobalSnoozeActive, vm.activeAlarm == nil {
+            scheduleAutoHide()
+        } else {
+            cancelAutoHide()
+        }
+    }
+
+    private func activateSnooze1h() {
+        if vm.activeAlarm != nil {
+            vm.snoozeTapped()
+        }
+
+        cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600)
+
+        showSnoozerBar = true
+        cancelAutoHide()
+    }
+
+    private func endSnooze() {
+        cfgStore.value.snoozeUntil = nil
+        if vm.activeAlarm == nil {
+            scheduleAutoHide()
+        } else {
+            cancelAutoHide()
+        }
+    }
+
+    private func adjustSnooze(byMinutes delta: Int) {
+        guard let current = cfgStore.value.snoozeUntil else { return }
+        let newDate = current.addingTimeInterval(TimeInterval(delta * 60))
+        if newDate <= Date() { endSnooze() } else { cfgStore.value.snoozeUntil = newDate }
+    }
+
+    private func snoozeUntilBindingForDate() -> Binding<Date> {
+        Binding<Date>(
+            get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) },
+            set: { newDateOnly in
+                let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600)
+                let cal = Calendar.current
+                let time = cal.dateComponents([.hour, .minute, .second], from: base)
+                var comps = cal.dateComponents([.year, .month, .day], from: newDateOnly)
+                comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second
+                cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newDateOnly
+            }
+        )
+    }
+
+    private func snoozeUntilBindingForTime() -> Binding<Date> {
+        Binding<Date>(
+            get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) },
+            set: { newTimeOnly in
+                let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600)
+                let cal = Calendar.current
+                var comps = cal.dateComponents([.year, .month, .day], from: base)
+                let time = cal.dateComponents([.hour, .minute, .second], from: newTimeOnly)
+                comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second
+                cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newTimeOnly
+            }
+        )
+    }
+
+    private func phaseIconName() -> String {
+        let now = Date()
+        let cal = Calendar.current
+        let comps = cal.dateComponents([.year, .month, .day], from: now)
+        func time(_ t: TimeOfDay) -> Date {
+            var c = comps
+            c.hour = t.hour
+            c.minute = t.minute
+            return cal.date(from: c) ?? now
+        }
+        let dayStart = time(cfgStore.value.dayStart)
+        let nightStart = time(cfgStore.value.nightStart)
+
+        let isNight: Bool
+        if dayStart <= nightStart {
+            if now >= nightStart { isNight = true }
+            else if now >= dayStart { isNight = false } else { isNight = true }
+        } else { // crosses midnight
+            if now >= dayStart { isNight = false }
+            else if now >= nightStart { isNight = true } else { isNight = false }
+        }
+        return isNight ? "moon.fill" : "sun.max.fill"
+    }
+
+    // MARK: - Sheets
+
+    private func datePickerSheetDate() -> some View {
+        NavigationView {
+            VStack {
+                DatePicker(
+                    "Snooze until (date)",
+                    selection: snoozeUntilBindingForDate(),
+                    displayedComponents: [.date]
+                )
+                .datePickerStyle(.graphical)
+                .padding()
+                Spacer()
+            }
+            .navigationTitle("Snooze Date")
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") { showDatePickerDate = false }
+                }
+            }
+        }
+        .onAppear { cancelAutoHide() }
+        .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } }
+    }
+
+    private func datePickerSheetTime() -> some View {
+        NavigationView {
+            VStack {
+                DatePicker(
+                    "Snooze until (time)",
+                    selection: snoozeUntilBindingForTime(),
+                    displayedComponents: [.hourAndMinute]
+                )
+                .datePickerStyle(.wheel)
+                .labelsHidden()
+                .padding()
+                Spacer()
+            }
+            .navigationTitle("Snooze Time")
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") { showDatePickerTime = false }
+                }
+            }
+        }
+        .onAppear { cancelAutoHide() }
+        .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } }
+    }
 }
 
 private extension View {

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -24,6 +24,7 @@ class Observable {
     var deltaText = ObservableValue<String>(default: "+0")
 
     var currentAlarm = ObservableValue<UUID?>(default: nil)
+    var alarmSoundPlaying = ObservableValue<Bool>(default: false)
 
     var debug = ObservableValue<Bool>(default: false)