Jonas Björkert 1 год назад
Родитель
Сommit
6411d6e532
33 измененных файлов с 1835 добавлено и 7 удалено
  1. 234 4
      LoopFollow.xcodeproj/project.pbxproj
  2. 13 0
      LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollow.xcscheme
  3. 154 0
      LoopFollow/Alarm/Alarm.swift
  4. 49 0
      LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift
  5. 24 0
      LoopFollow/Alarm/AlarmCondition/BuildExpireCondition.swift
  6. 34 0
      LoopFollow/Alarm/AlarmConfiguration.swift
  7. 15 0
      LoopFollow/Alarm/AlarmContext.swift
  8. 23 0
      LoopFollow/Alarm/AlarmData.swift
  9. 28 0
      LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift
  10. 211 0
      LoopFollow/Alarm/AlarmEditing/Components/AlarmEditorFields.swift
  11. 129 0
      LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift
  12. 46 0
      LoopFollow/Alarm/AlarmEditing/Editors/BuildExpireAlarmEditor.swift
  13. 35 0
      LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift
  14. 35 0
      LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift
  15. 78 0
      LoopFollow/Alarm/AlarmListView.swift
  16. 57 0
      LoopFollow/Alarm/AlarmManager.swift
  17. 188 0
      LoopFollow/Alarm/AlarmSettingsView.swift
  18. 96 0
      LoopFollow/Alarm/AlarmType.swift
  19. 14 0
      LoopFollow/Alarm/SnoozeState.swift
  20. 1 1
      LoopFollow/Controllers/Nightscout/CAge.swift
  21. 2 0
      LoopFollow/Controllers/Nightscout/NSProfile.swift
  22. 6 0
      LoopFollow/Controllers/Nightscout/ProfileManager.swift
  23. 20 0
      LoopFollow/Extensions/Binding+Optional.swift
  24. 13 0
      LoopFollow/Extensions/UUID+Identifiable.swift
  25. 20 0
      LoopFollow/Helpers/NightscoutUtils.swift
  26. 26 0
      LoopFollow/Helpers/TimeOfDay.swift
  27. 84 0
      LoopFollow/Snoozer/SnoozerView.swift
  28. 7 0
      LoopFollow/Storage/Observable.swift
  29. 7 0
      LoopFollow/Storage/Storage.swift
  30. 9 1
      LoopFollow/Task/AlarmTask.swift
  31. 49 1
      LoopFollow/ViewControllers/SettingsViewController.swift
  32. 77 0
      LoopFollowTests/AlwaysTrueCondition.swift
  33. 51 0
      LoopFollowTests/BuildExpireConditionTests.swift

+ 234 - 4
LoopFollow.xcodeproj/project.pbxproj

@@ -3,11 +3,13 @@
 	archiveVersion = 1;
 	classes = {
 	};
-	objectVersion = 54;
+	objectVersion = 70;
 	objects = {
 
 /* Begin PBXBuildFile section */
 		3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; };
+		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
+		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
 		DD07B5C929E2F9C400C6A635 /* NightscoutUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD07B5C829E2F9C400C6A635 /* NightscoutUtils.swift */; };
 		DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */; };
 		DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C612C4175FD00DBADDF /* NSProfile.swift */; };
@@ -54,6 +56,15 @@
 		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 */; };
+		DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */; };
+		DD4AFB3D2DB55D2900BB593F /* AlarmConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */; };
+		DD4AFB3F2DB55EA700BB593F /* AlarmContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB3E2DB55EA700BB593F /* AlarmContext.swift */; };
+		DD4AFB422DB5655700BB593F /* BuildExpireConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB412DB5652400BB593F /* BuildExpireConditionTests.swift */; };
+		DD4AFB432DB5655D00BB593F /* AlwaysTrueCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB402DB5651500BB593F /* AlwaysTrueCondition.swift */; };
+		DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */; };
+		DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */; };
+		DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */; };
+		DD4AFB6B2DB6BF2A00BB593F /* Binding+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */; };
 		DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */; };
 		DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */; };
 		DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; };
@@ -102,6 +113,10 @@
 		DDCF979824C1489C002C9752 /* GraphSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */; };
 		DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */; };
 		DDCF979E24C2382A002C9752 /* AppStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979D24C2382A002C9752 /* AppStateController.swift */; };
+		DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; };
+		DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; };
+		DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A872D85FD33004DF4DD /* AlarmData.swift */; };
+		DDCF9A8C2D86005E004DF4DD /* AlarmManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */; };
 		DDCFCAF22B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */; };
 		DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */; };
 		DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F002C510C6B00D76A8E /* ObservableUserDefaults.swift */; };
@@ -285,9 +300,22 @@
 		FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.swift */; };
 /* End PBXBuildFile section */
 
+/* Begin PBXContainerItemProxy section */
+		DD0247652DB2EB9A00FCADF6 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = FC97880C2485969B00A7906C /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = FC9788132485969B00A7906C;
+			remoteInfo = LoopFollow;
+		};
+/* End PBXContainerItemProxy section */
+
 /* Begin PBXFileReference section */
 		059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; 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>"; };
+		DD0247612DB2EB9A00FCADF6 /* LoopFollowTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopFollowTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		DD07B5C829E2F9C400C6A635 /* NightscoutUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUtils.swift; sourceTree = "<group>"; };
 		DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManager.swift; sourceTree = "<group>"; };
 		DD0C0C612C4175FD00DBADDF /* NSProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSProfile.swift; sourceTree = "<group>"; };
@@ -333,6 +361,15 @@
 		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>"; };
+		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>"; };
+		DD4AFB3E2DB55EA700BB593F /* AlarmContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmContext.swift; sourceTree = "<group>"; };
+		DD4AFB402DB5651500BB593F /* AlwaysTrueCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysTrueCondition.swift; sourceTree = "<group>"; };
+		DD4AFB412DB5652400BB593F /* BuildExpireConditionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireConditionTests.swift; sourceTree = "<group>"; };
+		DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSettingsView.swift; sourceTree = "<group>"; };
+		DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmListView.swift; sourceTree = "<group>"; };
+		DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Identifiable.swift"; sourceTree = "<group>"; };
+		DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Optional.swift"; sourceTree = "<group>"; };
 		DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = "<group>"; };
 		DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsView.swift; sourceTree = "<group>"; };
 		DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = "<group>"; };
@@ -382,6 +419,10 @@
 		DDCF979724C1489C002C9752 /* GraphSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979924C14DB4002C9752 /* WatchSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSettingsViewController.swift; sourceTree = "<group>"; };
 		DDCF979D24C2382A002C9752 /* AppStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateController.swift; sourceTree = "<group>"; };
+		DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarm.swift; sourceTree = "<group>"; };
+		DDCF9A812D85FD14004DF4DD /* AlarmType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmType.swift; sourceTree = "<group>"; };
+		DDCF9A872D85FD33004DF4DD /* AlarmData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmData.swift; sourceTree = "<group>"; };
+		DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmManager.swift; sourceTree = "<group>"; };
 		DDCFCAF12B17273200BE5751 /* LoopFollowDisplayNameConfig.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = LoopFollowDisplayNameConfig.xcconfig; sourceTree = "<group>"; };
 		DDD10EFE2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableUserDefaultsValue.swift; sourceTree = "<group>"; };
 		DDD10F002C510C6B00D76A8E /* ObservableUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableUserDefaults.swift; sourceTree = "<group>"; };
@@ -570,7 +611,19 @@
 		FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+		DD4AFB4A2DB684A200BB593F /* AlarmEditing */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AlarmEditing; sourceTree = "<group>"; };
+		DD4AFB6C2DBCDA6B00BB593F /* Snoozer */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Snoozer; sourceTree = "<group>"; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
 /* Begin PBXFrameworksBuildPhase section */
+		DD02475E2DB2EB9A00FCADF6 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		FC9788112485969B00A7906C /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
@@ -603,6 +656,24 @@
 			path = Pods;
 			sourceTree = "<group>";
 		};
+		DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = {
+			isa = PBXGroup;
+			children = (
+				DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */,
+				DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */,
+			);
+			path = AlarmCondition;
+			sourceTree = "<group>";
+		};
+		DD02476E2DB4321000FCADF6 /* LoopFollowTests */ = {
+			isa = PBXGroup;
+			children = (
+				DD4AFB412DB5652400BB593F /* BuildExpireConditionTests.swift */,
+				DD4AFB402DB5651500BB593F /* AlwaysTrueCondition.swift */,
+			);
+			path = LoopFollowTests;
+			sourceTree = "<group>";
+		};
 		DD0C0C692C4852A100DBADDF /* Metric */ = {
 			isa = PBXGroup;
 			children = (
@@ -769,6 +840,8 @@
 				DDCF979324C0D380002C9752 /* UIViewExtension.swift */,
 				DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */,
 				DD0C0C632C45A59400DBADDF /* HKUnit+Extensions.swift */,
+				DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */,
+				DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */,
 			);
 			path = Extensions;
 			sourceTree = "<group>";
@@ -803,6 +876,23 @@
 			path = Scripts;
 			sourceTree = "<group>";
 		};
