diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 5bd76cc2a..e767b05c5 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -35,9 +36,9 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -200,14 +201,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; }; DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; }; DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -251,6 +252,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -413,6 +415,8 @@ /* 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 = ""; }; + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -440,10 +444,10 @@ 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -605,14 +609,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -897,6 +901,7 @@ isa = PBXGroup; children = ( DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */, + 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */, DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */, DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */, DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */, @@ -1143,6 +1148,7 @@ isa = PBXGroup; children = ( DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */, + B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */, DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */, DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */, DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */, @@ -1194,6 +1200,7 @@ isa = PBXGroup; children = ( DDCC3A592DDC988F006F1C10 /* CarbSample.swift */, + 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */, DDB9FC7E2DDB584500EFAA76 /* BolusEntry.swift */, DD5DA27B2DC930D6003D44FC /* GlucoseValue.swift */, ); @@ -1993,6 +2000,9 @@ DDB9FC7B2DDB573F00EFAA76 /* IOBCondition.swift in Sources */, DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */, DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */, + F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */, + 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */, + ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */, DD0650F12DCE9A9E004D3B41 /* MissedReadingCondition.swift in Sources */, DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, @@ -2239,8 +2249,14 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2266,8 +2282,14 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/Charts", + "$(BUILT_PRODUCTS_DIR)/ShareClient", + "$(BUILT_PRODUCTS_DIR)/SwiftAlgorithms", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -2407,8 +2429,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2431,8 +2453,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 6b6b37f6d..8fff17f4d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -309,6 +309,12 @@ struct Alarm: Identifiable, Codable, Equatable { predictiveMinutes = 15 delta = 0.1 threshold = 4 + case .futureCarbs: + soundFile = .alertToneRingtone1 + threshold = 45 // max lookahead minutes + delta = 5 // min grams + snoozeDuration = 0 + repeatSoundOption = .never case .sensorChange: soundFile = .wakeUpWillYou threshold = 12 @@ -364,7 +370,7 @@ extension AlarmType { switch self { case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary: return .glucose - case .iob, .cob, .missedBolus, .recBolus: + case .iob, .cob, .missedBolus, .futureCarbs, .recBolus: return .insulin case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange, .sensorChange, .notLooping, .buildExpire: @@ -384,6 +390,7 @@ extension AlarmType { case .iob: return "syringe" case .cob: return "fork.knife" case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath" + case .futureCarbs: return "clock.arrow.circlepath" case .recBolus: return "bolt.horizontal" case .battery: return "battery.25" case .batteryDrop: return "battery.100.bolt" @@ -411,6 +418,7 @@ extension AlarmType { case .iob: return "High insulin-on-board." case .cob: return "High carbs-on-board." case .missedBolus: return "Carbs without bolus." + case .futureCarbs: return "Reminder when future carbs are due." case .recBolus: return "Recommended bolus issued." case .battery: return "Phone battery low." case .batteryDrop: return "Battery drops quickly." diff --git a/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift new file mode 100644 index 000000000..8fa0a7a27 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/FutureCarbsCondition.swift @@ -0,0 +1,97 @@ +// LoopFollow +// FutureCarbsCondition.swift + +import Foundation + +/// Fires once when a future-dated carb entry's scheduled time arrives. +/// +/// **How it works:** +/// 1. Each alarm tick scans `recentCarbs` for entries whose `date` is in the future +/// (within a configurable max lookahead window). New ones are added to a persistent +/// "pending" list. +/// 2. When a pending entry's `carbDate` passes (i.e. `carbDate <= now`), verify the +/// carb still exists in `recentCarbs`. If so, fire the alarm. If the carb was +/// deleted, silently remove it. +/// 3. Stale entries (observed > 2 hours ago) are cleaned up automatically. +struct FutureCarbsCondition: AlarmCondition { + static let type: AlarmType = .futureCarbs + init() {} + + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // ──────────────────────────────── + // 0. Pull settings + // ──────────────────────────────── + let maxLookaheadMin = alarm.threshold ?? 45 // max lookahead in minutes + let minGrams = alarm.delta ?? 5 // ignore carbs below this + + let nowTI = now.timeIntervalSince1970 + let maxLookaheadSec = maxLookaheadMin * 60 + + var pending = Storage.shared.pendingFutureCarbs.value + let tolerance: TimeInterval = 5 // seconds, for matching carb entries + + // ──────────────────────────────── + // 1. Scan for new future carbs + // ──────────────────────────────── + for carb in data.recentCarbs { + let carbTI = carb.date.timeIntervalSince1970 + + // Must be in the future and within the lookahead window + guard carbTI > nowTI, + carbTI - nowTI <= maxLookaheadSec, + carb.grams >= minGrams + else { continue } + + // Already tracked? + let alreadyTracked = pending.contains { entry in + abs(entry.carbDate - carbTI) < tolerance && entry.grams == carb.grams + } + if !alreadyTracked { + pending.append(PendingFutureCarb( + carbDate: carbTI, + grams: carb.grams, + observedAt: nowTI + )) + } + } + + // ──────────────────────────────── + // 2. Check if any pending entry is due + // ──────────────────────────────── + var fired = false + + pending.removeAll { entry in + // Cleanup stale entries (observed > 2 hours ago) + if nowTI - entry.observedAt > 7200 { + return true + } + + // Not yet due + guard entry.carbDate <= nowTI else { return false } + + // Due — verify carb still exists in recentCarbs + let stillExists = data.recentCarbs.contains { carb in + abs(carb.date.timeIntervalSince1970 - entry.carbDate) < tolerance + && carb.grams == entry.grams + } + + if stillExists, !fired { + fired = true + return true // remove from pending after firing + } + + // Carb was deleted or we already fired this tick — remove silently + if !stillExists { + return true + } + + return false + } + + // ──────────────────────────────── + // 3. Persist and return + // ──────────────────────────────── + Storage.shared.pendingFutureCarbs.value = pending + return fired + } +} diff --git a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift index 6ca26d576..e8ff4aff5 100644 --- a/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift @@ -82,6 +82,7 @@ struct AlarmEditor: View { case .battery: PhoneBatteryAlarmEditor(alarm: $alarm) case .batteryDrop: BatteryDropAlarmEditor(alarm: $alarm) case .missedBolus: MissedBolusAlarmEditor(alarm: $alarm) + case .futureCarbs: FutureCarbsAlarmEditor(alarm: $alarm) } } } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift new file mode 100644 index 000000000..0df1e2177 --- /dev/null +++ b/LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift @@ -0,0 +1,46 @@ +// LoopFollow +// FutureCarbsAlarmEditor.swift + +import SwiftUI + +struct FutureCarbsAlarmEditor: View { + @Binding var alarm: Alarm + + var body: some View { + Group { + InfoBanner( + text: "Alerts when a future-dated carb entry's scheduled time arrives — " + + "a reminder to start eating. Use the max lookahead to ignore " + + "fat/protein entries that are typically scheduled further ahead.", + alarmType: alarm.type + ) + + AlarmGeneralSection(alarm: $alarm) + + AlarmStepperSection( + header: "Max Lookahead", + footer: "Only track carb entries scheduled up to this many minutes " + + "in the future. Entries beyond this window are ignored.", + title: "Lookahead", + range: 5 ... 120, + step: 5, + unitLabel: "min", + value: $alarm.threshold + ) + + AlarmStepperSection( + header: "Minimum Carbs", + footer: "Ignore carb entries below this amount.", + title: "At or Above", + range: 0 ... 50, + step: 1, + unitLabel: "g", + value: $alarm.delta + ) + + AlarmActiveSection(alarm: $alarm) + AlarmAudioSection(alarm: $alarm) + AlarmSnoozeSection(alarm: $alarm) + } + } +} diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 436535ea6..3f5aa84ec 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -33,6 +33,7 @@ class AlarmManager { IOBCondition.self, BatteryCondition.self, BatteryDropCondition.self, + FutureCarbsCondition.self, ] ) { var dict = [AlarmType: AlarmCondition]() diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift index e242226cd..134e1fb5b 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+Snooze.swift @@ -11,7 +11,7 @@ extension AlarmType { return .day case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, - .recBolus, + .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return .minute diff --git a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift index 151dd3914..9f5f3b5d1 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType+canAcknowledge.swift @@ -8,7 +8,7 @@ extension AlarmType { var canAcknowledge: Bool { switch self { // These are alarms that typically has a "memory", they will only alarm once and acknowledge them is fine - case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: + case .low, .high, .fastDrop, .fastRise, .temporary, .cob, .missedBolus, .futureCarbs, .recBolus, .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd: return true // These are alarms without memory, if they only are acknowledged - they would alarm again immediately case diff --git a/LoopFollow/Alarm/AlarmType/AlarmType.swift b/LoopFollow/Alarm/AlarmType/AlarmType.swift index 7df7780a4..11a51885e 100644 --- a/LoopFollow/Alarm/AlarmType/AlarmType.swift +++ b/LoopFollow/Alarm/AlarmType/AlarmType.swift @@ -16,6 +16,7 @@ enum AlarmType: String, CaseIterable, Codable { case missedReading = "Missed Reading Alert" case notLooping = "Not Looping Alert" case missedBolus = "Missed Bolus Alert" + case futureCarbs = "Future Carbs Alert" case sensorChange = "Sensor Change Alert" case pumpChange = "Pump Change Alert" case pump = "Pump Insulin Alert" diff --git a/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift new file mode 100644 index 000000000..b28e9851a --- /dev/null +++ b/LoopFollow/Alarm/DataStructs/PendingFutureCarb.swift @@ -0,0 +1,17 @@ +// LoopFollow +// PendingFutureCarb.swift + +import Foundation + +/// Tracks a future-dated carb entry that has been observed but whose scheduled time +/// has not yet arrived. Used by `FutureCarbsCondition` to fire a reminder when it's time to eat. +struct PendingFutureCarb: Codable, Equatable { + /// Scheduled eating time (`timeIntervalSince1970`) + let carbDate: TimeInterval + + /// Grams of carbs (used together with `carbDate` to identify unique entries) + let grams: Double + + /// When the entry was first observed (`timeIntervalSince1970`, for staleness cleanup) + let observedAt: TimeInterval +} diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 8ff20df87..307a37e79 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -10,11 +10,11 @@ extension MainViewController { if !Storage.shared.downloadTreatments.value { return } let startTimeString = dateTimeUtils.getDateTimeString(addingDays: -1 * Storage.shared.downloadDays.value) - let currentTimeString = dateTimeUtils.getDateTimeString() + let endTimeString = dateTimeUtils.getDateTimeString(addingHours: 6) let estimatedCount = max(Storage.shared.downloadDays.value * 100, 5000) let parameters: [String: String] = [ "find[created_at][$gte]": startTimeString, - "find[created_at][$lte]": currentTimeString, + "find[created_at][$lte]": endTimeString, "count": "\(estimatedCount)", ] NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in diff --git a/LoopFollow/Helpers/DateTime.swift b/LoopFollow/Helpers/DateTime.swift index a4f31b914..8bbde95c2 100644 --- a/LoopFollow/Helpers/DateTime.swift +++ b/LoopFollow/Helpers/DateTime.swift @@ -69,16 +69,20 @@ class dateTimeUtils { return utcTime } - static func getDateTimeString(addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { + static func getDateTimeString(addingMinutes minutes: Int? = nil, addingHours hours: Int? = nil, addingDays days: Int? = nil) -> String { let currentDate = Date() var date = currentDate + if let minutesToAdd = minutes { + date = Calendar.current.date(byAdding: .minute, value: minutesToAdd, to: date)! + } + if let hoursToAdd = hours { - date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .hour, value: hoursToAdd, to: date)! } if let daysToAdd = days { - date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: currentDate)! + date = Calendar.current.date(byAdding: .day, value: daysToAdd, to: date)! } let dateFormatter = DateFormatter() diff --git a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift index f69d83181..3574eb2b2 100644 --- a/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift +++ b/LoopFollow/Settings/ImportExport/AlarmSelectionView.swift @@ -213,6 +213,8 @@ struct AlarmSelectionRow: View { return "Not Looping Alert" case .missedBolus: return "Missed Bolus Alert" + case .futureCarbs: + return "Future Carbs Alert" case .sensorChange: return "Sensor Change Alert" case .pumpChange: diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 97e99bc7c..97e7a3d8c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -59,6 +59,7 @@ class Storage { var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) + var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: []) // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) diff --git a/Tests/AlarmConditions/FutureCarbsConditionTests.swift b/Tests/AlarmConditions/FutureCarbsConditionTests.swift new file mode 100644 index 000000000..05cf581a4 --- /dev/null +++ b/Tests/AlarmConditions/FutureCarbsConditionTests.swift @@ -0,0 +1,215 @@ +// LoopFollow +// FutureCarbsConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct FutureCarbsConditionTests { + let cond = FutureCarbsCondition() + + private func resetPending() { + Storage.shared.pendingFutureCarbs.value = [] + } + + private func carb(minutesFromNow offset: Double, grams: Double = 20, relativeTo now: Date = .init()) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(offset * 60)) + } + + // MARK: - 1. Tracking — future carb within lookahead gets tracked + + @Test("#tracking — future carb within lookahead gets tracked") + func futureWithinLookaheadTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45, delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 2. Firing — pending carb whose time arrives fires + + @Test("#firing — pending carb whose time arrives fires") + func pendingCarbFires() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) // 1 min ago + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 3. Deleted carb — no fire, removed from pending + + @Test("#deleted carb — no fire, removed from pending") + func deletedCarbNoFire() { + resetPending() + let now = Date() + let pastDate = now.addingTimeInterval(-60) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) // carb was deleted + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 4. Beyond lookahead — carb ignored + + @Test("#beyond lookahead — carb ignored") + func beyondLookaheadIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(threshold: 45) + let data = AlarmData.withCarbs([carb(minutesFromNow: 60, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 5. Below min grams — carb ignored + + @Test("#below min grams — carb ignored") + func belowMinGramsIgnored() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs(delta: 5) + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 3, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 6. Past carb — not tracked + + @Test("#past carb — not tracked") + func pastCarbNotTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: -5, grams: 20, relativeTo: now)]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 7. Stale cleanup — entry observed > 2h ago is removed + + @Test("#stale cleanup — entry observed > 2h ago is removed") + func staleCleanup() { + resetPending() + let now = Date() + let futureDate = now.addingTimeInterval(300) // still in the future + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: futureDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(!result) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 8. Multiple carbs — only one fires per tick + + @Test("#multiple carbs — only one fires per tick") + func multipleOnlyOnePerTick() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + let result = cond.evaluate(alarm: alarm, data: data, now: now) + + #expect(result) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } + + // MARK: - 9. Second tick fires second carb + + @Test("#second tick fires second carb") + func secondTickFiresSecond() { + resetPending() + let now = Date() + let past1 = now.addingTimeInterval(-60) + let past2 = now.addingTimeInterval(-120) + + Storage.shared.pendingFutureCarbs.value = [ + PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970), + ] + + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([ + CarbSample(grams: 20, date: past1), + CarbSample(grams: 30, date: past2), + ]) + + // First tick + let result1 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result1) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + // Second tick + let result2 = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(result2) + #expect(Storage.shared.pendingFutureCarbs.value.isEmpty) + } + + // MARK: - 10. Duplicate carb not double-tracked + + @Test("#duplicate carb not double-tracked") + func duplicateNotDoubleTracked() { + resetPending() + let now = Date() + let alarm = Alarm.futureCarbs() + let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)]) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + + _ = cond.evaluate(alarm: alarm, data: data, now: now) + #expect(Storage.shared.pendingFutureCarbs.value.count == 1) + } +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 37220d12f..c615f4972 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -6,9 +6,6 @@ import Foundation @testable import LoopFollow import Testing -@testable import LoopFollow -import Testing - // MARK: - Alarm helpers extension Alarm { @@ -17,6 +14,13 @@ extension Alarm { alarm.threshold = threshold return alarm } + + static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self { + var alarm = Alarm(type: .futureCarbs) + alarm.threshold = threshold + alarm.delta = delta + return alarm + } } // MARK: - AlarmData helpers @@ -40,8 +44,33 @@ extension AlarmData { IOB: nil, recentBoluses: [], latestBattery: level, + latestPumpBattery: nil, batteryHistory: [], recentCarbs: [] ) } + + static func withCarbs(_ carbs: [CarbSample]) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: carbs + ) + } }