+		DDCF9A7E2D85FCE6004DF4DD /* Alarm */ = {
+			isa = PBXGroup;
+			children = (
+				DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */,
+				DD4AFB4A2DB684A200BB593F /* AlarmEditing */,
+				DD4AFB482DB576C200BB593F /* AlarmSettingsView.swift */,
+				DD4AFB3E2DB55EA700BB593F /* AlarmContext.swift */,
+				DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */,
+				DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */,
+				DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */,
+				DDCF9A872D85FD33004DF4DD /* AlarmData.swift */,
+				DDCF9A812D85FD14004DF4DD /* AlarmType.swift */,
+				DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */,
+			);
+			path = Alarm;
+			sourceTree = "<group>";
+		};
 		DDDF6F412D479A8E00884336 /* Loop */ = {
 			isa = PBXGroup;
 			children = (
@@ -1044,6 +1134,8 @@
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 			isa = PBXGroup;
 			children = (
+				DD4AFB6C2DBCDA6B00BB593F /* Snoozer */,
+				DDCF9A7E2D85FCE6004DF4DD /* Alarm */,
 				DD1A97122D429495000DDC11 /* Settings */,
 				DD2C2E522D3C36A8006413A5 /* Dexcom */,
 				DD2C2E4D2D3B8ACF006413A5 /* Nightscout */,
@@ -1078,6 +1170,7 @@
 				FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */,
 				FC5A5C3C2497B229009C550E /* Config.xcconfig */,
 				FC8DEEE32485D1680075863F /* LoopFollow */,
+				DD02476E2DB4321000FCADF6 /* LoopFollowTests */,
 				FC9788152485969B00A7906C /* Products */,
 				8E32230C453C93FDCE59C2B9 /* Pods */,
 				6A5880E0B811AF443B05AB02 /* Frameworks */,
@@ -1088,6 +1181,7 @@
 			isa = PBXGroup;
 			children = (
 				FC9788142485969B00A7906C /* Loop Follow.app */,
+				DD0247612DB2EB9A00FCADF6 /* LoopFollowTests.xctest */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -1112,6 +1206,7 @@
 		FCC688542489367300A0279D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
 				DD7B0D432D730A320063DCB6 /* CycleHelper.swift */,
 				DDF6999C2C5AAA4C0058A8D9 /* Views */,
 				FCC6886E2489A53800A0279D /* AppConstants.swift */,
@@ -1155,6 +1250,26 @@
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
+		DD0247602DB2EB9A00FCADF6 /* LoopFollowTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = DD0247672DB2EB9A00FCADF6 /* Build configuration list for PBXNativeTarget "LoopFollowTests" */;
+			buildPhases = (
+				DD02475D2DB2EB9A00FCADF6 /* Sources */,
+				DD02475E2DB2EB9A00FCADF6 /* Frameworks */,
+				DD02475F2DB2EB9A00FCADF6 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				DD0247662DB2EB9A00FCADF6 /* PBXTargetDependency */,
+			);
+			name = LoopFollowTests;
+			packageProductDependencies = (
+			);
+			productName = LoopFollowTests;
+			productReference = DD0247612DB2EB9A00FCADF6 /* LoopFollowTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
 		FC9788132485969B00A7906C /* LoopFollow */ = {
 			isa = PBXNativeTarget;
 			buildConfigurationList = FC97882D2485969C00A7906C /* Build configuration list for PBXNativeTarget "LoopFollow" */;
@@ -1170,6 +1285,9 @@
 			);
 			dependencies = (
 			);
+			fileSystemSynchronizedGroups = (
+				DD4AFB4A2DB684A200BB593F /* AlarmEditing */,
+			);
 			name = LoopFollow;
 			packageProductDependencies = (
 				DD48781B2C7DAF140048F05C /* SwiftJWT */,
@@ -1184,10 +1302,14 @@
 		FC97880C2485969B00A7906C /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastSwiftUpdateCheck = 1150;
+				LastSwiftUpdateCheck = 1630;
 				LastUpgradeCheck = 1140;
 				ORGANIZATIONNAME = "Jon Fawcett";
 				TargetAttributes = {
+					DD0247602DB2EB9A00FCADF6 = {
+						CreatedOnToolsVersion = 16.3;
+						TestTargetID = FC9788132485969B00A7906C;
+					};
 					FC9788132485969B00A7906C = {
 						CreatedOnToolsVersion = 11.4.1;
 					};
@@ -1210,11 +1332,19 @@
 			projectRoot = "";
 			targets = (
 				FC9788132485969B00A7906C /* LoopFollow */,
+				DD0247602DB2EB9A00FCADF6 /* LoopFollowTests */,
 			);
 		};
 /* End PBXProject section */
 
 /* Begin PBXResourcesBuildPhase section */
+		DD02475F2DB2EB9A00FCADF6 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		FC9788122485969B00A7906C /* Resources */ = {
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -1356,10 +1486,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-LoopFollow/Pods-LoopFollow-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-LoopFollow/Pods-LoopFollow-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-LoopFollow/Pods-LoopFollow-frameworks.sh\"\n";
@@ -1408,6 +1542,15 @@
 /* End PBXShellScriptBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
+		DD02475D2DB2EB9A00FCADF6 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DD4AFB432DB5655D00BB593F /* AlwaysTrueCondition.swift in Sources */,
+				DD4AFB422DB5655700BB593F /* BuildExpireConditionTests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		FC9788102485969B00A7906C /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -1416,13 +1559,17 @@
 				FCC68850248935D800A0279D /* AlarmViewController.swift in Sources */,
 				DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */,
 				DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */,
+				DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */,
 				DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */,
 				DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */,
 				DD9ACA102D34129200415D8A /* Task.swift in Sources */,
+				DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */,
 				DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */,
 				FC7CE59F248D8D23001F83B8 /* SnoozeViewController.swift in Sources */,
+				DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */,
 				DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */,
 				DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */,
+				DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */,
 				DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */,
 				FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */,
 				DDF699942C555B310058A8D9 /* ViewControllerManager.swift in Sources */,
@@ -1439,6 +1586,7 @@
 				DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */,
 				FC16A97A24996673003D6245 /* NightScout.swift in Sources */,
 				DD07B5C929E2F9C400C6A635 /* NightscoutUtils.swift in Sources */,
+				DDCF9A8C2D86005E004DF4DD /* AlarmManager.swift in Sources */,
 				FCC6886924898FB100A0279D /* UserDefaultsValueGroups.swift in Sources */,
 				DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */,
 				DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */,
@@ -1460,11 +1608,13 @@
 				DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */,
 				DDDF6F472D479AD200884336 /* LoopOverrideViewModel.swift in Sources */,
 				DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */,
+				DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */,
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
 				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
+				DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */,
 				DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */,
 				DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
 				DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
@@ -1494,6 +1644,7 @@
 				DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */,
 				DD9ACA0A2D33095600415D8A /* MinAgoTask.swift in Sources */,
 				DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */,
+				DD4AFB3F2DB55EA700BB593F /* AlarmContext.swift in Sources */,
 				DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
 				DD58171A2D299EF80041FB98 /* DexcomHeartbeatBluetoothDevice.swift in Sources */,
 				DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */,
@@ -1509,19 +1660,23 @@
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
 				DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
+				DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
 				DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
+				DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */,
 				DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */,
 				DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
 				DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
 				DD493AE92ACF2445009A6922 /* BGData.swift in Sources */,
 				FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */,
+				DD4AFB6B2DB6BF2A00BB593F /* Binding+Optional.swift in Sources */,
 				DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */,
 				DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */,
 				DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */,
 				DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */,
 				DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */,
+				DD4AFB3D2DB55D2900BB593F /* AlarmConfiguration.swift in Sources */,
 				DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */,
 				DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
 				DDDF6F452D479AB100884336 /* LoopOverrideView.swift in Sources */,
@@ -1550,6 +1705,7 @@
 				DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */,
 				DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */,
 				DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
+				DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */,
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
 				DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
 				DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */,
@@ -1567,6 +1723,14 @@
 		};
 /* End PBXSourcesBuildPhase section */
 
+/* Begin PBXTargetDependency section */
+		DD0247662DB2EB9A00FCADF6 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = FC9788132485969B00A7906C /* LoopFollow */;
+			targetProxy = DD0247652DB2EB9A00FCADF6 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
 /* Begin PBXVariantGroup section */
 		FC97881F2485969B00A7906C /* Main.storyboard */ = {
 			isa = PBXVariantGroup;
@@ -1587,6 +1751,59 @@
 /* End PBXVariantGroup section */
 
 /* Begin XCBuildConfiguration section */
+		DD0247682DB2EB9A00FCADF6 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 18.4;
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.LoopFollowTests";
+				PRODUCT_MODULE_NAME = LoopFollowTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop Follow.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Loop Follow";
+			};
+			name = Debug;
+		};
+		DD0247692DB2EB9A00FCADF6 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GENERATE_INFOPLIST_FILE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 18.4;
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.LoopFollowTests";
+				PRODUCT_MODULE_NAME = LoopFollowTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop Follow.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Loop Follow";
+			};
+			name = Release;
+		};
 		FC97882B2485969C00A7906C /* Debug */ = {
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = FC5A5C3C2497B229009C550E /* Config.xcconfig */;
@@ -1622,6 +1839,7 @@
 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
 				COPY_PHASE_STRIP = NO;
 				DEBUG_INFORMATION_FORMAT = dwarf;
+				DEFINES_MODULE = YES;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
 				ENABLE_TESTABILITY = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -1642,6 +1860,7 @@
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
+				PRODUCT_MODULE_NAME = LoopFollow;
 				SDKROOT = iphoneos;
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -1683,6 +1902,7 @@
 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
 				COPY_PHASE_STRIP = NO;
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				DEFINES_MODULE = YES;
 				ENABLE_NS_ASSERTIONS = NO;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -1696,6 +1916,7 @@
 				IPHONEOS_DEPLOYMENT_TARGET = 15.6;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
+				PRODUCT_MODULE_NAME = LoopFollow;
 				SDKROOT = iphoneos;
 				SWIFT_COMPILATION_MODE = wholemodule;
 				SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -1707,10 +1928,10 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements";
 				CODE_SIGN_STYLE = Automatic;
+				DEFINES_MODULE = YES;
 				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
 				INFOPLIST_FILE = LoopFollow/Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -1729,10 +1950,10 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements";
 				CODE_SIGN_STYLE = Automatic;
+				DEFINES_MODULE = YES;
 				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
 				INFOPLIST_FILE = LoopFollow/Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -1750,6 +1971,15 @@
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
+		DD0247672DB2EB9A00FCADF6 /* Build configuration list for PBXNativeTarget "LoopFollowTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				DD0247682DB2EB9A00FCADF6 /* Debug */,
+				DD0247692DB2EB9A00FCADF6 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 		FC97880F2485969B00A7906C /* Build configuration list for PBXProject "LoopFollow" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (

+ 13 - 0
LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollow.xcscheme

@@ -28,6 +28,19 @@
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       shouldUseLaunchSchemeArgsEnv = "YES"
       shouldAutocreateTestPlan = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO"
+            parallelizable = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DD0247602DB2EB9A00FCADF6"
+               BuildableName = "LoopFollowTests.xctest"
+               BlueprintName = "LoopFollowTests"
+               ReferencedContainer = "container:LoopFollow.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
    </TestAction>
    <LaunchAction
       buildConfiguration = "Debug"

+ 154 - 0
LoopFollow/Alarm/Alarm.swift

@@ -0,0 +1,154 @@
+//
+//  Alarm.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+enum PlaySoundOption: String, CaseIterable, Codable {
+  case always, day, night, never
+}
+enum RepeatSoundOption: String, CaseIterable, Codable {
+  case always, day, night, never
+}
+enum ActiveOption: String, CaseIterable, Codable {
+  case always, day, night
+}
+
+struct Alarm: Identifiable, Codable, Equatable {
+    var id: UUID = UUID()
+    var type: AlarmType
+
+    /// Name of the alarm, defaults to alarm type
+    var name: String
+
+    var isEnabled: Bool = true
+
+    /// If the alarm is manually snoozed, we store the end time for the snooze here
+    var snoozedUntil: Date?
+
+    /// Alarm threashold, it can be a bgvalue (in mg/Dl), or day for example
+    /// Also used as bg limit for drop alarms for example
+    var threshold: Float?
+
+    /// If the alarm looks at predictions, this is how many predictions to include
+    var predictiveReadings: Int?
+
+    /// If the alarm acts on delta, the delta is stored here, it can be a delta bgvalue (in mg/Dl)
+    /// If a delta alarm is only active below a bg, that bg is stored in threshold
+    var delta: Float?
+
+    /// Number of consecutive 5‑min readings that must satisfy the alarm criteria
+    var consecutiveReadings: Int?
+
+    /// Size of window to observe values, for example battery drop of x within this number of minutes,
+    var monitoringWindow: Int?
+
+    var soundFile: SoundFile
+
+    /// Snooze duration, it can be minutes, days or hours. Stepping is different per alarm type
+    var snoozeDuration: Int = 5
+
+    /// When the alarm should play it's sound
+    var playSoundOption: PlaySoundOption = .always
+
+    /// When the sound should repeat
+    var repeatSoundOption: RepeatSoundOption = .always
+
+    /// When is the alarm active
+    var activeOption: ActiveOption = .always
+
+    /// For temporary alerts, it will trigger once and then disable itself
+    var disableAfterFiring: Bool = false
+
+    // ─────────────────────────────────────────────────────────────
+    //  Missed‑Bolus‑specific settings
+    // ─────────────────────────────────────────────────────────────
+
+    /// “Prebolus Max Time” (if a bolus comes within this many minutes *before* the carbs, treat it as prebolus)
+    var missedBolusPrebolusWindow: Int?
+
+    /// “Ignore Bolus <= X units” (don’t count any bolus smaller than or equal to this)
+    var missedBolusIgnoreSmallBolusUnits: Float?
+
+    /// “Ignore Under Grams” (if carb entry is under this many grams, skip the alert)
+    var missedBolusIgnoreUnderGrams: Float?
+
+    /// “Ignore Under BG” (if current BG is below this, skip the alert)
+    var missedBolusIgnoreUnderBG: Float?
+
+    // ─────────────────────────────────────────────────────────────
+    // Bolus‑Count fields ─
+    // ─────────────────────────────────────────────────────────────
+    /// trigger when N or more of those boluses occur...
+    var bolusCountThreshold: Int?
+    /// ...within this many minutes
+    var bolusWindowMinutes: Int?
+
+    func checkCondition(data: AlarmData) -> Bool {
+        return false
+    }
+
+    func trigger() {
+        // TODO: play sound / update UI / schedule snooze etc.
+        print("🔔 Alarm “\(name)” triggered! Playing \(soundFile.displayName)")
+    }
+
+    init(type: AlarmType) {
+        self.type = type
+        self.name = type.rawValue
+
+        switch type {
+        case .buildExpire:
+            /// Alert 7 days before the build expires
+            self.threshold = 7
+            self.soundFile = .wrongAnswer
+            self.snoozeDuration = 1
+            self.repeatSoundOption = .always
+        case .low:
+            soundFile = .indeed
+        case .iob:
+            soundFile = .alertToneRingtone1
+        case .bolus:
+            soundFile = .dholShuffleloop
+        case .cob:
+            soundFile = .alertToneRingtone2
+        case .high:
+            soundFile = .timeHasCome
+        case .fastDrop:
+            soundFile = .bigClockTicking
+        case .fastRise:
+            soundFile = .cartoonFailStringsTrumpet
+        case .missedReading:
+            soundFile = .cartoonTipToeSneakyWalk
+        case .notLooping:
+            soundFile = .sciFiEngineShutDown
+        case .missedBolus:
+            soundFile = .dholShuffleloop
+        case .sensorChange:
+            soundFile = .wakeUpWillYou
+        case .pumpChange:
+            soundFile = .wakeUpWillYou
+        case .pump:
+            soundFile = .marimbaDescend
+        case .battery:
+            soundFile = .machineCharge
+        case .batteryDrop:
+            soundFile = .machineCharge
+        case .recBolus:
+            soundFile = .dholShuffleloop
+        case .overrideStart:
+            soundFile = .endingReached
+        case .overrideEnd:
+            soundFile = .alertToneBusy
+        case .tempTargetStart:
+            soundFile = .endingReached
+        case .tempTargetEnd:
+            soundFile = .alertToneBusy
+        }
+    }
+}

+ 49 - 0
LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift

@@ -0,0 +1,49 @@
+//
+//  AlarmCondition.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+protocol AlarmCondition {
+    static var type: AlarmType { get }
+    init()
+    /// pure, per-alarm logic against `AlarmData`
+    func evaluate(alarm: Alarm, data: AlarmData) -> Bool
+}
+
+extension AlarmCondition {
+    /// applies every global & per-alarm guard exactly once
+    func shouldFire(alarm: Alarm, data: AlarmData, context: AlarmContext) -> Bool {
+        // master on/off
+        guard alarm.isEnabled else { return false }
+        // global mute
+        if let until = context.config.muteUntil, until > context.now { return false }
+        // per-alarm snooze
+        if let snooze = alarm.snoozedUntil, snooze > context.now { return false }
+
+        // time-of-day guard
+        let comps   = Calendar.current.dateComponents([.hour, .minute], from: context.now)
+        let nowMin  = (comps.hour! * 60) + comps.minute!
+        let dStart  = context.config.dayStart.minutesSinceMidnight
+        let nStart  = context.config.nightStart.minutesSinceMidnight
+        let isNight = (nowMin < dStart) || (nowMin >= nStart)
+
+        switch alarm.activeOption {
+        case .always:
+            break
+        case .day:
+            // only fire in day
+            guard !isNight else { return false }
+        case .night:
+            // only fire in night
+            guard isNight else { return false }
+        }
+
+        // finally, run the type-specific logic
+        return evaluate(alarm: alarm, data: data)
+    }
+}

+ 24 - 0
LoopFollow/Alarm/AlarmCondition/BuildExpireCondition.swift

@@ -0,0 +1,24 @@
+//
+//  ExpireCondition.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-18.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+struct BuildExpireCondition: AlarmCondition {
+    static let type: AlarmType = .buildExpire
+    init() {}
+
+    func evaluate(alarm: Alarm, data: AlarmData) -> Bool {
+        guard let expiry = data.expireDate else { return false }
+        guard let thresholdDays = alarm.threshold else { return false }
+
+        let thresholdSeconds = TimeInterval(thresholdDays) * TimeUnit.day.seconds
+        let thresholdDate = expiry.addingTimeInterval(-thresholdSeconds)
+
+        return Date() >= thresholdDate
+    }
+}

+ 34 - 0
LoopFollow/Alarm/AlarmConfiguration.swift

@@ -0,0 +1,34 @@
+// AlarmConfiguration.swift
+// LoopFollow
+//
+// Created by Jonas Björkert on 2025‑04‑20.
+// Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+struct AlarmConfiguration: Codable, Equatable {
+    // MARK: Core
+    var snoozeUntil: Date?
+    var muteUntil: Date?
+    var dayStart: TimeOfDay
+    var nightStart: TimeOfDay
+
+    // MARK: System audio overrides
+    var overrideSystemOutputVolume: Bool
+    var forcedOutputVolume: Float            // 0 … 1
+    var audioDuringCalls: Bool
+    var ignoreZeroBG: Bool
+    var autoSnoozeCGMStart: Bool
+
+    static let `default` = AlarmConfiguration(
+        muteUntil:              nil,
+        dayStart:               TimeOfDay(hour: 6,  minute: 0),
+        nightStart:             TimeOfDay(hour: 22, minute: 0),
+        overrideSystemOutputVolume: true,
+        forcedOutputVolume:         0.5,
+        audioDuringCalls:           true,
+        ignoreZeroBG:               true,
+        autoSnoozeCGMStart:         false,
+    )
+}

+ 15 - 0
LoopFollow/Alarm/AlarmContext.swift

@@ -0,0 +1,15 @@
+//
+//  AlarmContext.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-20.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import Foundation
+
+struct AlarmContext {
+    let now: Date
+    let config: AlarmConfiguration
+}

+ 23 - 0
LoopFollow/Alarm/AlarmData.swift

@@ -0,0 +1,23 @@
+//
+//  AlarmData.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+struct AlarmData {
+//    let bgReadings: [ShareGlucoseData]
+//    let iob: Double?
+//    let cob: Double?
+//    let predictionData: [ShareGlucoseData]
+//    let latestBoluses: [BolusEntry]
+//    let batteryLevel: Double?
+//    let latestCarbs: [CarbEntry]
+//    let overrideData: [OverrideEntry]
+//    let tempTargetData: [TempTargetEntry]
+//    let pumpVolume: Double?
+    let expireDate: Date?
+}

+ 28 - 0
LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

@@ -0,0 +1,28 @@
+//
+//  AlarmEditor.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+
+struct AlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        switch alarm.type {
+        case .buildExpire:
+            BuildExpireAlarmEditor(alarm: $alarm)
+        case .high:
+            HighBgAlarmEditor(alarm: $alarm)
+        case .low:
+            LowBgAlarmEditor(alarm: $alarm)
+        default:
+            Text("No editor for \(alarm.type.rawValue)")
+                .padding()
+        }
+    }
+}

+ 211 - 0
LoopFollow/Alarm/AlarmEditing/Components/AlarmEditorFields.swift

@@ -0,0 +1,211 @@
+//
+//  AlarmEditorFields.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct AlarmGeneralSection: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Section(header: Text("General")) {
+            HStack {
+                Text("Name")
+                TextField("Alarm Name", text: $alarm.name)
+                    .multilineTextAlignment(.trailing)
+                    .textFieldStyle(.plain)
+            }
+            Toggle("Enabled", isOn: $alarm.isEnabled)
+        }
+    }
+}
+
+struct AlarmThresholdSection: View {
+    let title: String
+    let range: ClosedRange<Double>
+    let step: Double
+    let unitLabel: String
+    @Binding var value: Double
+
+    var body: some View {
+        Section(
+          header: Text(title),
+          footer: Text("\(title) in \(Int(range.lowerBound))–\(Int(range.upperBound)) \(unitLabel)")
+        ) {
+            Stepper(
+                "\(title): \(Int(value)) \(unitLabel)",
+                value: $value,
+                in: range,
+                step: step
+            )
+        }
+    }
+}
+
+struct AlarmSnoozeSection: View {
+    let title: String
+    let range: ClosedRange<Double>
+    let step: Double
+    let unitLabel: String
+    @Binding var value: Double
+
+    var body: some View {
+        Section(
+          header: Text(title),
+          footer: Text("How long to snooze after firing \(Int(range.lowerBound))–\(Int(range.upperBound)) \(unitLabel)")
+        ) {
+            Stepper(
+                "\(title): \(Int(value)) \(unitLabel)",
+                value: $value,
+                in: range,
+                step: step
+            )
+        }
+    }
+}
+
+import SwiftUI
+
+struct AlarmAudioSection: View {
+    @Binding var alarm: Alarm
+    @State private var showingTonePicker = false
+
+    var body: some View {
+        Section(header: Text("Alert Sound")) {
+            // ——— Tone Row ———
+            Button {
+                showingTonePicker = true
+            } label: {
+                HStack {
+                    Text("Tone")
+                    Spacer()
+                    Text(alarm.soundFile.displayName)
+                        .foregroundColor(.secondary)
+                    Image(systemName: "chevron.right")
+                        .foregroundColor(.secondary)
+                }
+            }
+            .sheet(isPresented: $showingTonePicker) {
+                NavigationView {
+                    List {
+                        ForEach(SoundFile.allCases) { tone in
+                            Button {
+                                alarm.soundFile = tone
+                                // play test tone
+                                AlarmSound.setSoundFile(str: tone.rawValue)
+                                AlarmSound.stop()
+                                AlarmSound.playTest()
+                            } label: {
+                                HStack {
+                                    Text(tone.displayName)
+                                    if alarm.soundFile == tone {
+                                        Spacer()
+                                        Image(systemName: "checkmark")
+                                            .foregroundColor(.accentColor)
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    .navigationTitle("Choose Tone")
+                    .toolbar {
+                        ToolbarItem(placement: .confirmationAction) {
+                            Button("Done") {
+                                AlarmSound.stop()
+                                showingTonePicker = false
+                            }
+                        }
+                    }
+                }
+            }
+
+            // ——— Play / Repeat Toggles ———
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Play")
+                    .font(.subheadline)
+                    .foregroundColor(.secondary)
+
+                Picker("", selection: $alarm.playSoundOption) {
+                    ForEach(PlaySoundOption.allCases, id: \.self) { opt in
+                        Text(opt.rawValue.capitalized).tag(opt)
+                    }
+                }
+                .pickerStyle(.segmented)
+            }
+
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Repeat")
+                    .font(.subheadline)
+                    .foregroundColor(.secondary)
+
+                Picker("", selection: $alarm.repeatSoundOption) {
+                    ForEach(RepeatSoundOption.allCases, id: \.self) { opt in
+                        Text(opt.rawValue.capitalized).tag(opt)
+                    }
+                }
+                .pickerStyle(.segmented)
+            }
+        }
+    }
+}
+
+struct AlarmActiveSection: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Section(header: Text("Active During")) {
+            Picker("Active", selection: $alarm.activeOption) {
+                Text("Always").tag(ActiveOption.always)
+                Text("Day").tag(ActiveOption.day)
+                Text("Night").tag(ActiveOption.night)
+            }
+            .pickerStyle(.segmented)
+        }
+    }
+}
+
+struct AlarmSnoozedUntilSection: View {
+    @Binding var alarm: Alarm
+
+    private var isSnoozed: Binding<Bool> {
+        Binding(
+            get: {
+                if let until = alarm.snoozedUntil, until > Date() {
+                    return true
+                }
+                return false
+            },
+            set: { on in
+                if on {
+                    // keep existing future snooze or set default ahead
+                    if let until = alarm.snoozedUntil, until > Date() {
+                        alarm.snoozedUntil = until
+                    } else {
+                        let secs = alarm.type.timeUnit.seconds
+                        alarm.snoozedUntil = Date().addingTimeInterval(Double(alarm.snoozeDuration) * secs)
+                    }
+                } else {
+                    alarm.snoozedUntil = nil
+                }
+            }
+        )
+    }
+
+    var body: some View {
+        Section(header: Text("Snoozed Until")) {
+            Toggle("Snoozed", isOn: isSnoozed)
+            if isSnoozed.wrappedValue, let until = alarm.snoozedUntil {
+                DatePicker("Until", selection: Binding(
+                    get: { until },
+                    set: { alarm.snoozedUntil = $0 }
+                ), displayedComponents: [.date, .hourAndMinute])
+            }
+        }
+    }
+}
+
+

+ 129 - 0
LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift

@@ -0,0 +1,129 @@
+//
+//  File.swift
+//  SoundFile
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+enum SoundFile: String, CaseIterable, Identifiable, Codable {
+    case alarmBuzzer                  = "Alarm_Buzzer"
+    case alarmClock                   = "Alarm_Clock"
+    case alertToneBusy                = "Alert_Tone_Busy"
+    case alertToneRingtone1           = "Alert_Tone_Ringtone_1"
+    case alertToneRingtone2           = "Alert_Tone_Ringtone_2"
+    case alienSiren                   = "Alien_Siren"
+    case ambulance                    = "Ambulance"
+    case analogWatchAlarm             = "Analog_Watch_Alarm"
+    case bigClockTicking              = "Big_Clock_Ticking"
+    case burglarAlarmSiren1           = "Burglar_Alarm_Siren_1"
+    case burglarAlarmSiren2           = "Burglar_Alarm_Siren_2"
+    case cartoonAscendClimbSneaky     = "Cartoon_Ascend_Climb_Sneaky"
+    case cartoonAscendThenDescend     = "Cartoon_Ascend_Then_Descend"
+    case cartoonBounceToCeiling       = "Cartoon_Bounce_To_Ceiling"
+    case cartoonDreamyGlissandoHarp   = "Cartoon_Dreamy_Glissando_Harp"
+    case cartoonFailStringsTrumpet    = "Cartoon_Fail_Strings_Trumpet"
+    case cartoonMachineClumsyLoop     = "Cartoon_Machine_Clumsy_Loop"
+    case cartoonSiren                 = "Cartoon_Siren"
+    case cartoonTipToeSneakyWalk      = "Cartoon_Tip_Toe_Sneaky_Walk"
+    case cartoonUhOh                  = "Cartoon_Uh_Oh"
+    case cartoonVillainHorns          = "Cartoon_Villain_Horns"
+    case cellPhoneRingTone            = "Cell_Phone_Ring_Tone"
+    case chimesGlassy                 = "Chimes_Glassy"
+    case computerMagic                = "Computer_Magic"
+    case csfx2Alarm                   = "CSFX-2_Alarm"
+    case cuckooClock                  = "Cuckoo_Clock"
+    case dholShuffleloop              = "Dhol_Shuffleloop"
+    case discreet                     = "Discreet"
+    case earlySunrise                 = "Early_Sunrise"
+    case emergencyAlarmCarbonMonoxide = "Emergency_Alarm_Carbon_Monoxide"
+    case emergencyAlarmSiren          = "Emergency_Alarm_Siren"
+    case emergencyAlarm               = "Emergency_Alarm"
+    case endingReached                = "Ending_Reached"
+    case fly                          = "Fly"
+    case ghostHover                   = "Ghost_Hover"
+    case goodMorning                  = "Good_Morning"
+    case hellYeahSomewhatCalmer       = "Hell_Yeah_Somewhat_Calmer"
+    case inAHurry                     = "In_A_Hurry"
+    case indeed                       = "Indeed"
+    case insistently                  = "Insistently"
+    case jingleAllTheWay              = "Jingle_All_The_Way"
+    case laserShoot                   = "Laser_Shoot"
+    case machineCharge                = "Machine_Charge"
+    case magicalTwinkle               = "Magical_Twinkle"
+    case marchingHeavyFootedFatElephants = "Marching_Heavy_Footed_Fat_Elephants"
+    case marimbaDescend               = "Marimba_Descend"
+    case marimbaFlutterOrShake        = "Marimba_Flutter_or_Shake"
+    case martianGun                   = "Martian_Gun"
+    case martianScanner               = "Martian_Scanner"
+    case metallic                     = "Metallic"
+    case nightguard                   = "Nightguard"
+    case notKiddin                    = "Not_Kiddin"
+    case openYourEyesAndSee           = "Open_Your_Eyes_And_See"
+    case orchestralHorns              = "Orchestral_Horns"
+    case oringz                       = "Oringz"
+    case pagerBeeps                   = "Pager_Beeps"
+    case remembersMeOfAsia            = "Remembers_Me_Of_Asia"
+    case riseAndShine                 = "Rise_And_Shine"
+    case rush                         = "Rush"
+    case sciFiAirRaidAlarm            = "Sci-Fi_Air_Raid_Alarm"
+    case sciFiAlarmLoop1              = "Sci-Fi_Alarm_Loop_1"
+    case sciFiAlarmLoop2              = "Sci-Fi_Alarm_Loop_2"
+    case sciFiAlarmLoop3              = "Sci-Fi_Alarm_Loop_3"
+    case sciFiAlarmLoop4              = "Sci-Fi_Alarm_Loop_4"
+    case sciFiAlarm                   = "Sci-Fi_Alarm"
+    case sciFiComputerConsoleAlarm    = "Sci-Fi_Computer_Console_Alarm"
+    case sciFiConsoleAlarm            = "Sci-Fi_Console_Alarm"
+    case sciFiEerieAlarm              = "Sci-Fi_Eerie_Alarm"
+    case sciFiEngineShutDown          = "Sci-Fi_Engine_Shut_Down"
+    case sciFiIncomingMessageAlert    = "Sci-Fi_Incoming_Message_Alert"
+    case sciFiSpaceshipMessage        = "Sci-Fi_Spaceship_Message"
+    case sciFiSpaceshipWarmUp         = "Sci-Fi_Spaceship_Warm_Up"
+    case sciFiWarning                 = "Sci-Fi_Warning"
+    case signatureCorporate           = "Signature_Corporate"
+    case siriAlertCalibrationNeeded   = "Siri_Alert_Calibration_Needed"
+    case siriAlertDeviceMuted         = "Siri_Alert_Device_Muted"
+    case siriAlertGlucoseDroppingFast = "Siri_Alert_Glucose_Dropping_Fast"
+    case siriAlertGlucoseRisingFast   = "Siri_Alert_Glucose_Rising_Fast"
+    case siriAlertHighGlucose         = "Siri_Alert_High_Glucose"
+    case siriAlertLowGlucose          = "Siri_Alert_Low_Glucose"
+    case siriAlertMissedReadings      = "Siri_Alert_Missed_Readings"
+    case siriAlertTransmitterBatteryLow = "Siri_Alert_Transmitter_Battery_Low"
+    case siriAlertUrgentHighGlucose   = "Siri_Alert_Urgent_High_Glucose"
+    case siriAlertUrgentLowGlucose    = "Siri_Alert_Urgent_Low_Glucose"
+    case siriCalibrationNeeded        = "Siri_Calibration_Needed"
+    case siriDeviceMuted              = "Siri_Device_Muted"
+    case siriGlucoseDroppingFast      = "Siri_Glucose_Dropping_Fast"
+    case siriGlucoseRisingFast        = "Siri_Glucose_Rising_Fast"
+    case siriHighGlucose              = "Siri_High_Glucose"
+    case siriLowGlucose               = "Siri_Low_Glucose"
+    case siriMissedReadings           = "Siri_Missed_Readings"
+    case siriTransmitterBatteryLow    = "Siri_Transmitter_Battery_Low"
+    case siriUrgentHighGlucose        = "Siri_Urgent_High_Glucose"
+    case siriUrgentLowGlucose         = "Siri_Urgent_Low_Glucose"
+    case softMarimbaPadPositive       = "Soft_Marimba_Pad_Positive"
+    case softWarmAiryOptimistic       = "Soft_Warm_Airy_Optimistic"
+    case softWarmAiryReassuring       = "Soft_Warm_Airy_Reassuring"
+    case storeDoorChime               = "Store_Door_Chime"
+    case sunny                        = "Sunny"
+    case thunderSoundFX               = "Thunder_Sound_FX"
+    case timeHasCome                  = "Time_Has_Come"
+    case tornadoSiren                 = "Tornado_Siren"
+    case twoTurtleDoves               = "Two_Turtle_Doves"
+    case unpaved                      = "Unpaved"
+    case wakeUpWillYou                = "Wake_Up_Will_You"
+    case winGain                      = "Win_Gain"
+    case wrongAnswer                  = "Wrong_Answer"
+
+    // Identifiable conformance
+    var id: SoundFile { self }
+
+    /// Human-friendly name (spaces instead of underscores)
+    var displayName: String {
+        rawValue
+            .replacingOccurrences(of: "_", with: " ")
+            .replacingOccurrences(of: "  ", with: " ")
+    }
+}

+ 46 - 0
LoopFollow/Alarm/AlarmEditing/Editors/BuildExpireAlarmEditor.swift

@@ -0,0 +1,46 @@
+//
+//  BuildExpireAlarmEditor.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct BuildExpireAlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Form {
+          AlarmGeneralSection(alarm: $alarm)
+
+          AlarmThresholdSection(
+            title: "Expires In",
+            range: 1...14,
+            step: 1,
+            unitLabel: alarm.type.timeUnit.label,
+            value: Binding(
+              get: { Double(alarm.threshold ?? 1) },
+              set: { alarm.threshold = Float($0) }
+            )
+          )
+
+          AlarmSnoozeSection(
+            title: "Default Snooze",
+            range: 1...14,
+            step: 1,
+            unitLabel: alarm.type.timeUnit.label,
+            value: Binding(
+              get: { Double(alarm.snoozeDuration) },
+              set: { alarm.snoozeDuration = Int($0) }
+            )
+          )
+
+          AlarmAudioSection(alarm: $alarm)
+          AlarmActiveSection(alarm: $alarm)
+          AlarmSnoozedUntilSection(alarm: $alarm)
+        }
+        .navigationTitle(alarm.type.rawValue)
+    }
+}

+ 35 - 0
LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift

@@ -0,0 +1,35 @@
+//
+//  HighBgAlarmEditor.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+
+struct HighBgAlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Form {/*
+            AlarmNameField(alarm: $alarm)
+            EnabledToggle(alarm: $alarm)
+            ValueStepper(
+                title: "BG Above",
+                value: Binding(
+                    get: { Double(alarm.threshold ?? 0) },
+                    set: { alarm.threshold = Float($0) }
+                ),
+                range: 0...500, step: 1,
+                formatter: { "\(Int($0))" }
+            )
+            DayNightToggle(alarm: $alarm)
+            SoundPicker(alarm: $alarm)
+            SnoozeDatePicker(alarm: $alarm)
+            SnoozeDurationStepper(alarm: $alarm)*/
+        }
+        .navigationTitle("High BG Alert")
+    }
+}

+ 35 - 0
LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift

@@ -0,0 +1,35 @@
+//
+//  LowBgAlarmEditor.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+
+struct LowBgAlarmEditor: View {
+    @Binding var alarm: Alarm
+
+    var body: some View {
+        Form {/*
+            AlarmNameField(alarm: $alarm)
+            EnabledToggle(alarm: $alarm)
+            ValueStepper(
+                title: "BG Below",
+                value: Binding(
+                    get: { Double(alarm.threshold ?? 0) },
+                    set: { alarm.threshold = Float($0) }
+                ),
+                range: 0...500, step: 1,
+                formatter: { "\(Int($0))" }
+            )
+            DayNightToggle(alarm: $alarm)
+            SoundPicker(alarm: $alarm)
+            SnoozeDatePicker(alarm: $alarm)
+            SnoozeDurationStepper(alarm: $alarm)*/
+        }
+        .navigationTitle("Low BG Alert")
+    }
+}

+ 78 - 0
LoopFollow/Alarm/AlarmListView.swift

@@ -0,0 +1,78 @@
+//
+//  AlarmListView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+
+/// Displays all configured alarms and allows adding a new one by selecting its type.
+struct AlarmListView: View {
+    @ObservedObject private var store = Storage.shared.alarms
+    @Environment(\.presentationMode) var presentationMode
+    @State private var showingTypePicker = false
+    @State private var editingAlarmID: UUID?
+
+    var body: some View {
+        NavigationView {
+            List {
+                ForEach(store.value) { alarm in
+                    NavigationLink(alarm.name) {
+                        AlarmEditor(alarm: binding(for: alarm))
+                    }
+                }
+                .onDelete { idxs in
+                    store.value.remove(atOffsets: idxs)
+                }
+            }
+            .navigationTitle("Alarms")
+            .toolbar {
+                ToolbarItem(placement: .navigationBarLeading) {
+                    Button {
+                        showingTypePicker = true
+                    } label: {
+                        Image(systemName: "plus")
+                    }
+                }
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+            // Step 1: pick a type for the new alarm
+            .actionSheet(isPresented: $showingTypePicker) {
+                ActionSheet(
+                    title: Text("Select Alarm Type"),
+                    buttons: AlarmType.allCases.map { type in
+                        .default(Text(type.rawValue)) {
+                            let newAlarm = Alarm(type: type)
+                            store.value.append(newAlarm)
+                            editingAlarmID = newAlarm.id
+                        }
+                    } + [.cancel()]
+                )
+            }
+            // Step 2: when an ID is set, present the editor
+            .sheet(item: $editingAlarmID) { id in
+                if let idx = store.value.firstIndex(where: { $0.id == id }) {
+                    AlarmEditor(alarm: $store.value[idx])
+                } else {
+                    Text("Alarm not found")
+                        .padding()
+                }
+            }
+        }
+    }
+
+    /// Find and return a binding to the given alarm in the store
+    private func binding(for alarm: Alarm) -> Binding<Alarm> {
+        guard let idx = store.value.firstIndex(where: { $0.id == alarm.id }) else {
+            fatalError("Alarm not found in store")
+        }
+        return $store.value[idx]
+    }
+}

+ 57 - 0
LoopFollow/Alarm/AlarmManager.swift

@@ -0,0 +1,57 @@
+//
+//  AlarmManager.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+class AlarmManager {
+    static let shared = AlarmManager()
+
+    private let evaluators: [AlarmType: AlarmCondition]
+    private let config: AlarmConfiguration
+
+    private init(
+        config: AlarmConfiguration = .default,
+        conditionTypes: [AlarmCondition.Type] = [
+            BuildExpireCondition.self
+            // …add your other condition types here
+        ]
+    ) {
+        self.config = config
+        var dict = [AlarmType: AlarmCondition]()
+        conditionTypes.forEach { dict[$0.type] = $0.init() }
+        evaluators = dict
+    }
+
+    func checkAlarms(data: AlarmData) {
+        let context = AlarmContext(now: Date(), config: config)
+        let alarms = Storage.shared.alarms.value
+
+        let sorted = alarms.sorted { lhs, rhs in
+            // Primary: type priority
+            if lhs.type.priority != rhs.type.priority {
+                return lhs.type.priority < rhs.type.priority
+            }
+            // Secondary: threshold ordering if applicable
+            if let asc = lhs.type.thresholdSortAscending {
+                let leftVal = lhs.threshold ?? (asc ? Float.infinity : -Float.infinity)
+                let rightVal = rhs.threshold ?? (asc ? Float.infinity : -Float.infinity)
+                return asc ? leftVal < rightVal : leftVal > rightVal
+            }
+            // Tertiary: fallback to insertion order
+            return false
+        }
+
+        for alarm in sorted {
+            guard let checker = evaluators[alarm.type],
+                  checker.shouldFire(alarm: alarm, data: data, context: context)
+            else { continue }
+            alarm.trigger()
+            break
+        }
+    }
+}

+ 188 - 0
LoopFollow/Alarm/AlarmSettingsView.swift

@@ -0,0 +1,188 @@
+//
+//  AlarmSettingsView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025‑04‑20.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+
+struct AlarmSettingsView: View {
+    @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration
+    @Environment(\.presentationMode) var presentationMode
+
+    /// Helper to bind an optional Date? into a non‑optional Date for DatePicker
+    private func optDateBinding(_ b: Binding<Date?>) -> Binding<Date> {
+        Binding(
+            get: { b.wrappedValue ?? Date() },
+            set: { b.wrappedValue = $0 }
+        )
+    }
+
+    private var dayBinding: Binding<Date> {
+        Binding(
+            get: {
+                var c = Calendar.current.dateComponents([.year, .month, .day], from: Date())
+                c.hour   = cfgStore.value.dayStart.hour
+                c.minute = cfgStore.value.dayStart.minute
+                return Calendar.current.date(from: c)!
+            },
+            set: { d in
+                let hc = Calendar.current.dateComponents([.hour, .minute], from: d)
+                cfgStore.value.dayStart = TimeOfDay(hour: hc.hour!, minute: hc.minute!)
+            }
+        )
+    }
+
+    private var nightBinding: Binding<Date> {
+        Binding(
+            get: {
+                var c = Calendar.current.dateComponents([.year, .month, .day], from: Date())
+                c.hour   = cfgStore.value.nightStart.hour
+                c.minute = cfgStore.value.nightStart.minute
+                return Calendar.current.date(from: c)!
+            },
+            set: { d in
+                let hc = Calendar.current.dateComponents([.hour, .minute], from: d)
+                cfgStore.value.nightStart = TimeOfDay(hour: hc.hour!, minute: hc.minute!)
+            }
+        )
+    }
+
+    var body: some View {
+        NavigationView {
+            Form {
+                Section(
+                    header: Text("Snooze & Mute Options"),
+                    footer: Text("""
+                        Snooze All turns everything off, \
+                        Mute All turns off phone sounds but leaves vibration \
+                        and iOS notifications on
+                        """)
+                ) {
+                    // Snooze All Until
+                    DatePicker(
+                        "Snooze All Until",
+                        selection: optDateBinding(
+                            Binding(
+                                get: { cfgStore.value.snoozeUntil },
+                                set: { cfgStore.value.snoozeUntil = $0 }
+                            )
+                        ),
+                        displayedComponents: [.date, .hourAndMinute]
+                    )
+
+                    Toggle(
+                        "All Alerts Snoozed",
+                        isOn: Binding(
+                            get: {
+                                if let until = cfgStore.value.snoozeUntil {
+                                    return until > Date()
+                                }
+                                return false
+                            },
+                            set: { newOn in
+                                if newOn {
+                                    // if turning on, set a default 1h snooze if none or expired
+                                    if cfgStore.value.snoozeUntil == nil || cfgStore.value.snoozeUntil! <= Date() {
+                                        cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600)
+                                    }
+                                } else {
+                                    cfgStore.value.snoozeUntil = nil
+                                }
+                            }
+                        )
+                    )
+
+                    // Mute All Until
+                    DatePicker(
+                        "Mute All Until",
+                        selection: optDateBinding(
+                            Binding(
+                                get: { cfgStore.value.muteUntil },
+                                set: { cfgStore.value.muteUntil = $0 }
+                            )
+                        ),
+                        displayedComponents: [.date, .hourAndMinute]
+                    )
+
+                    Toggle(
+                        "All Sounds Muted",
+                        isOn: Binding(
+                            get: {
+                                if let until = cfgStore.value.muteUntil {
+                                    return until > Date()
+                                }
+                                return false
+                            },
+                            set: { newOn in
+                                if newOn {
+                                    if cfgStore.value.muteUntil == nil || cfgStore.value.muteUntil! <= Date() {
+                                        cfgStore.value.muteUntil = Date().addingTimeInterval(3600)
+                                    }
+                                } else {
+                                    cfgStore.value.muteUntil = nil
+                                }
+                            }
+                        )
+                    )
+                }
+
+                Section(header: Text("Alarm Settings")) {
+                    Toggle(
+                        "Override System Volume",
+                        isOn: Binding(
+                            get: { cfgStore.value.overrideSystemOutputVolume },
+                            set: { cfgStore.value.overrideSystemOutputVolume = $0 }
+                        )
+                    )
+
+                    if cfgStore.value.overrideSystemOutputVolume {
+                        Stepper(
+                            "Volume Level: \(Int(cfgStore.value.forcedOutputVolume * 100))%",
+                            value: Binding(
+                                get: { Double(cfgStore.value.forcedOutputVolume) },
+                                set: { cfgStore.value.forcedOutputVolume = Float($0) }
+                            ),
+                            in: 0...1,
+                            step: 0.05
+                        )
+                    }
+
+                    Toggle(
+                        "Audio During Calls",
+                        isOn: Binding(
+                            get: { cfgStore.value.audioDuringCalls },
+                            set: { cfgStore.value.audioDuringCalls = $0 }
+                        )
+                    )
+
+                    Toggle(
+                        "Ignore Zero BG",
+                        isOn: Binding(
+                            get: { cfgStore.value.ignoreZeroBG },
+                            set: { cfgStore.value.ignoreZeroBG = $0 }
+                        )
+                    )
+
+                    Toggle(
+                        "Auto‑Snooze CGM Start",
+                        isOn: Binding(
+                            get: { cfgStore.value.autoSnoozeCGMStart },
+                            set: { cfgStore.value.autoSnoozeCGMStart = $0 }
+                        )
+                    )
+                }
+            }
+            .navigationTitle("Alarm Settings")
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+        }
+    }
+}

+ 96 - 0
LoopFollow/Alarm/AlarmType.swift

@@ -0,0 +1,96 @@
+//
+//  AlarmType.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+/// Categorizes alarms into distinct types, prioritized in the order they appear here.
+/// Multiple user-defined alarms may share the same type but differ in configuration.
+enum AlarmType: String, CaseIterable, Codable {
+    case iob = "IOB Alert"
+    case bolus = "Bolus Alert"
+    case cob = "COB Alert"
+    case low = "Low Alert"
+    case high = "High Alert"
+    case fastDrop = "Fast Drop Alert"
+    case fastRise = "Fast Rise Alert"
+    case missedReading = "Missed Reading Alert"
+    case notLooping = "Not Looping Alert"
+    case missedBolus = "Missed Bolus Alert"
+    case sensorChange = "Sensor Change Alert"
+    case pumpChange = "Pump Change Alert"
+    case pump = "Low Insulin Alert"
+    case battery = "Low Battery"
+    case batteryDrop = "Battery Drop"
+    case recBolus = "Rec. Bolus"
+    case overrideStart = "Override Started"
+    case overrideEnd = "Override Ended"
+    case tempTargetStart = "Temp Target Started"
+    case tempTargetEnd = "Temp Target Ended"
+    case buildExpire = "Looping app expiration"
+}
+
+extension AlarmType {
+    var priority: Int {
+        return AlarmType.allCases.firstIndex(of: self) ?? 0
+    }
+}
+
+extension AlarmType {
+    /// Should alarms of this type sort their thresholds ascending (true) or descending (false)
+    var thresholdSortAscending: Bool? {
+        switch self {
+        case .low, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, .buildExpire:
+            return true
+        case .high, .iob, .cob:
+            return false
+        default:
+            return nil
+        }
+    }
+}
+
+extension AlarmType {
+  /// What “unit” we use for snoozeDuration for this alarmType.
+  var timeUnit: TimeUnit {
+    switch self {
+    case .buildExpire:
+      return .day
+    case .low, .high, .fastDrop, .fastRise,
+         .missedReading, .notLooping, .missedBolus,
+         .iob, .bolus, .cob, .recBolus,
+         .overrideStart, .overrideEnd, .tempTargetStart,
+         .tempTargetEnd:
+      return .minute
+    case .battery, .batteryDrop, .sensorChange, .pumpChange,
+        .pump:
+      return .hour
+    }
+  }
+}
+
+enum TimeUnit {
+  case minute, hour, day
+
+  /// How many seconds in one “unit”
+  var seconds: TimeInterval {
+    switch self {
+    case .minute: return 60
+    case .hour:   return 60 * 60
+    case .day:    return 60 * 60 * 24
+    }
+  }
+
+  /// A user-facing label
+  var label: String {
+    switch self {
+    case .minute: return "minutes"
+    case .hour:   return "hours"
+    case .day:    return "days"
+    }
+  }
+}

+ 14 - 0
LoopFollow/Alarm/SnoozeState.swift

@@ -0,0 +1,14 @@
+//
+//  SnoozeState.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-03-15.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+struct SnoozeState: Codable {
+    var isSnoozed: Bool = false
+    var snoozeUntil: Date?
+}

+ 1 - 1
LoopFollow/Controllers/Nightscout/CAge.swift

@@ -37,7 +37,7 @@ extension MainViewController {
         
         currentCage = data[0]
         let lastCageString = data[0].created_at
-        
+
         let formatter = ISO8601DateFormatter()
         formatter.formatOptions = [.withFullDate,
                                    .withTime,

+ 2 - 0
LoopFollow/Controllers/Nightscout/NSProfile.swift

@@ -46,6 +46,7 @@ struct NSProfile: Decodable {
     let isAPNSProduction: Bool?
     let deviceToken: String?
     let teamID: String?
+    let expirationDate: String?
 
     struct TrioOverrideEntry: Decodable {
         let name: String
@@ -94,5 +95,6 @@ struct NSProfile: Decodable {
         case trioOverrides = "overridePresets"
         case loopSettings = "loopSettings"
         case teamID
+        case expirationDate
     }
 }

+ 6 - 0
LoopFollow/Controllers/Nightscout/ProfileManager.swift

@@ -105,6 +105,12 @@ final class ProfileManager {
         }
 
         Storage.shared.deviceToken.value = profileData.deviceToken ?? ""
+        if let expirationDate = profileData.expirationDate {
+            Storage.shared.expirationDate.value = NightscoutUtils.parseDate(expirationDate)
+            Storage.shared.expirationDate.value = NightscoutUtils.parseDate("2025-04-25T17:54:56.000Z")
+        } else {
+            Storage.shared.expirationDate.value = nil
+        }
         Storage.shared.bundleId.value = profileData.bundleIdentifier ?? ""
         Storage.shared.productionEnvironment.value = profileData.isAPNSProduction ?? false
         Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? ""

+ 20 - 0
LoopFollow/Extensions/Binding+Optional.swift

@@ -0,0 +1,20 @@
+//
+//  Binding+Optional.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+extension Binding where Value: Equatable {
+  /// Create a Binding<Value> out of a Binding<Value?> by substituting `defaultValue` when nil.
+  init(_ source: Binding<Value?>, replacingNilWith defaultValue: Value) {
+    self.init(
+      get:   { source.wrappedValue ?? defaultValue },
+      set:   { source.wrappedValue = $0 }
+    )
+  }
+}

+ 13 - 0
LoopFollow/Extensions/UUID+Identifiable.swift

@@ -0,0 +1,13 @@
+//
+//  UUID+Identifiable.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-21.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+extension UUID: @retroactive Identifiable {
+    public var id: UUID { self }
+}

+ 20 - 0
LoopFollow/Helpers/NightscoutUtils.swift

@@ -90,7 +90,27 @@ class NightscoutUtils {
                 DispatchQueue.main.async {
                     completion(.success(decodedObject))
                 }
+            }
+            catch let decodingError as DecodingError {
+                print("[ERROR] Failed to decode \(T.self):")
+                switch decodingError {
+                case .typeMismatch(let type, let context):
+                    print("Type mismatch for type \(type), context: \(context.debugDescription)")
+                    print("Coding path:", context.codingPath)
+                case .valueNotFound(let type, let context):
+                    print("Value not found for type \(type), context: \(context.debugDescription)")
+                    print("Coding path:", context.codingPath)
+                case .keyNotFound(let key, let context):
+                    print("Key '\(key.stringValue)' not found, context: \(context.debugDescription)")
+                    print("Coding path:", context.codingPath)
+                case .dataCorrupted(let context):
+                    print("Data corrupted, context: \(context.debugDescription)")
+                @unknown default:
+                    print("Unknown decoding error")
+                }
+                completion(.failure(decodingError))
             } catch {
+                print("[ERROR] General error:", error)
                 completion(.failure(error))
             }
         }

+ 26 - 0
LoopFollow/Helpers/TimeOfDay.swift

@@ -0,0 +1,26 @@
+//
+//  TimeOfDay.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-20.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import Foundation
+
+/// A time‐of‐day independent of any date
+struct TimeOfDay: Codable, Equatable {
+    let hour: Int    // 0…23
+    let minute: Int  // 0…59
+
+    /// total minutes since midnight
+    var minutesSinceMidnight: Int { hour * 60 + minute }
+
+    init(hour: Int, minute: Int) {
+        precondition((0...23).contains(hour))
+        precondition((0...59).contains(minute))
+        self.hour = hour
+        self.minute = minute
+    }
+}

+ 84 - 0
LoopFollow/Snoozer/SnoozerView.swift

@@ -0,0 +1,84 @@
+//
+//  SnoozerView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-26.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+import Combine
+
+struct SnoozerView: View {
+    @ObservedObject var bg = Observable.shared.bgValue
+    @ObservedObject var trend = Observable.shared.trendArrow
+    @ObservedObject var delta = Observable.shared.delta
+    @ObservedObject var minutesAgo = Observable.shared.minutesAgo
+    @ObservedObject var alarmTitle = Observable.shared.alarmTitle
+
+    @State private var snoozeMinutes = 10
+    @State private var currentTime = Date()
+
+    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+
+    var bgColor: Color {
+        switch bg.value {
+        case ..<4.0:
+            return .red
+        case 4.0..<10:
+            return .yellow
+        default:
+            return .blue
+        }
+    }
+
+    var body: some View {
+        VStack(spacing: 20) {
+            Text(String(format: "%.1f", bg.value).replacingOccurrences(of: ".", with: ","))
+                .font(.system(size: 100, weight: .bold))
+                .foregroundColor(bgColor)
+
+            Text(trend.value)
+                .font(.system(size: 40))
+
+            Text(String(format: "%+.1f", delta.value))
+                .font(.title2)
+
+            Text("\(minutesAgo.value) min ago")
+                .font(.subheadline)
+
+            Text(currentTimeFormatted)
+                .font(.largeTitle)
+                .onReceive(timer) { _ in currentTime = Date() }
+
+            if let alarm = alarmTitle.value {
+                Text(alarm)
+                    .font(.title2)
+                    .foregroundColor(.red)
+                    .padding(.top)
+
+                Stepper("Snooze for \(snoozeMinutes) min", value: $snoozeMinutes, in: 5...60, step: 5)
+                    .padding(.horizontal)
+
+                Button("Snooze") {
+                    // Call snooze logic
+                    print("Snoozing \(alarm) for \(snoozeMinutes) minutes")
+                }
+                .padding()
+                .background(Color.gray.opacity(0.3))
+                .cornerRadius(10)
+            }
+        }
+        .padding()
+        .background(Color.black)
+        .foregroundColor(.white)
+        .ignoresSafeArea()
+    }
+
+    private var currentTimeFormatted: String {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm"
+        return formatter.string(from: currentTime)
+    }
+}

+ 7 - 0
LoopFollow/Storage/Observable.swift

@@ -16,5 +16,12 @@ class Observable {
     var override = ObservableValue<String?>(default: nil)
     var lastRecBolusTriggered = ObservableValue<Double?>(default: nil)
 
+    // Work in progress here.. 
+    var bgValue = ObservableValue<Double>(default: 0.0)
+    var trendArrow = ObservableValue<String>(default: "→")
+    var delta = ObservableValue<Double>(default: 0.0)
+    var minutesAgo = ObservableValue<Int>(default: 0)
+    var alarmTitle = ObservableValue<String?>(default: nil)
+
     private init() {}
 }

+ 7 - 0
LoopFollow/Storage/Storage.swift

@@ -12,6 +12,7 @@ import HealthKit
 class Storage {
     var remoteType = StorageValue<RemoteType>(key: "remoteType", defaultValue: .nightscout)
     var deviceToken = StorageValue<String>(key: "deviceToken", defaultValue: "")
+    var expirationDate = StorageValue<Date?>(key: "expirationDate", defaultValue: nil)
     var sharedSecret = StorageValue<String>(key: "sharedSecret", defaultValue: "")
     var productionEnvironment = StorageValue<Bool>(key: "productionEnvironment", defaultValue: true)
     var apnsKey = StorageValue<String>(key: "apnsKey", defaultValue: "")
@@ -45,6 +46,12 @@ class Storage {
     
     var sensorScheduleOffset = StorageValue<Double?>(key: "sensorScheduleOffset", defaultValue: nil)
 
+    var alarms = StorageValue<[Alarm]>(key: "alarms", defaultValue: [])
+    var alarmConfiguration = StorageValue<AlarmConfiguration>(
+        key: "alarmConfiguration",
+        defaultValue: .default
+    )
+
     static let shared = Storage()
 
     private init() { }

+ 9 - 1
LoopFollow/Task/AlarmTask.swift

@@ -19,6 +19,14 @@ extension MainViewController {
 
     func alarmTaskAction() {
         DispatchQueue.main.async {
+            let alarmData = AlarmData(
+                expireDate: Storage.shared.expirationDate.value
+            )
+
+            LogManager.shared.log(category: .alarm, message: "Checking alarms based on \(alarmData)", isDebug: true)
+
+            AlarmManager.shared.checkAlarms(data: alarmData)
+            /*
             if self.bgData.count > 0 {
                 self.checkAlarms(bgs: self.bgData)
             }
@@ -27,7 +35,7 @@ extension MainViewController {
             }
             if self.tempTargetGraphData.count > 0 {
                 self.checkTempTargetAlarms()
-            }
+            }*/
 
             TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(30))
         }

+ 49 - 1
LoopFollow/ViewControllers/SettingsViewController.swift

@@ -127,7 +127,7 @@ class SettingsViewController: FormViewController, NightscoutSettingsViewModelDel
                 }
                                              ), onDismiss: nil)
         }
-        <<< ButtonRow("alarmsSettings") {
+        <<< ButtonRow("alarmsSettingstobedeleted") {
             $0.title = "Alarms"
             $0.presentationMode = .show(
                 controllerProvider: .callback(builder: {
@@ -137,6 +137,30 @@ class SettingsViewController: FormViewController, NightscoutSettingsViewModelDel
                     return alarmVC
                 }), onDismiss: nil)
         }
+
+
+        <<< ButtonRow("alarmsList") {
+            $0.title = "Alarms"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentAlarmList()
+                    return UIViewController()
+                }),
+                onDismiss: nil
+            )
+        }
+
+        <<< ButtonRow("alarmsSettings") {
+            $0.title = "Alarm Settings"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentAlarmSettings()
+                    return UIViewController()
+                }),
+                onDismiss: nil
+            )
+        }
+
         <<< ButtonRow("remoteSettings") {
             $0.title = "Remote Settings"
             $0.presentationMode = .show(
@@ -288,6 +312,30 @@ class SettingsViewController: FormViewController, NightscoutSettingsViewModelDel
         present(hostingController, animated: true, completion: nil)
     }
 
+    func presentAlarmSettings() {
+        let settingsView = AlarmSettingsView()
+        let hostingController = UIHostingController(rootView: settingsView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
+    func presentAlarmList() {
+        let settingsView = AlarmListView()
+        let hostingController = UIHostingController(rootView: settingsView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
+
     func presentContactSettings() {
         let viewModel = ContactSettingsViewModel()
         let contactSettingsView = ContactSettingsView(viewModel: viewModel)

+ 77 - 0
LoopFollowTests/AlwaysTrueCondition.swift

@@ -0,0 +1,77 @@
+//
+//  AlwaysTrueCondition.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-20.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import XCTest
+@testable import LoopFollow
+
+struct AlwaysTrueCondition: AlarmCondition {
+    static let type: AlarmType = .low
+    init() {}
+    func evaluate(alarm: Alarm, data: AlarmData) -> Bool { true }
+}
+
+final class CommonAlarmGuardsTests: XCTestCase {
+    var now: Date!
+    var config: AlarmConfiguration!
+    var context: AlarmContext!
+    var alarm: Alarm!
+    var data: AlarmData!
+    var cond: AlwaysTrueCondition!
+
+    override func setUp() {
+        super.setUp()
+        now = Date()
+        config = .default
+        context = AlarmContext(now: now, config: config)
+
+        alarm = Alarm(type: .low)
+        alarm.name = "test"
+        alarm.isEnabled = true
+        alarm.snoozedUntil = nil
+        alarm.playSoundOption = .always
+        alarm.activeOption = .always
+        alarm.snoozeDuration = 0
+
+        data = AlarmData(expireDate: nil)
+        cond = AlwaysTrueCondition()
+    }
+
+    func testMuteUntil() {
+        config.muteUntil = now.addingTimeInterval(60)
+        context = AlarmContext(now: now, config: config)
+        XCTAssertFalse(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+
+    func testDisabledAlarm() {
+        alarm.isEnabled = false
+        XCTAssertFalse(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+
+    func testSnoozedAlarm() {
+        alarm.snoozedUntil = now.addingTimeInterval(60)
+        XCTAssertFalse(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+
+    func testRespectsNightFlag() {
+        let night = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: now)!
+        alarm.activeOption = .day // alarm should be inactive at night
+        context = AlarmContext(now: night, config: config)
+        XCTAssertFalse(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+
+    func testRespectsDayFlag() {
+        let day = Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: now)!
+        alarm.activeOption = .night // alarm should be inactive during day
+        context = AlarmContext(now: day, config: config)
+        XCTAssertFalse(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+
+    func testAllGuardsPass() {
+        XCTAssertTrue(cond.shouldFire(alarm: alarm, data: data, context: context))
+    }
+}

+ 51 - 0
LoopFollowTests/BuildExpireConditionTests.swift

@@ -0,0 +1,51 @@
+//
+//  BuildExpireConditionTests.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-04-20.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import XCTest
+@testable import LoopFollow
+
+final class BuildExpireConditionTests: XCTestCase {
+    let cond = BuildExpireCondition()
+    var alarm: Alarm!
+
+    override func setUp() {
+        super.setUp()
+        alarm = Alarm(type: .buildExpire)
+        alarm.threshold = 7 // 7 days before expiration
+    }
+
+    func testEvaluateWhenWithinThreshold() {
+        let now = Date()
+        // Build expires in 6 days → within threshold (7 days)
+        let expiryDate = Calendar.current.date(byAdding: .day, value: 6, to: now)!
+        let data = AlarmData(expireDate: expiryDate)
+        XCTAssertTrue(cond.evaluate(alarm: alarm, data: data))
+    }
+
+    func testEvaluateWhenOutsideThreshold() {
+        let now = Date()
+        // Build expires in 10 days → outside threshold (7 days)
+        let expiryDate = Calendar.current.date(byAdding: .day, value: 10, to: now)!
+        let data = AlarmData(expireDate: expiryDate)
+        XCTAssertFalse(cond.evaluate(alarm: alarm, data: data))
+    }
+
+    func testEvaluateWhenNoExpireDate() {
+        let data = AlarmData(expireDate: nil)
+        XCTAssertFalse(cond.evaluate(alarm: alarm, data: data))
+    }
+
+    func testEvaluateWhenNoThreshold() {
+        // Clear threshold
+        alarm.threshold = nil
+        let now = Date()
+        let expiryDate = Calendar.current.date(byAdding: .day, value: 5, to: now)!
+        let data = AlarmData(expireDate: expiryDate)
+        XCTAssertFalse(cond.evaluate(alarm: alarm, data: data))
+    }
+}