diff --git a/.gitmodules b/.gitmodules index 024f06be07..79da6ecc30 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,43 +1,55 @@ [submodule "LoopKit"] path = LoopKit url = https://github.com/loopandlearn/LoopKit.git + branch = trio-next-dev [submodule "CGMBLEKit"] path = CGMBLEKit url = https://github.com/loopandlearn/CGMBLEKit.git + branch = trio-next-dev [submodule "dexcom-share-client-swift"] path = dexcom-share-client-swift url = https://github.com/loopandlearn/dexcom-share-client-swift.git + branch = next-dev [submodule "RileyLinkKit"] path = RileyLinkKit url = https://github.com/loopandlearn/RileyLinkKit + branch = trio-next-dev [submodule "G7SensorKit"] path = G7SensorKit url = https://github.com/loopandlearn/G7SensorKit.git - branch = feat/trio-g7-direct + branch = trio-next-dev [submodule "MinimedKit"] path = MinimedKit url = https://github.com/loopandlearn/MinimedKit.git + branch = trio-next-dev [submodule "LibreTransmitter"] path = LibreTransmitter url = https://github.com/loopandlearn/LibreTransmitter.git + branch = trio-next-dev [submodule "TidepoolService"] path = TidepoolService url = https://github.com/loopandlearn/TidepoolService.git + branch = trio-next-dev [submodule "DanaKit"] path = DanaKit url = https://github.com/loopandlearn/DanaKit + branch = trio-next-dev [submodule "MedtrumKit"] path = MedtrumKit url = https://github.com/loopandlearn/MedtrumKit + branch = trio-next-dev [submodule "OmnipodKit"] path = OmnipodKit url = https://github.com/loopandlearn/OmnipodKit + branch = next-dev [submodule "AccuChekKit"] path = AccuChekKit url = https://github.com/loopandlearn/AccuChekKit + branch = trio-next-dev [submodule "EversenseKit"] path = EversenseKit url = https://github.com/loopandlearn/EversenseKit + branch = trio-next-dev [submodule "RoundWhiteDiscKit"] path = RoundWhiteDiscKit url = https://github.com/loopandlearn/RoundWhiteDiscKit.git @@ -45,10 +57,8 @@ [submodule "LibreLoop"] path = LibreLoop url = https://github.com/loopandlearn/LibreLoop.git - branch = legacy-loopkit-compat -[submodule "LoopAlgorithm"] - path = LoopAlgorithm - url = https://github.com/loopandlearn/LoopAlgorithm.git + branch = trio-next-dev [submodule "SyaiKit"] path = SyaiKit url = https://github.com/nightscout/SyaiKit + branch = trio-next-dev diff --git a/AccuChekKit b/AccuChekKit index 4f8c3a8cb5..3a784a8e2b 160000 --- a/AccuChekKit +++ b/AccuChekKit @@ -1 +1 @@ -Subproject commit 4f8c3a8cb50e68a979bdbed2e207aca00e4b35e0 +Subproject commit 3a784a8e2b79e6f12edcc0ff325869cf8f3948e0 diff --git a/CGMBLEKit b/CGMBLEKit index 776543fca9..5bde830de8 160000 --- a/CGMBLEKit +++ b/CGMBLEKit @@ -1 +1 @@ -Subproject commit 776543fca902ad74e3a068a3ec402d62d716a322 +Subproject commit 5bde830de82f62b71dc60e06765d5c1704396c56 diff --git a/Config.xcconfig b/Config.xcconfig index 28c84574c3..bba3fcad37 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 1.0.2 -APP_DEV_VERSION = 1.0.2.42 +APP_DEV_VERSION = 1.0.2.59 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 diff --git a/DanaKit b/DanaKit index eadb9fefc0..787a639245 160000 --- a/DanaKit +++ b/DanaKit @@ -1 +1 @@ -Subproject commit eadb9fefc0e48df6639e1a9de5147ecf6a110caf +Subproject commit 787a6392451fd1c29597afa83a68587b71908434 diff --git a/EversenseKit b/EversenseKit index 94dc675725..ef496f1688 160000 --- a/EversenseKit +++ b/EversenseKit @@ -1 +1 @@ -Subproject commit 94dc6757254733fb914b119c3b3c7eb45181bb42 +Subproject commit ef496f1688a7d8a17941a95ec9e336f81cd267d0 diff --git a/G7SensorKit b/G7SensorKit index f455451853..08d488633c 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit f45545185370bc9ebc57d06e8eea5773b1ef868c +Subproject commit 08d488633cc9e248a4b4095626c958ed3a75d0d7 diff --git a/LibreLoop b/LibreLoop index a76748161d..0c90cd9a05 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit a76748161d31089ba102a05204afbaca448f5f09 +Subproject commit 0c90cd9a05353ed60642100e0bf1f7c1ec05f76a diff --git a/LibreTransmitter b/LibreTransmitter index 41105123c3..fb8e4ad28d 160000 --- a/LibreTransmitter +++ b/LibreTransmitter @@ -1 +1 @@ -Subproject commit 41105123c3c4fb5c55ce89a38630cea2f8f7a17a +Subproject commit fb8e4ad28dc75b8a0d51db1d9b968d150b28747b diff --git a/LiveActivity/LiveActivity+Helper.swift b/LiveActivity/LiveActivity+Helper.swift index 59e1211b4f..2499bbde2c 100644 --- a/LiveActivity/LiveActivity+Helper.swift +++ b/LiveActivity/LiveActivity+Helper.swift @@ -38,10 +38,6 @@ extension Int { var asMmolL: Decimal { rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain) } - - var formattedAsMmolL: String { - NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" - } } extension Decimal { @@ -52,21 +48,6 @@ extension Decimal { var asMgdL: Decimal { rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain) } - - var formattedAsMmolL: String { - NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" - } -} - -extension NumberFormatter { - static let glucoseFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.locale = Locale.current - formatter.numberStyle = .decimal - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - return formatter - }() } extension LiveActivityAttributes.ContentState { diff --git a/LoopAlgorithm b/LoopAlgorithm deleted file mode 160000 index e3f5e3c0f8..0000000000 --- a/LoopAlgorithm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e3f5e3c0f8b05c4a59d63a6b145cd6d005cf1b45 diff --git a/LoopKit b/LoopKit index ff8c30e281..babddf0453 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit ff8c30e281d04edc2237a18834e5e783ae8c63d3 +Subproject commit babddf04536c04c6852ee7c76a1905164ee9893d diff --git a/MedtrumKit b/MedtrumKit index c33699595b..1e761ef6ac 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit c33699595b6f636d8ca1bff377a8765df5597c34 +Subproject commit 1e761ef6acee446262e6cedd5e9cff5979260d10 diff --git a/MinimedKit b/MinimedKit index a7b26858e3..008aab09c1 160000 --- a/MinimedKit +++ b/MinimedKit @@ -1 +1 @@ -Subproject commit a7b26858e3073ac8881e7303b675c6b0f8c51ee9 +Subproject commit 008aab09c108b0c29d999134c9a1103f5dab15d7 diff --git a/Model/Classes+Properties/PumpStatusStored+CoreDataClass.swift b/Model/Classes+Properties/PumpStatusStored+CoreDataClass.swift new file mode 100644 index 0000000000..f9f0bbdff0 --- /dev/null +++ b/Model/Classes+Properties/PumpStatusStored+CoreDataClass.swift @@ -0,0 +1,4 @@ +import CoreData +import Foundation + +@objc(PumpStatusStored) public class PumpStatusStored: NSManagedObject {} diff --git a/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift b/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift new file mode 100644 index 0000000000..22146ac994 --- /dev/null +++ b/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift @@ -0,0 +1,13 @@ +import CoreData +import Foundation + +public extension PumpStatusStored { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "PumpStatusStored") + } + + @NSManaged var basalDeliveryState: String? + @NSManaged var date: Date? +} + +extension PumpStatusStored: Identifiable {} diff --git a/Model/Helper/PumpStatusStored+helper.swift b/Model/Helper/PumpStatusStored+helper.swift new file mode 100644 index 0000000000..1519f7ccee --- /dev/null +++ b/Model/Helper/PumpStatusStored+helper.swift @@ -0,0 +1,32 @@ +import CoreData +import Foundation + +extension PumpStatusStored { + /// Basal delivery as the pump manager reports it. Temp basals and their transitions count as active. + enum DeliveryState: String { + case active + /// Delivery active since `.distantPast`, which Omnipod reports while no pod is active. + case inactive + case suspending + case suspended + case resuming + /// No basal delivery state, or no pump. + case unknown + } + + static func fetchLast() -> NSFetchRequest { + let request = PumpStatusStored.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpStatusStored.date, ascending: false)] + request.fetchLimit = 1 + return request + } + + var deliveryState: DeliveryState { + basalDeliveryState.flatMap(DeliveryState.init(rawValue:)) ?? .unknown + } + + /// Without a pump manager nothing is bolusing, and the timestamp is when the stored state began. + var pumpStatus: PumpStatus { + PumpStatus(deliveryState: deliveryState, bolusing: false, timestamp: date) + } +} diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index 0b4ab488d1..54090a4fbf 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -328,6 +328,13 @@ + + + + + + + diff --git a/OmnipodKit b/OmnipodKit index 0c6d68b00d..6fdf0e0e34 160000 --- a/OmnipodKit +++ b/OmnipodKit @@ -1 +1 @@ -Subproject commit 0c6d68b00d45f4fecd9b0fc0477fc00e2d7e4c02 +Subproject commit 6fdf0e0e342b734e1a844f388d4cedc68c34196c diff --git a/RileyLinkKit b/RileyLinkKit index 049f0d51b2..52a652c932 160000 --- a/RileyLinkKit +++ b/RileyLinkKit @@ -1 +1 @@ -Subproject commit 049f0d51b2fb90fa5180f3f62555d8c661b55968 +Subproject commit 52a652c932cdd5b677b11f149654ab773c433188 diff --git a/SyaiKit b/SyaiKit index 8161089806..d46674c540 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit 816108980650863b637934367dc5e35212001956 +Subproject commit d46674c5405d16418b55709d2e7464cd95d92412 diff --git a/TidepoolService b/TidepoolService index 99ac3544f8..9cee9e1389 160000 --- a/TidepoolService +++ b/TidepoolService @@ -1 +1 @@ -Subproject commit 99ac3544f8462973f6ea886d1a46c2954ad0cc0f +Subproject commit 9cee9e1389980df667e1bfab46e391ca74961fec diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 74626c50f4..9625ec270e 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 477F2885616B2839D22FF0A4 /* PumpStatusStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7EA1FA38A1728182C949F4 /* PumpStatusStorageTests.swift */; }; + 3A627606ABC518974CC60B55 /* PumpStatusStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E3EA2038BADFBCBC8801A4 /* PumpStatusStorage.swift */; }; + 4AB6E54D1659F6137A70BF74 /* PumpStatusStored+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE4D8D4BA1BD69A89CA1C13 /* PumpStatusStored+helper.swift */; }; + BDDB1F2A2BEE371D5C0765DE /* PumpStatusStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D62B1642A5DA5C2ADB0F9AA /* PumpStatusStored+CoreDataProperties.swift */; }; + FACBD2E6052820E56AC57A75 /* PumpStatusStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD86951C1C2313AD6D03EAD /* PumpStatusStored+CoreDataClass.swift */; }; 03B15F4B3077CD4B00DFEBE4 /* SyaiKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; }; 03B15F4D3077CD5400DFEBE4 /* SyaiKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; }; 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -78,7 +83,6 @@ 3811DE4325C9D4A100A708ED /* SettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE3E25C9D4A100A708ED /* SettingsProvider.swift */; }; 3811DE5C25C9D4D500A708ED /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE5425C9D4D500A708ED /* Formatters.swift */; }; 3811DE5D25C9D4D500A708ED /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE5525C9D4D500A708ED /* Publisher.swift */; }; - 3811DE5F25C9D4D500A708ED /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE5725C9D4D500A708ED /* ProgressBar.swift */; }; 3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE5925C9D4D500A708ED /* ViewModifiers.swift */; }; 3811DE8F25C9D80400A708ED /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE8E25C9D80400A708ED /* User.swift */; }; 3811DEA925C9D88300A708ED /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE9325C9D88200A708ED /* AppearanceManager.swift */; }; @@ -129,7 +133,6 @@ 3895E4C625B9E00D00214B37 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3895E4C525B9E00D00214B37 /* Preferences.swift */; }; 389A572026079BAA00BC102F /* Interpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389A571F26079BAA00BC102F /* Interpolation.swift */; }; 389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389ECDFD2601061500D86C4F /* View+Snapshot.swift */; }; - 389ECE052601144100D86C4F /* ConcurrentMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389ECE042601144100D86C4F /* ConcurrentMap.swift */; }; 38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A00B2225FC2B55006BC0B0 /* LRUCache.swift */; }; 38A0363B25ECF07E00FCBB52 /* GlucoseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */; }; 38A0364225ED069400FCBB52 /* TempBasal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A0364125ED069400FCBB52 /* TempBasal.swift */; }; @@ -183,10 +186,8 @@ 38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */; }; 38E98A3025F52FF700C0CED0 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A2F25F52FF700C0CED0 /* Config.swift */; }; 38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; }; - 38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */; }; 38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F37827261260DC009DB701 /* Color+Extensions.swift */; }; 38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; }; - 38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; }; 38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; }; 38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */; }; 38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826925CC82DB001FF17A /* NetworkService.swift */; }; @@ -435,7 +436,6 @@ 5E12FE0E2EBC0000000000B2 /* LibreLoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */; }; 5E12FE0E2EBC0000000000C1 /* LibreLoop.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5E12FE0E2EBC0000000000C2 /* LibreLoopUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5E12FE112EBC0000000000A2 /* LoopAlgorithm in Frameworks */ = {isa = PBXBuildFile; productRef = 5E12FE112EBC0000000000A1 /* LoopAlgorithm */; }; 5E12FE112EBC0000000000B2 /* RoundWhiteDiscKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5E12FE112EBC0000000000B1 /* RoundWhiteDiscKit */; }; 63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF91130F2FCCC7EBBA11AD /* BasalProfileEditorStateModel.swift */; }; 642F76A05A4FF530463A9FD0 /* NightscoutConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8782B44544F38F2B2D82C38E /* NightscoutConfigRootView.swift */; }; @@ -723,6 +723,7 @@ BD54A95B2D28087C00F9C1EE /* OverridePresetWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */; }; BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */; }; BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD54A9702D281A7A00F9C1EE /* TempTargetPresetsView.swift */; }; + A1A0EDA830974FFF0028A032 /* GlucoseFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */; }; A1B2C3D40F00000000000002 /* AlarmSnoozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40F00000000000001 /* AlarmSnoozeView.swift */; }; BD54A9732D281ABC00F9C1EE /* TempTargetPresetWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */; }; BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */; }; @@ -1136,7 +1137,6 @@ E00EEC0727368630002FF094 /* APSAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEC0127368630002FF094 /* APSAssembly.swift */; }; E00EEC0827368630002FF094 /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00EEC0227368630002FF094 /* NetworkAssembly.swift */; }; E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */; }; - E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06B9119275B5EEA003C04B6 /* Array+Extension.swift */; }; E0CC2C5C275B9F0F00A7BC71 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */; }; E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */; }; E13B7DAB2A435F57066AF02E /* TargetsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */; }; @@ -1339,6 +1339,11 @@ /* Begin PBXFileReference section */ 07629711A990D7666480FF41 /* ProfileSchedulerProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileSchedulerProvider.swift; sourceTree = ""; }; + 5C7EA1FA38A1728182C949F4 /* PumpStatusStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusStorageTests.swift; sourceTree = ""; }; + A0E3EA2038BADFBCBC8801A4 /* PumpStatusStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusStorage.swift; sourceTree = ""; }; + 1EE4D8D4BA1BD69A89CA1C13 /* PumpStatusStored+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpStatusStored+helper.swift"; sourceTree = ""; }; + 1D62B1642A5DA5C2ADB0F9AA /* PumpStatusStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpStatusStored+CoreDataProperties.swift"; sourceTree = ""; }; + 5CD86951C1C2313AD6D03EAD /* PumpStatusStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpStatusStored+CoreDataClass.swift"; sourceTree = ""; }; 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; }; @@ -1409,7 +1414,6 @@ 3811DE3E25C9D4A100A708ED /* SettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsProvider.swift; sourceTree = ""; }; 3811DE5425C9D4D500A708ED /* Formatters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = ""; }; 3811DE5525C9D4D500A708ED /* Publisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; - 3811DE5725C9D4D500A708ED /* ProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; 3811DE5925C9D4D500A708ED /* ViewModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; 3811DE8E25C9D80400A708ED /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 3811DE9325C9D88200A708ED /* AppearanceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = ""; }; @@ -1469,7 +1473,6 @@ 3895E4C525B9E00D00214B37 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 389A571F26079BAA00BC102F /* Interpolation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Interpolation.swift; sourceTree = ""; }; 389ECDFD2601061500D86C4F /* View+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Snapshot.swift"; sourceTree = ""; }; - 389ECE042601144100D86C4F /* ConcurrentMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentMap.swift; sourceTree = ""; }; 38A00B2225FC2B55006BC0B0 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; 38A0363A25ECF07E00FCBB52 /* GlucoseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorage.swift; sourceTree = ""; }; 38A0364125ED069400FCBB52 /* TempBasal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasal.swift; sourceTree = ""; }; @@ -1522,11 +1525,9 @@ 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSLocking+Extensions.swift"; sourceTree = ""; }; 38E98A2F25F52FF700C0CED0 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; - 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressViewStyle.swift; sourceTree = ""; }; 38F37827261260DC009DB701 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; 38F3783A2613555C009DB701 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetsStorage.swift; sourceTree = ""; }; - 38FCF3D525E8FDF40078B0D1 /* MD5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5.swift; sourceTree = ""; }; 38FCF3ED25E9028E0078B0D1 /* TrioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TrioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 38FCF3F125E9028E0078B0D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorageTests.swift; sourceTree = ""; }; @@ -2041,6 +2042,7 @@ BD54A9582D27FB6A00F9C1EE /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetWatch.swift; sourceTree = ""; }; BD54A9702D281A7A00F9C1EE /* TempTargetPresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetPresetsView.swift; sourceTree = ""; }; + A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseFormattingTests.swift; sourceTree = ""; }; A1B2C3D40F00000000000001 /* AlarmSnoozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSnoozeView.swift; sourceTree = ""; }; BD54A9722D281A9C00F9C1EE /* TempTargetPresetWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetPresetWatch.swift; sourceTree = ""; }; BD6EB2D52C7D049B0086BBB6 /* LiveActivityWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityWidgetConfiguration.swift; sourceTree = ""; }; @@ -2455,7 +2457,6 @@ E00EEC0127368630002FF094 /* APSAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APSAssembly.swift; sourceTree = ""; }; E00EEC0227368630002FF094 /* NetworkAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSimulatorSource.swift; sourceTree = ""; }; - E06B9119275B5EEA003C04B6 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = ""; }; E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = ""; }; @@ -2554,7 +2555,6 @@ 3B4BA78C2D8DC0EC0069D5B8 /* ShareClientUI.framework in Frameworks */, 3B4BA77A2D8DBD690069D5B8 /* MinimedKitUI.framework in Frameworks */, 3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */, - 5E12FE112EBC0000000000A2 /* LoopAlgorithm in Frameworks */, 5E12FE112EBC0000000000B2 /* RoundWhiteDiscKit in Frameworks */, 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */, 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */, @@ -3395,7 +3395,6 @@ 3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */, 383420D825FFEB3F002D46C1 /* Popup.swift */, 389ECDFD2601061500D86C4F /* View+Snapshot.swift */, - 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */, DDB0BBA02026050100000002 /* BolusProgressBar.swift */, 38DF1785276A73D400B3528F /* TagCloudView.swift */, 48961D478F2B46F1AC363923 /* ShareSheet.swift */, @@ -3558,14 +3557,12 @@ 492DD6C12F86C65900EA69C1 /* GlucoseBucketing.swift */, 492B87002D9EC02700D5E42B /* TaiStyle.swift */, 49CCD2102CAE5E99002A9476 /* BulletList.swift */, - E06B9119275B5EEA003C04B6 /* Array+Extension.swift */, DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */, 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */, FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */, 7A11AC0DE000000000000130 /* UIHostingController+KeyboardSafeArea.swift */, BD249DA62D42FE3800412DEB /* Calendar+GlucoseStatsChart.swift */, 38F37827261260DC009DB701 /* Color+Extensions.swift */, - 389ECE042601144100D86C4F /* ConcurrentMap.swift */, C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */, 38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */, 49AC5B632D7F2833005A47F3 /* CustomDateTimePicker.swift */, @@ -3580,10 +3577,8 @@ 388E5A5B25B6F0770019842D /* JSON.swift */, 38A00B2225FC2B55006BC0B0 /* LRUCache.swift */, 49B21BBC2D7F102100D0B6C3 /* MainChartHelper.swift */, - 38FCF3D525E8FDF40078B0D1 /* MD5.swift */, 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */, 38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */, - 3811DE5725C9D4D500A708ED /* ProgressBar.swift */, DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */, 3811DEE325CA063400A708ED /* PropertyWrappers */, 3811DE5525C9D4D500A708ED /* Publisher.swift */, @@ -3606,6 +3601,7 @@ 38A0362725ECF05300FCBB52 /* Storage */ = { isa = PBXGroup; children = ( + A0E3EA2038BADFBCBC8801A4 /* PumpStatusStorage.swift */, 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */, 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */, DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */, @@ -3737,6 +3733,7 @@ 38FCF3EE25E9028E0078B0D1 /* TrioTests */ = { isa = PBXGroup; children = ( + A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */, DDDD0FFD2F4E231600F9C645 /* Mocks */, DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */, 7A11AC0DE000000000000015 /* CoreDataTests/MultiUsePanelStateTests.swift */, @@ -4146,6 +4143,7 @@ 5825D1622BD405AE00F36E9B /* Helper */ = { isa = PBXGroup; children = ( + 1EE4D8D4BA1BD69A89CA1C13 /* PumpStatusStored+helper.swift */, 49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */, 581516A82BCEEDF800BF67D7 /* NSPredicates.swift */, 583684052BD178DB00070A60 /* GlucoseStored+helper.swift */, @@ -4668,6 +4666,7 @@ BD8FC0552D66187700B95AED /* CoreDataTests */ = { isa = PBXGroup; children = ( + 5C7EA1FA38A1728182C949F4 /* PumpStatusStorageTests.swift */, BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */, 7A11AC0DE00000000000000C /* ScheduledBasalInferenceTests.swift */, 7A11AC0DE000000000000009 /* PumpEventFinalizationTests.swift */, @@ -5423,6 +5422,8 @@ DDE179112C9100FA003CDDB7 /* Classes+Properties */ = { isa = PBXGroup; children = ( + 1D62B1642A5DA5C2ADB0F9AA /* PumpStatusStored+CoreDataProperties.swift */, + 5CD86951C1C2313AD6D03EAD /* PumpStatusStored+CoreDataClass.swift */, DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */, DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */, BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */, @@ -5759,7 +5760,6 @@ 3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */, DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */, 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */, - 5E12FE112EBC0000000000A1 /* LoopAlgorithm */, 5E12FE112EBC0000000000B1 /* RoundWhiteDiscKit */, ); productName = Trio; @@ -5932,7 +5932,6 @@ 3B47C60E2DA0A28F00B0E5EF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, DD868FD92E381E1C005D3308 /* XCRemoteSwiftPackageReference "Swift-JWT" */, 3E28F2A92EB5337F00FB9EEB /* XCRemoteSwiftPackageReference "connectiq-companion-app-sdk-ios" */, - 5E12FE112EBC0000000000A0 /* XCLocalSwiftPackageReference "LoopAlgorithm" */, 5E12FE112EBC0000000000B0 /* XCLocalSwiftPackageReference "RoundWhiteDiscKit" */, ); productRefGroup = 388E595925AD948C0019842D /* Products */; @@ -6190,6 +6189,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3A627606ABC518974CC60B55 /* PumpStatusStorage.swift in Sources */, + 4AB6E54D1659F6137A70BF74 /* PumpStatusStored+helper.swift in Sources */, + BDDB1F2A2BEE371D5C0765DE /* PumpStatusStored+CoreDataProperties.swift in Sources */, + FACBD2E6052820E56AC57A75 /* PumpStatusStored+CoreDataClass.swift in Sources */, DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */, 3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */, C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */, @@ -6515,12 +6518,10 @@ 3B5607A02ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift in Sources */, BD249D882D42FC0000412DEB /* BolusStatsView.swift in Sources */, 38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */, - 38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */, DDD163142C4C68D300CD525A /* AdjustmentsProvider.swift in Sources */, 3871F39C25ED892B0013ECB5 /* TempTarget.swift in Sources */, 191F62682AD6B05A004D7911 /* NightscoutSettings.swift in Sources */, 3811DEAB25C9D88300A708ED /* HTTPResponseStatus.swift in Sources */, - 3811DE5F25C9D4D500A708ED /* ProgressBar.swift in Sources */, BD249D8C2D42FC2C00412DEB /* GlucoseDistributionChart.swift in Sources */, DDFF20312DB1D15500AB8A96 /* BluetoothPermissionStepView.swift in Sources */, 38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */, @@ -6697,8 +6698,6 @@ 58645B9B2CA2D24F008AFCE7 /* CarbSetup.swift in Sources */, 38E44536274E411700EC9A94 /* Disk.swift in Sources */, 19A910362A24D6D700C8951B /* DateFilter.swift in Sources */, - E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */, - 38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */, DDB0BBA02026050100000001 /* BolusProgressBar.swift in Sources */, 389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */, 38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */, @@ -6812,7 +6811,6 @@ 9702FF92A09C53942F20D7EA /* TargetsEditorRootView.swift in Sources */, 1967DFBE29D052C200759F30 /* Icons.swift in Sources */, DDD163182C4C694000CD525A /* AdjustmentsRootView.swift in Sources */, - 389ECE052601144100D86C4F /* ConcurrentMap.swift in Sources */, 110AEDEC2C51A0AE00615CC9 /* ShortcutsConfigDataFlow.swift in Sources */, CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */, 3BF424C72DF4805A0017CFD9 /* AutosensError.swift in Sources */, @@ -7054,6 +7052,7 @@ 7A11AC0DE000000000000115 /* CoreDataTests/MultiUsePanelStateTests.swift in Sources */, 49AD0D012F8797F3004301B6 /* GlucoseBucketingTests.swift in Sources */, 49AD0CF32F8797F4004301B6 /* AdaptiveUKFOrderingRegressionTest.swift in Sources */, + 477F2885616B2839D22FF0A4 /* PumpStatusStorageTests.swift in Sources */, 3B5CD2C92D4AECD500CE213C /* ProfileCarbsTests.swift in Sources */, 3BEDC9932EEB27B600AC6492 /* IobConsecutiveEventsTests.swift in Sources */, 3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */, @@ -7061,6 +7060,7 @@ 3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */, 3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */, 3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */, + A1A0EDA830974FFF0028A032 /* GlucoseFormattingTests.swift in Sources */, 3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */, BD11A011000000000000A011 /* RoundBasalTests.swift in Sources */, BD11A015000000000000A011 /* PumpRateTables.swift in Sources */, @@ -7883,10 +7883,6 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5E12FE112EBC0000000000A0 /* XCLocalSwiftPackageReference "LoopAlgorithm" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = LoopAlgorithm; - }; 5E12FE112EBC0000000000B0 /* XCLocalSwiftPackageReference "RoundWhiteDiscKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = RoundWhiteDiscKit; @@ -8012,11 +8008,6 @@ package = 3E28F2A92EB5337F00FB9EEB /* XCRemoteSwiftPackageReference "connectiq-companion-app-sdk-ios" */; productName = ConnectIQ; }; - 5E12FE112EBC0000000000A1 /* LoopAlgorithm */ = { - isa = XCSwiftPackageProductDependency; - package = 5E12FE112EBC0000000000A0 /* XCLocalSwiftPackageReference "LoopAlgorithm" */; - productName = LoopAlgorithm; - }; 5E12FE112EBC0000000000B1 /* RoundWhiteDiscKit */ = { isa = XCSwiftPackageProductDependency; package = 5E12FE112EBC0000000000B0 /* XCLocalSwiftPackageReference "RoundWhiteDiscKit" */; diff --git a/Trio.xcworkspace/contents.xcworkspacedata b/Trio.xcworkspace/contents.xcworkspacedata index edc3da0af8..cb0a0eca4a 100644 --- a/Trio.xcworkspace/contents.xcworkspacedata +++ b/Trio.xcworkspace/contents.xcworkspacedata @@ -31,9 +31,6 @@ - - diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0d8c52a9da..93a3e6ec7b 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -295,7 +295,7 @@ "location" : "https://github.com/tidepool-org/TidepoolKit", "state" : { "branch" : "dev", - "revision" : "5c92b4ffc29cabf07609b367e3f82d0f733a6bc1" + "revision" : "290bd0431c55e8d0e0e542b163de13b1f07b77d9" } } ], diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 0d88d129ad..9645009b80 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -500,11 +500,6 @@ final class BaseAPSManager: APSManager, Injectable { return APSError.invalidPumpState(message: String(localized: "Pump suspended")) } - let reservoir = storage.retrieve(OpenAPS.Monitor.reservoir, as: Decimal.self) ?? 100 - guard reservoir >= 0 else { - return APSError.invalidPumpState(message: String(localized: "Reservoir is empty")) - } - return nil } @@ -849,7 +844,8 @@ final class BaseAPSManager: APSManager, Injectable { guard let state = pumpManager?.status.basalDeliveryState else { return fetchedTempBasal } switch state { - case .active: + case .active, + .pumpInoperable: return TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: date) case let .tempBasal(dose): let rate = adjustPumpedRateToU100(Decimal(dose.unitsPerHour)) @@ -1529,7 +1525,7 @@ final class BaseAPSManager: APSManager, Injectable { private extension PumpManager { func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.enactTempBasal(unitsPerHour: unitsPerHour, for: duration) { error in + self.enactTempBasal(decisionId: nil, unitsPerHour: unitsPerHour, for: duration) { error in if let error = error { debug(.pump, "Temp basal command failed: \(unitsPerHour) U/h for \(Int(duration / 60)) min") continuation.resume(throwing: error) @@ -1544,7 +1540,7 @@ private extension PumpManager { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let automaticValue = automatic ? BolusActivationType.automatic : BolusActivationType.manualRecommendationAccepted - self.enactBolus(units: units, activationType: automaticValue) { error in + self.enactBolus(decisionId: nil, units: units, activationType: automaticValue) { error in if let error = error { debug(.pump, "Bolus command failed: \(units) U") continuation.resume(throwing: error) @@ -1659,8 +1655,6 @@ extension BaseAPSManager: PumpManagerStatusObserver { warning(.storage, "Saving pump battery status failed", error: error) } } - // TODO: - remove this after ensuring that NS still gets the same infos from Core Data - storage.save(status.pumpStatus, as: OpenAPS.Monitor.status) } } @@ -1676,7 +1670,8 @@ extension BaseAPSManager: DoseProgressObserver { extension PumpManagerStatus { var pumpStatus: PumpStatus { let bolusing = bolusState != .noBolus - let suspended = basalDeliveryState?.isSuspended ?? true + // A halted patch or faulted pod reports .pumpInoperable; never loop against it. + let suspended = basalDeliveryState.map { $0.isSuspended || $0 == .pumpInoperable } ?? true let type = suspended ? StatusType.suspended : (bolusing ? .bolusing : .normal) return PumpStatus(status: type, bolusing: bolusing, suspended: suspended, timestamp: Date()) } diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift index f929e1f46c..e3c33f7554 100644 --- a/Trio/Sources/APS/CGM/PluginSource.swift +++ b/Trio/Sources/APS/CGM/PluginSource.swift @@ -118,54 +118,40 @@ extension PluginSource: CGMManagerDelegate { /// logging treatment as everything else. Used to be a no-op; on /// dev-libre3 / LibreLoop builds that meant CGM-issued alerts (sensor /// failure, signal loss, expiry, etc.) silently dropped. - func issueAlert(_ alert: LoopKit.Alert) { - glucoseManager?.trioAlertManager?.issueAlert(alert) + nonisolated func issueAlert(_ alert: LoopKit.Alert) { + processQueue.async { self.glucoseManager?.trioAlertManager?.issueAlert(alert) } } - func retractAlert(identifier: LoopKit.Alert.Identifier) { - glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier) + nonisolated func retractAlert(identifier: LoopKit.Alert.Identifier) { + processQueue.async { self.glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier) } } /// LoopKit asks this on reconnect to avoid re-issuing an alert that's /// still live. `TrioAlertManager` deduplicates downstream via its own /// throttler and live-alert table, so answering "no" here is safe — at /// worst we get one duplicate banner, which the throttler suppresses. - func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion: @escaping (Result) -> Void) { - completion(.success(false)) + func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier) async throws -> Bool { + false } - func lookupAllUnretracted( - managerIdentifier _: String, - completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void - ) { - completion(.success([])) + func lookupAllUnretracted(managerIdentifier _: String) async throws -> [LoopKit.PersistedAlert] { + [] } - func lookupAllUnacknowledgedUnretracted( - managerIdentifier _: String, - completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void - ) { - completion(.success([])) + func lookupAllUnacknowledgedUnretracted(managerIdentifier _: String) async throws -> [LoopKit.PersistedAlert] { + [] } /// LoopKit calls this when a manager itself decides an alert is no /// longer relevant. Mirror the action on our pipeline so the in-app /// banner, scheduled UN, and history all clear. - func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) { - glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier) + nonisolated func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) async throws { + processQueue.async { self.glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier) } } - func cgmManagerWantsDeletion(_ manager: CGMManager) { - processQueue.async { [weak self] in - guard let self = self else { return } - - dispatchPrecondition(condition: .onQueue(self.processQueue)) - - info(.cgm, "CGM \(manager.pluginIdentifier) requested removal") - Task { - await self.glucoseManager?.deleteGlucoseSource() - } - } + func cgmManagerWantsDeletion(_ manager: CGMManager) async { + info(.cgm, "CGM \(manager.pluginIdentifier) requested removal") + await glucoseManager?.deleteGlucoseSource() } func cgmManager(_: CGMManager, hasNew readingResult: CGMReadingResult) { diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index a343e0e0b9..967a1b8115 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,10 +1,11 @@ import Combine import CoreData +import DanaKit import Foundation -import HealthKit import LoopKit import LoopKitUI import MedtrumKit +import MinimedKit import MockKit import MockKitUI import OmnipodKit @@ -39,7 +40,9 @@ private let accessLock = NSRecursiveLock(label: "BaseDeviceDataManager.accessLoc final class BaseDeviceDataManager: DeviceDataManager, Injectable { private let processQueue = DispatchQueue.markedQueue(label: "BaseDeviceDataManager.processQueue", qos: .userInitiated) + private static let legacyReservoirFile = "monitor/reservoir.json" @Injected() private var pumpHistoryStorage: PumpHistoryStorage! + @Injected() private var pumpStatusStorage: PumpStatusStorage! @Injected() var alertHistoryStorage: AlertHistoryStorage! @Injected() private var storage: FileStorage! @Injected() private var broadcaster: Broadcaster! @@ -122,8 +125,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { // determines are within the user's configured maxBasal. let pumpSettings = settingsManager.pumpSettings let deliveryLimits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(pumpSettings.maxBasal)), - maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(pumpSettings.maxBolus)) + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(pumpSettings.maxBasal)), + maximumBolus: LoopQuantity(unit: .internationalUnit, doubleValue: Double(pumpSettings.maxBolus)) ) processQueue.async { @@ -160,7 +163,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { if let simulatorPump = pumpManager as? MockPumpManager { pumpDisplayState.value = PumpDisplayState(name: simulatorPump.localizedTitle, image: simulatorPump.smallImage) pumpName.send(simulatorPump.localizedTitle) - storage.save(Decimal(simulatorPump.pumpReservoirCapacity), as: OpenAPS.Monitor.reservoir) DispatchQueue.main.async { self.broadcaster.notify(PumpReservoirObserver.self, on: .main) { $0.pumpReservoirDidChange(Decimal(simulatorPump.state.reservoirUnitsRemaining)) @@ -172,6 +174,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } } else { info(.pump, oldValue == nil ? "No pump configured" : "Pump removed") + // A stored pump manager that fails to decode at launch counts as removed. + if oldValue != nil || rawPumpManager != nil { + pumpStatusStorage.storePumpRemoved() + } pumpDisplayState.value = nil pumpExpiresAtDate.send(nil) pumpActivatedAtDate.send(nil) @@ -237,6 +243,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { init(resolver: Resolver) { self.resolver = resolver injectServices(resolver) + removeLegacyReservoirFile() setupPumpManager() UIDevice.current.isBatteryMonitoringEnabled = true @@ -252,6 +259,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } } + private func removeLegacyReservoirFile() { // FIXME: this can be removed in later releases + storage.remove(Self.legacyReservoirFile) + } + func setupPumpManager() { if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.standard.legacyPumpManagerRawValue { pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) @@ -364,6 +375,9 @@ extension BaseDeviceDataManager: PumpManagerDelegate { settingsManager.settings.dosingMode.automation != .off // Trio may command the pump } + // Display-only hint for pump UIs; Trio doesn't report one. + var automatedTreatmentState: AutomatedTreatmentState? { nil } + func pumpManager( _: LoopKit.PumpManager, didRequestBasalRateScheduleChange _: LoopKit.BasalRateSchedule, @@ -461,8 +475,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { // Before any of the pump-specific branches below, which return early. storeBatteryStatus(status) - // TODO: - remove this after ensuring that NS still gets the same infos from Core Data - storage.save(status.pumpStatus, as: OpenAPS.Monitor.status) + pumpStatusStorage.storePumpStatus(status) switch status.bolusState { case .initiating: @@ -484,6 +497,8 @@ extension BaseDeviceDataManager: PumpManagerDelegate { case .suspended: suspended.send(true) scheduledBasal.send(false) + case .pumpInoperable: + scheduledBasal.send(nil) // pump is not currently available default: suspended.send(false) scheduledBasal.send(false) @@ -501,9 +516,8 @@ extension BaseDeviceDataManager: PumpManagerDelegate { manualTempBasal.send(manualTempBasalActive) if let medtrumPump = pumpManager as? MedtrumPumpManager { - storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { - $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir)) + $0.pumpReservoirDidChange(medtrumPump.currentReservoir) } // Medtrum's patchExpiresAt includes its grace period. Match @@ -523,13 +537,8 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } if let omni = pumpManager as? OmniPumpManager { - let reservoirVal = omni.state.podState?.lastInsulinMeasurements?.reservoirLevel ?? 0xDEAD_BEEF - // TODO: find the value Pod.maximumReservoirReading - let reservoir = Decimal(reservoirVal) > 50.0 ? 0xDEAD_BEEF : reservoirVal - - storage.save(Decimal(reservoir), as: OpenAPS.Monitor.reservoir) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { - $0.pumpReservoirDidChange(Decimal(reservoir)) + $0.pumpReservoirDidChange(omni.currentReservoir) } guard let endTime = omni.state.podState?.expiresAt else { @@ -550,11 +559,15 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } - func pumpManagerWillDeactivate(_: PumpManager) { - dispatchPrecondition(condition: .onQueue(processQueue)) - pumpManager = nil - broadcaster.notify(PumpDeactivatedObserver.self, on: processQueue) { - $0.pumpDeactivatedDidChange() + func pumpManagerWantsDeletion(_: PumpManager) async { + await withCheckedContinuation { continuation in + processQueue.async { + self.pumpManager = nil + self.broadcaster.notify(PumpDeactivatedObserver.self, on: self.processQueue) { + $0.pumpDeactivatedDidChange() + } + continuation.resume() + } } } @@ -618,7 +631,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate { ) { dispatchPrecondition(condition: .onQueue(processQueue)) debug(.pump, "Reservoir \(units) U") - storage.save(Decimal(units), as: OpenAPS.Monitor.reservoir) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(units)) } @@ -648,26 +660,23 @@ extension BaseDeviceDataManager: PumpManagerDelegate { // MARK: - DeviceManagerDelegate extension BaseDeviceDataManager: DeviceManagerDelegate { - func issueAlert(_ alert: Alert) { + nonisolated func issueAlert(_ alert: Alert) { debug(.alerts, "Pump alert issued: \(alert.identifier.alertIdentifier)") - trioAlertManager.issueAlert(alert) + processQueue.async { self.trioAlertManager.issueAlert(alert) } } - func retractAlert(identifier: Alert.Identifier) { + nonisolated func retractAlert(identifier: Alert.Identifier) { debug(.alerts, "Pump alert retracted: \(identifier.alertIdentifier)") - trioAlertManager.retractAlert(identifier: identifier) + processQueue.async { self.trioAlertManager.retractAlert(identifier: identifier) } } - func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result) -> Void) {} + func doesIssuedAlertExist(identifier _: Alert.Identifier) async throws -> Bool { false } - func lookupAllUnretracted(managerIdentifier _: String, completion _: @escaping (Result<[PersistedAlert], Error>) -> Void) {} + func lookupAllUnretracted(managerIdentifier _: String) async throws -> [PersistedAlert] { [] } - func lookupAllUnacknowledgedUnretracted( - managerIdentifier _: String, - completion _: @escaping (Result<[PersistedAlert], Error>) -> Void - ) {} + func lookupAllUnacknowledgedUnretracted(managerIdentifier _: String) async throws -> [PersistedAlert] { [] } - func recordRetractedAlert(_: Alert, at _: Date) {} + func recordRetractedAlert(_: Alert, at _: Date) async throws {} func removeNotificationRequests(for _: DeviceManager, identifiers: [String]) { DispatchQueue.main.async { @@ -699,8 +708,33 @@ extension BaseDeviceDataManager: DeviceManagerDelegate { // MARK: Others +extension PumpManager { + /// The reservoir level held in the pump manager's own state, nil when it has none. + /// 0xDEADBEEF stands for an Omnipod reading above 50 U. + var currentReservoir: Decimal? { + switch self { + case let medtrum as MedtrumPumpManager: + // MedtrumKit keeps the last patch's reservoir until a new patch is activated + return medtrum.state.patchSessionEnded ? nil : Decimal(medtrum.state.reservoir) + case let omni as OmniPumpManager: + guard let podState = omni.state.podState else { return nil } + guard let level = podState.lastInsulinMeasurements?.reservoirLevel, level <= 50 else { return 0xDEAD_BEEF } + return Decimal(level) + case let dana as DanaKitPumpManager: + // pumpTime is set on the first sync, after the same connection has read the reservoir + return dana.state.pumpTime == nil ? nil : Decimal(dana.state.reservoirLevel) + case let minimed as MinimedPumpManager: + return minimed.state.lastReservoirReading.map { Decimal($0.units) } + case let mock as MockPumpManager: + return Decimal(mock.state.reservoirUnitsRemaining) + default: + return nil + } + } +} + protocol PumpReservoirObserver { - func pumpReservoirDidChange(_ reservoir: Decimal) + func pumpReservoirDidChange(_ reservoir: Decimal?) } protocol PumpDeactivatedObserver { diff --git a/Trio/Sources/APS/Devices/DeviceCatalog.swift b/Trio/Sources/APS/Devices/DeviceCatalog.swift index bc2526b3ab..15d0f8d131 100644 --- a/Trio/Sources/APS/Devices/DeviceCatalog.swift +++ b/Trio/Sources/APS/Devices/DeviceCatalog.swift @@ -212,7 +212,8 @@ struct CGMCatalogEntry: DeviceCatalogEntry { /// Backed by a `CGMType` case other than `.plugin`. case native(CGMType) /// Backed by a statically linked `CGMManagerUI`; persisted as `CGMType.plugin`. - case managed(CGMManagerUI.Type) + /// `id` is the manager's `pluginIdentifier`, which LoopKit only exposes per instance. + case managed(CGMManagerUI.Type, id: String) /// The value persisted to `TrioSettings.cgm`. var cgmType: CGMType { @@ -226,12 +227,12 @@ struct CGMCatalogEntry: DeviceCatalogEntry { var id: String { switch self { case let .native(type): return type.rawValue - case let .managed(manager): return manager.pluginIdentifier + case let .managed(_, id): return id } } var managerType: CGMManagerUI.Type? { - guard case let .managed(manager) = self else { return nil } + guard case let .managed(manager, _) = self else { return nil } return manager } @@ -286,6 +287,8 @@ struct CGMCatalogEntry: DeviceCatalogEntry { struct PumpCatalogEntry: DeviceCatalogEntry { let manager: PumpManagerUI.Type + /// The manager's `pluginIdentifier`, which LoopKit only exposes per instance. + let id: String let manufacturer: DeviceManufacturer let name: String let supportedModels: [String] @@ -306,13 +309,13 @@ struct PumpCatalogEntry: DeviceCatalogEntry { /// `rewindResetsAutosens` is meaningless for them and onboarding hides the question. let reportsRewindEvents: Bool - var id: String { manager.pluginIdentifier } var iconImage: UIImage? { icon.image(managerBundle: Bundle(for: manager)) } var fallbackSymbolName: String { "ivfluid.bag" } var isSelectableInPicker: Bool { true } init( _ manager: PumpManagerUI.Type, + id: String, manufacturer: DeviceManufacturer, name: String, supportedModels: [String] = [], @@ -323,6 +326,7 @@ struct PumpCatalogEntry: DeviceCatalogEntry { reportsRewindEvents: Bool ) { self.manager = manager + self.id = id self.manufacturer = manufacturer self.name = name self.supportedModels = supportedModels @@ -349,14 +353,14 @@ extension DeviceCatalog { // Both Libre names carry their variants inline, matching the titles the driver authors chose upstream. // No icons: LibreTransmitterUI ships no asset catalog, and LibreLoopUI ships only onboarding steps. CGMCatalogEntry( - .managed(LibreTransmitterManagerV3.self), + .managed(LibreTransmitterManagerV3.self, id: "LibreTransmitterManagerV3"), manufacturer: .abbott, name: "FreeStyle Libre 1 / 2 / 2+" ), // "(Beta)" stays in the bold first line: it is a maturity warning about a driver feeding dosing // decisions, so it must not be demoted to the secondary line. CGMCatalogEntry( - .managed(LibreLoopCGMManager.self), + .managed(LibreLoopCGMManager.self, id: "LibreLoopCGMManager"), manufacturer: .abbott, name: "FreeStyle Libre 3 / 3+ (Beta)" ), @@ -364,29 +368,29 @@ extension DeviceCatalog { // Dexcom rebrands rather than versioning, so the "/" names carry the variants and a sub-line would // just repeat them. // No G5 icon: CGMBLEKitUI ships only "g6", and reusing it would picture the wrong hardware. - CGMCatalogEntry(.managed(G5CGMManager.self), manufacturer: .dexcom, name: "Dexcom G5"), + CGMCatalogEntry(.managed(G5CGMManager.self, id: "DexG5Transmitter"), manufacturer: .dexcom, name: "Dexcom G5"), CGMCatalogEntry( - .managed(G6CGMManager.self), + .managed(G6CGMManager.self, id: "DexG6Transmitter"), manufacturer: .dexcom, name: "Dexcom G6 / ONE", icon: .uiBundle(identifier: "com.loopkit.CGMBLEKitUI", asset: "g6") ), CGMCatalogEntry( - .managed(G7CGMManager.self), + .managed(G7CGMManager.self, id: "G7CGMManager"), manufacturer: .dexcom, name: "Dexcom G7 / ONE+ / Stelo", icon: .uiBundle(identifier: "org.loopkit.G7SensorKitUI", asset: "g7") ), CGMCatalogEntry( - .managed(AccuChekCgmManager.self), + .managed(AccuChekCgmManager.self, id: "AccuChek"), manufacturer: .roche, name: "Accu-Chek SmartGuide", icon: .managerBundle(asset: "sensor") ), CGMCatalogEntry( - .managed(EversenseCGMManager.self), + .managed(EversenseCGMManager.self, id: "EversenseKit"), manufacturer: .senseonics, name: "Eversense", supportedModels: ["E3", "E365"], @@ -394,7 +398,7 @@ extension DeviceCatalog { ), CGMCatalogEntry( - .managed(SyaiCGMManager.self), + .managed(SyaiCGMManager.self, id: "SyaiCGMManager"), manufacturer: .syai, name: "Syai Ultra", icon: .uiBundle(identifier: "org.loopkit.SyaiKitUI", asset: "sensor") @@ -413,6 +417,7 @@ extension DeviceCatalog { static let pumps: [PumpCatalogEntry] = [ PumpCatalogEntry( OmniPumpManager.self, + id: "Omni", manufacturer: .insulet, name: "Omnipod", supportedModels: ["Classic", "DASH", "5"], @@ -423,6 +428,7 @@ extension DeviceCatalog { ), PumpCatalogEntry( MinimedPumpManager.self, + id: "Minimed500", manufacturer: .medtronic, name: "MiniMed", supportedModels: ["x15", "x22", "x23", "x54"], @@ -432,6 +438,7 @@ extension DeviceCatalog { ), PumpCatalogEntry( MedtrumPumpManager.self, + id: "Medtrum", manufacturer: .medtrum, name: "Medtrum Nano", supportedModels: ["200U", "300U"], @@ -441,6 +448,7 @@ extension DeviceCatalog { ), PumpCatalogEntry( DanaKitPumpManager.self, + id: "Dana", manufacturer: .sooil, name: "Dana", supportedModels: ["DanaRS", "Dana-i"], @@ -450,6 +458,7 @@ extension DeviceCatalog { ), PumpCatalogEntry( MockPumpManager.self, + id: "MockPumpManager", manufacturer: .simulator, name: String(localized: "Pump Simulator", comment: "Simulated pump in the device picker"), icon: .uiBundle(identifier: "com.loopkit.MockKitUI", asset: "Pump Simulator"), diff --git a/Trio/Sources/APS/Extensions/LoopUIColorPalette+Default.swift b/Trio/Sources/APS/Extensions/LoopUIColorPalette+Default.swift index 1b1fe5f1dd..8354410fe3 100644 --- a/Trio/Sources/APS/Extensions/LoopUIColorPalette+Default.swift +++ b/Trio/Sources/APS/Extensions/LoopUIColorPalette+Default.swift @@ -25,6 +25,7 @@ extension ChartColorPalette { axisLine: .axisLineColor, axisLabel: .axisLabelColor, grid: .gridColor, + presetTint: .systemIndigo, glucoseTint: .glucoseTintColor, insulinTint: .insulinTintColor, carbTint: .carbTintColor diff --git a/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift b/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift index f027967d2b..2031686681 100644 --- a/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift +++ b/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift @@ -25,7 +25,7 @@ extension Double { } extension PumpManagerUI { - func settingsViewController( + @MainActor func settingsViewController( bluetoothProvider: BluetoothProvider, pumpManagerOnboardingDelegate: PumpManagerOnboardingDelegate? ) -> UIViewController & CompletionNotifying { diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 7d351e24d2..6552bdaf5a 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -1,7 +1,6 @@ import Combine import CoreData import Foundation -import HealthKit import LoopKit import LoopKitUI import SwiftDate @@ -233,8 +232,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { private func updateManagerUnits(_ manager: CGMManagerUI?) { let units = settingsManager.settings.units let managerName = cgmManager.map { "\(type(of: $0))" } ?? "nil" - let loopkitUnits: HKUnit = units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter - debug(.cgm, "\(managerName) units set to \(loopkitUnits.description)") + let loopkitUnits: LoopUnit = units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter + debug(.cgm, "\(managerName) units set to \(loopkitUnits.unitString)") manager?.unitDidChange(to: loopkitUnits) } diff --git a/Trio/Sources/APS/OpenAPS/Constants.swift b/Trio/Sources/APS/OpenAPS/Constants.swift index 67974fee41..8450e93c8c 100644 --- a/Trio/Sources/APS/OpenAPS/Constants.swift +++ b/Trio/Sources/APS/OpenAPS/Constants.swift @@ -16,10 +16,8 @@ extension OpenAPS { enum Monitor { static let pumpHistory = "monitor/pumphistory-24h-zoned.json" - static let reservoir = "monitor/reservoir.json" static let carbHistory = "monitor/carbhistory.json" static let clock = "monitor/clock-zoned.json" - static let status = "monitor/status.json" static let tempBasal = "monitor/temp_basal.json" static let meal = "monitor/meal.json" static let glucose = "monitor/glucose.json" diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index 268be24e47..c4c1a87604 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -285,7 +285,6 @@ final class OpenAPS { async let profileAsync = loadFileFromStorageAsync(name: Settings.profile) async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile) async let autosenseAsync = loadFileFromStorageAsync(name: Settings.autosense) - async let reservoirAsync = loadFileFromStorageAsync(name: Monitor.reservoir) async let hasSufficientTddForDynamic = tddStorage.hasSufficientTDD() // Await the results of asynchronous tasks @@ -297,7 +296,6 @@ final class OpenAPS { rawProfile, rawBasalProfile, rawAutosens, - rawReservoir, hasSufficientTdd ) = await ( try parsePumpHistory(on: context, await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount), @@ -307,7 +305,6 @@ final class OpenAPS { profileAsync, basalAsync, autosenseAsync, - reservoirAsync, try hasSufficientTddForDynamic ) @@ -319,7 +316,6 @@ final class OpenAPS { profile.suspendOnly = dosingMode.automation == .hypoSuspendOnly let basalProfile = try JSONBridge.basalProfile(from: rawBasalProfile) let autosens = try JSONBridge.autosens(from: rawAutosens.isEmpty ? .null : rawAutosens) - let reservoir = Decimal(string: rawReservoir) ?? 100 let trioCustomOrefVariables = try JSONBridge.trioCustomOrefVariables(from: rawTrioCustomOrefVariables) // Meal calculation @@ -360,7 +356,6 @@ final class OpenAPS { autosens: autosens, meal: meal, microBolusAllowed: true, - reservoir: reservoir, pumpHistory: pumpHistory, preferences: preferences, trioCustomOrefVariables: trioCustomOrefVariables, @@ -720,7 +715,6 @@ final class OpenAPS { autosens: Autosens?, meal: ComputedCarbs?, microBolusAllowed: Bool, - reservoir: Decimal, pumpHistory: [PumpHistoryEvent], preferences: Preferences, trioCustomOrefVariables: TrioCustomOrefVariables, @@ -740,7 +734,6 @@ final class OpenAPS { iobData: iob, mealData: meal, autosensData: autosens, - reservoirData: reservoir, glucose: glucose, pumpHistory: pumpHistory, microBolusAllowed: microBolusAllowed, diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift index 45b0953995..0615ca5c06 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift @@ -20,7 +20,6 @@ enum DeterminationGenerator { iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData: Decimal, glucose: [BloodGlucose], pumpHistory: [PumpHistoryEvent], microBolusAllowed: Bool, @@ -60,7 +59,6 @@ enum DeterminationGenerator { iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, pumpHistory: pumpHistory, microBolusAllowed: microBolusAllowed, @@ -82,7 +80,6 @@ enum DeterminationGenerator { iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData _: Decimal, glucoseStatus: GlucoseStatus, pumpHistory: [PumpHistoryEvent] = [], microBolusAllowed: Bool, diff --git a/Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift b/Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift deleted file mode 100644 index f0391452d8..0000000000 --- a/Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift +++ /dev/null @@ -1,11 +0,0 @@ -// import Foundation -// -// extension ComputedBGTargets { -// func targetEntry(for time: Date = Date()) -> ComputedBGTargetEntry? { -// // Assumes targets are sorted by start/offset ascending, wrap at midnight -// let nowMinutes = Calendar.current.component(.hour, from: time) * 60 + -// Calendar.current.component(.minute, from: time) -// // Find last entry with offset <= nowMinutes -// return targets.last(where: { $0.offset <= nowMinutes }) ?? targets.first -// } -// } diff --git a/Trio/Sources/APS/PluginManager.swift b/Trio/Sources/APS/PluginManager.swift index f0f4755d11..350a16a115 100644 --- a/Trio/Sources/APS/PluginManager.swift +++ b/Trio/Sources/APS/PluginManager.swift @@ -22,7 +22,7 @@ class BasePluginManager: Injectable, PluginManager { var availableCGMManagers: [CGMManagerDescriptor] { DeviceCatalog.cgmManagerEntries.map { - CGMManagerDescriptor(identifier: $0.id, localizedTitle: $0.name) + CGMManagerDescriptor(identifier: $0.id, localizedTitle: $0.name, manufacturer: nil, image: nil) } } } diff --git a/Trio/Sources/APS/Storage/GlucoseStorage.swift b/Trio/Sources/APS/Storage/GlucoseStorage.swift index 39f5211608..61b522b96a 100644 --- a/Trio/Sources/APS/Storage/GlucoseStorage.swift +++ b/Trio/Sources/APS/Storage/GlucoseStorage.swift @@ -51,17 +51,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { injectServices(resolver) } - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if settingsManager.settings.units == .mmolL { - formatter.maximumFractionDigits = 1 - } - formatter.decimalSeparator = "." - return formatter - } - /// Backfills glucose values and stores in CoreData /// /// CGM managers will sometimes backfill glucose readings. To handle these backfilled values diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift index 7b3bc27db8..a246412521 100644 --- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift +++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift @@ -744,6 +744,7 @@ extension PumpEventType { case .replaceComponent(componentType: .infusionSet), .replaceComponent(componentType: .pump): return .siteChange case .replaceComponent: return nil + case .timeZoneSync: return nil @unknown default: return nil } } diff --git a/Trio/Sources/APS/Storage/PumpStatusStorage.swift b/Trio/Sources/APS/Storage/PumpStatusStorage.swift new file mode 100644 index 0000000000..7d095a1ce6 --- /dev/null +++ b/Trio/Sources/APS/Storage/PumpStatusStorage.swift @@ -0,0 +1,132 @@ +import CoreData +import Foundation +import LoopKit +import Swinject + +protocol PumpStatusStorage { + /// Stores the status when its delivery state differs from the last stored status. + func storePumpStatus(_ status: PumpManagerStatus) + /// Stores an unknown delivery state for a removed pump. + func storePumpRemoved() + func lastPumpStatus() async -> PumpStatus? + /// Deletes statuses older than the given number of days, keeping the latest one. + func deleteStatuses(olderThan days: Int) async +} + +final class BasePumpStatusStorage: PumpStatusStorage, Injectable { + @Injected() private var fileStorage: FileStorage! + + /// One context for all stores, so each compares against the status stored before it, in the order the updates arrive. + private let context: NSManagedObjectContext + + private static let legacyStatusFile = "monitor/status.json" + + init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) { + context = contextProvider?() ?? CoreDataStack.shared.newTaskContext() + context.name = "pumpStatusStorage" + injectServices(resolver) + migrateLegacyStatusFile() + } + + func storePumpStatus(_ status: PumpManagerStatus) { + store(PumpStatusStored.DeliveryState(status.basalDeliveryState)) + } + + func storePumpRemoved() { + store(.unknown) + } + + func lastPumpStatus() async -> PumpStatus? { + await context.perform { [context] in + do { + return try context.fetch(PumpStatusStored.fetchLast()).first?.pumpStatus + } catch { + warning(.storage, "Last pump status fetch failed", error: error) + return nil + } + } + } + + func deleteStatuses(olderThan days: Int) async { + guard let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else { return } + + await context.perform { [context] in + do { + guard let latestDate = try context.fetch(PumpStatusStored.fetchLast()).first?.date else { return } + let request = PumpStatusStored.fetchRequest() + request.predicate = NSPredicate(format: "date < %@ AND date < %@", cutoff as NSDate, latestDate as NSDate) + for stored in try context.fetch(request) { + context.delete(stored) + } + guard context.hasChanges else { return } + try context.save() + } catch { + context.rollback() + warning(.storage, "Deleting old pump statuses failed", error: error) + } + } + } + + private func store(_ deliveryState: PumpStatusStored.DeliveryState) { + let date = Date() + + context.perform { [context] in + do { + if let last = try context.fetch(PumpStatusStored.fetchLast()).first, last.deliveryState == deliveryState { + return + } + + let stored = PumpStatusStored(context: context) + stored.date = date + stored.basalDeliveryState = deliveryState.rawValue + try context.save() + debug(.storage, "Pump status stored: \(deliveryState.rawValue)") + } catch { + context.rollback() + warning(.storage, "Saving pump status failed", error: error) + } + } + } + + private func migrateLegacyStatusFile() { // FIXME: this can be removed in later releases + guard let legacy = fileStorage.retrieve(Self.legacyStatusFile, as: PumpStatus.self) else { return } + + context.perform { [context, fileStorage] in + let stored = PumpStatusStored(context: context) + stored.date = legacy.timestamp ?? Date() + stored.basalDeliveryState = (legacy.suspended ? PumpStatusStored.DeliveryState.suspended : .active).rawValue + do { + try context.save() + fileStorage?.remove(Self.legacyStatusFile) + debug(.storage, "Legacy pump status migrated: \(legacy.status.rawValue)") + } catch { + context.rollback() + warning(.storage, "Migrating the legacy pump status failed", error: error) + } + } + } +} + +extension PumpStatusStored.DeliveryState { + init(_ basalDeliveryState: PumpManagerStatus.BasalDeliveryState?) { + switch basalDeliveryState { + case .none: + self = .unknown + case let .active(since) where since == .distantPast: + self = .inactive + case .pumpInoperable: + self = .inactive + case .suspending: + self = .suspending + case .suspended: + self = .suspended + case .resuming: + self = .resuming + case .active, + .cancelingTempBasal, + .initiatingTempBasal, + .tempBasal: + self = .active + } + } +} diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 21b4e6afed..d31f9dc02f 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -396,6 +396,7 @@ extension Notification.Name { .onChange(of: showLoadingView) { _, isLoading in if !isLoading { presentDevelopmentBranchWarningIfNeeded() + requestAlarmAuthorizationIfNeeded() } } // A first-time user is still in onboarding when the loading screen goes away, so the @@ -423,6 +424,7 @@ extension Notification.Name { AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC) } presentDevelopmentBranchWarningIfNeeded() + requestAlarmAuthorizationIfNeeded() if initState.complete { performCleanupIfNecessary() } @@ -442,6 +444,16 @@ extension Notification.Name { developmentBranchAlerter.alertIfNeeded() } + /// Onboarding asks for AlarmKit, but users who onboarded before it existed were never asked, + /// leaving critical alarms on the in-app audio fallback. No-op once decided or below iOS 26. + @MainActor private func requestAlarmAuthorizationIfNeeded() { + guard initState.complete, !onboardingManager.shouldShowOnboarding else { + return + } + + Task { await CriticalAlertAlarmScheduler.requestAuthorization() } + } + func configureTabBarAppearance() { let appearance = UITabBarAppearance() appearance.configureWithDefaultBackground() @@ -507,6 +519,7 @@ extension Notification.Name { async let determinationDeletion: () = coreDataStack .batchDeleteOlderThan(OrefDetermination.self, dateKey: "deliverAt", days: 90) async let batteryDeletion: () = coreDataStack.batchDeleteOlderThan(OpenAPS_Battery.self, dateKey: "date", days: 90) + async let pumpStatusDeletion: () = resolver.resolve(PumpStatusStorage.self)!.deleteStatuses(olderThan: 90) async let carbEntryDeletion: () = coreDataStack.batchDeleteOlderThan(CarbEntryStored.self, dateKey: "date", days: 90) async let forecastDeletion: () = coreDataStack.batchDeleteOlderThan(Forecast.self, dateKey: "date", days: 2) async let forecastValueDeletion: () = coreDataStack.batchDeleteOlderThan( @@ -529,6 +542,7 @@ extension Notification.Name { try await tempBasalDeletion try await determinationDeletion try await batteryDeletion + await pumpStatusDeletion try await carbEntryDeletion try await forecastDeletion try await forecastValueDeletion diff --git a/Trio/Sources/Assemblies/StorageAssembly.swift b/Trio/Sources/Assemblies/StorageAssembly.swift index 006654b6be..c2dcf3e420 100644 --- a/Trio/Sources/Assemblies/StorageAssembly.swift +++ b/Trio/Sources/Assemblies/StorageAssembly.swift @@ -8,6 +8,7 @@ final class StorageAssembly: Assembly { } container.register(FileStorage.self) { _ in BaseFileStorage() } container.register(PumpHistoryStorage.self) { r in BasePumpHistoryStorage(resolver: r) } + container.register(PumpStatusStorage.self) { r in BasePumpStatusStorage(resolver: r) } container.register(OverrideStorage.self) { r in BaseOverrideStorage(resolver: r) } container.register(DeterminationStorage.self) { r in BaseDeterminationStorage(resolver: r) } container.register(TDDStorage.self) { r in BaseTDDStorage(resolver: r) } diff --git a/Trio/Sources/Extensions/Color+Extensions.swift b/Trio/Sources/Extensions/Color+Extensions.swift deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/Trio/Sources/Helpers/Array+Extension.swift b/Trio/Sources/Helpers/Array+Extension.swift deleted file mode 100644 index 9eb77a18aa..0000000000 --- a/Trio/Sources/Helpers/Array+Extension.swift +++ /dev/null @@ -1,11 +0,0 @@ -extension Array where Element: Hashable { - func removeDublicates() -> Self { - var result = Self() - for item in self { - if !result.contains(item) { - result.append(item) - } - } - return result - } -} diff --git a/Trio/Sources/Helpers/ConcurrentMap.swift b/Trio/Sources/Helpers/ConcurrentMap.swift deleted file mode 100644 index e9d11b0091..0000000000 --- a/Trio/Sources/Helpers/ConcurrentMap.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -extension Collection where Index == Int { - func concurrentMap(_ transform: (Element) -> T) -> [T] { - let buffer = UnsafeMutableRawPointer.allocate( - byteCount: count * MemoryLayout.stride, - alignment: MemoryLayout.alignment - ).bindMemory(to: T.self, capacity: count) - - DispatchQueue.concurrentPerform(iterations: count) { index in - let element = self[index] - let transformedElement = transform(element) - buffer[index] = transformedElement - } - - return [T](UnsafeBufferPointer(start: buffer, count: count)) - } -} diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index 1d943518cd..d5f2f01ee1 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -1,20 +1,5 @@ import Foundation -enum Formatters { - static func percent(for number: Double) -> String { - let formater = NumberFormatter() - formater.numberStyle = .percent - return formater.string(for: number)! - } - - static func timeFor(minutes: Int) -> String { - let formater = DateComponentsFormatter() - formater.unitsStyle = .abbreviated - formater.allowedUnits = [.hour, .minute] - return formater.string(from: TimeInterval(minutes * 60))! - } -} - extension Formatter { static let iso8601withFractionalSeconds: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() @@ -72,19 +57,6 @@ extension Formatter { return dateFormatter }() - static let dayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "d" - return formatter - }() - - static let timeForLogFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - formatter.timeZone = TimeZone.current - return formatter - }() - static let decimalFormatterWithOneFractionDigit: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal @@ -100,23 +72,6 @@ extension Formatter { return formatter }() - static func glucoseFormatter(for units: GlucoseUnits) -> NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.roundingMode = .halfUp - - switch units { - case .mmolL: - formatter.locale = .current - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - case .mgdL: - formatter.maximumFractionDigits = 0 - } - - return formatter - } - /// Live bolus progress, rendered to the increment the pump actually delivers in. static func bolusProgressFormatter(for bolusIncrement: Decimal) -> NumberFormatter { let formatter = NumberFormatter() diff --git a/Trio/Sources/Helpers/MD5.swift b/Trio/Sources/Helpers/MD5.swift deleted file mode 100644 index 07544af117..0000000000 --- a/Trio/Sources/Helpers/MD5.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CryptoKit -import Foundation - -extension Data { - var md5String: String { - let digest = Insecure.MD5.hash(data: self) - return digest.map { - String(format: "%02hhx", $0) - }.joined() - } -} - -extension String { - var md5String: String { - (data(using: .utf8) ?? Data()).md5String - } -} diff --git a/Trio/Sources/Helpers/ProgressBar.swift b/Trio/Sources/Helpers/ProgressBar.swift deleted file mode 100644 index eceb251e79..0000000000 --- a/Trio/Sources/Helpers/ProgressBar.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct ProgressBar: View { - @Binding var value: Float - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule(style: .circular) - .frame(width: geometry.size.width, height: geometry.size.height) - .opacity(0.3) - .foregroundColor(.secondary) - - Capsule(style: .circular) - .frame( - width: min(CGFloat(self.value) * geometry.size.width, geometry.size.width), - height: geometry.size.height - ) - .foregroundColor(.accentColor) - .animation(.linear, value: value) - } - } - .frame(height: 20) - } -} diff --git a/Trio/Sources/Helpers/SettingsRowView.swift b/Trio/Sources/Helpers/SettingsRowView.swift deleted file mode 100644 index 6d8c99e885..0000000000 --- a/Trio/Sources/Helpers/SettingsRowView.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -struct SettingsRowView: View { - let imageName: String - let title: String - let tint: Color - let spacing: CGFloat? - let font: CGFloat? - - init(imageName: String, title: String, tint: Color, spacing: CGFloat? = 12, font: CGFloat? = 35) { - self.imageName = imageName - self.title = title - self.tint = tint - self.spacing = spacing - self.font = font - } - - var body: some View { - HStack(spacing: spacing ?? 12, content: { - Image(systemName: imageName) - .imageScale(.small) - .font(.system(size: font ?? 35)) - .foregroundColor(tint) - - Text(title) - .font(.subheadline) - .foregroundStyle(.primary) - }) - } -} - -struct SettingsRowViewCustomImage: View { - let imageName: String - let title: String - let frame: CGFloat? - let spacing: CGFloat? - - init(imageName: String, title: String, frame: CGFloat? = 35, spacing: CGFloat? = 12) { - self.imageName = imageName - self.title = title - self.frame = frame - self.spacing = spacing - } - - var body: some View { - HStack(spacing: spacing ?? 12, content: { - Image(imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: frame ?? 35, height: frame ?? 35) - - Text(title) - .font(.subheadline) - .foregroundStyle(.primary) - }) - } -} diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index dcbc7afc6a..2c5703199a 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -141,8 +141,8 @@ final class DeviceLogStore: @unchecked Sendable { }) return } - deviceLog.getLogEntries(startDate: window.start, endDate: window.end) { result in - let entries = (try? result.get()) ?? [] + Task { + let entries = (try? await deviceLog.fetch(startDate: window.start, endDate: window.end)) ?? [] guard let managerIdentifier else { continuation.resume(returning: entries) return diff --git a/Trio/Sources/Models/BloodGlucose+LoopKit.swift b/Trio/Sources/Models/BloodGlucose+LoopKit.swift index 6416d0e52c..3c8ba80bd4 100644 --- a/Trio/Sources/Models/BloodGlucose+LoopKit.swift +++ b/Trio/Sources/Models/BloodGlucose+LoopKit.swift @@ -12,7 +12,7 @@ extension BloodGlucose { StoredGlucoseSample( syncIdentifier: id, startDate: dateString.date, - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(glucose!)), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(glucose!)), wasUserEntered: isManualGlucose, device: HKDevice.local() ) diff --git a/Trio/Sources/Models/BloodGlucose.swift b/Trio/Sources/Models/BloodGlucose.swift index f24a6e472a..855da5ac34 100644 --- a/Trio/Sources/Models/BloodGlucose.swift +++ b/Trio/Sources/Models/BloodGlucose.swift @@ -198,12 +198,8 @@ extension Int { Trio.rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain) } - var formattedAsMmolL: String { - NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" - } - func formatted(for units: GlucoseUnits) -> String { - units == .mgdL ? description : formattedAsMmolL + Decimal(self).formatted(for: units) } func formatted(withUnits units: GlucoseUnits) -> String { @@ -224,17 +220,35 @@ extension Decimal { Trio.rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain) } - var formattedAsMmolL: String { - NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" + func formatted(for units: GlucoseUnits, locale: Locale = .autoupdatingCurrent) -> String { + converted(to: units).formatted(Self.glucoseStyle(for: units).locale(locale)) } - func formatted(for units: GlucoseUnits) -> String { - units == .mgdL ? description : formattedAsMmolL + func formattedDelta(for units: GlucoseUnits, locale: Locale = .autoupdatingCurrent) -> String { + converted(to: units).formatted(Self.glucoseStyle(for: units).locale(locale).sign(strategy: .always(includingZero: true))) + } + + func formattedISF(for units: GlucoseUnits, locale: Locale = .autoupdatingCurrent) -> String { + converted(to: units).formatted(Self.glucoseStyle(for: units, mgdLFractionLength: 0 ... 1).locale(locale)) } func formatted(withUnits units: GlucoseUnits) -> String { formatted(for: units) + " \(units.rawValue)" } + + private func converted(to units: GlucoseUnits) -> Decimal { + units == .mgdL ? self : self * GlucoseUnits.exchangeRate + } + + private static func glucoseStyle( + for units: GlucoseUnits, + mgdLFractionLength: ClosedRange = 0 ... 0 + ) -> Decimal.FormatStyle { + .number + .precision(.fractionLength(units == .mgdL ? mgdLFractionLength : 1 ... 1)) + .grouping(.never) + .rounded(rule: .toNearestOrAwayFromZero) + } } extension Double { @@ -250,12 +264,8 @@ extension Double { Trio.rounded(Decimal(self) / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain) } - var formattedAsMmolL: String { - NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)" - } - func formatted(for units: GlucoseUnits) -> String { - units == .mgdL ? description : formattedAsMmolL + Decimal(self).formatted(for: units) } func formatted(withUnits units: GlucoseUnits) -> String { @@ -263,17 +273,6 @@ extension Double { } } -extension NumberFormatter { - static let glucoseFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.locale = Locale.current - formatter.numberStyle = .decimal - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - return formatter - }() -} - /// Spells out compact unit abbreviations for VoiceOver so screen readers don't voice /// "mg/dL" as letters or "U" as "you". Compound units (e.g. "mg/dL/U", "g/U", "U/hr") /// are split on "/" and joined with "per", so each token only needs a single mapping. diff --git a/Trio/Sources/Models/PumpStatus.swift b/Trio/Sources/Models/PumpStatus.swift index 1d03fedc62..339a376c9b 100644 --- a/Trio/Sources/Models/PumpStatus.swift +++ b/Trio/Sources/Models/PumpStatus.swift @@ -8,9 +8,19 @@ struct PumpStatus: JSON, Equatable { } extension PumpStatus { + /// Suspended for any delivery state without delivery. + init(deliveryState: PumpStatusStored.DeliveryState, bolusing: Bool, timestamp: Date?) { + let suspended = [.suspended, .inactive, .unknown].contains(deliveryState) + self.init( + status: suspended ? .suspended : (bolusing ? .bolusing : .normal), + bolusing: bolusing, + suspended: suspended, + timestamp: timestamp + ) + } + /// Also suspended while the pump manager reports delivery active since before the last pump - /// suspend that no resume, temp basal or pump bolus followed. Omnipod reports delivery as active - /// since `.distantPast` while no pod is active. + /// suspend that no resume, temp basal or pump bolus followed. func includingSuspend(deliveryActiveSince: Date, lastPumpEvent: (type: String, date: Date)?) -> PumpStatus { guard !suspended, let lastPumpEvent, lastPumpEvent.type == PumpEventStored.EventType.pumpSuspend.rawValue, diff --git a/Trio/Sources/Modules/AddCarbs/AddCarbsStateModel.swift b/Trio/Sources/Modules/AddCarbs/AddCarbsStateModel.swift deleted file mode 100644 index 9ee0bf9f38..0000000000 --- a/Trio/Sources/Modules/AddCarbs/AddCarbsStateModel.swift +++ /dev/null @@ -1,180 +0,0 @@ -import CoreData -import Foundation -import SwiftUI - -extension AddCarbs { - final class StateModel: BaseStateModel { - @Injected() var carbsStorage: CarbsStorage! - @Injected() var apsManager: APSManager! - @Injected() var settings: SettingsManager! - @Published var carbs: Decimal = 0 - @Published var date = Date() - @Published var protein: Decimal = 0 - @Published var fat: Decimal = 0 - @Published var carbsRequired: Decimal? - @Published var useFPUconversion: Bool = true - @Published var dish: String = "" - @Published var selection: Presets? - @Published var summation: [String] = [] - @Published var maxCarbs: Decimal = 250 - @Published var maxFat: Decimal = 250 - @Published var maxProtein: Decimal = 250 - @Published var note: String = "" - - let coredataContext = CoreDataStack.shared.persistentContainer.viewContext - - override func subscribe() { - subscribeSetting(\.useFPUconversion, on: $useFPUconversion) { useFPUconversion = $0 } - carbsRequired = provider.suggestion?.carbsReq - maxCarbs = settings.settings.maxCarbs - maxFat = settings.settings.maxFat - maxProtein = settings.settings.maxProtein - } - - func add() { - guard carbs > 0 || fat > 0 || protein > 0 else { - showModal(for: nil) - return - } - carbs = min(carbs, maxCarbs) - - carbsStorage.storeCarbs( - [CarbsEntry( - id: UUID().uuidString, - createdAt: date, - carbs: carbs, - fat: fat, - protein: protein, - note: note, - enteredBy: CarbsEntry.manual, - isFPU: false, fpuID: nil - )] - ) - - if settingsManager.settings.skipBolusScreenAfterCarbs { - apsManager.determineBasalSync() - showModal(for: nil) - } else { - showModal(for: .bolus(waitForSuggestion: true)) - } - } - - func deletePreset() { - if selection != nil { - try? coredataContext.delete(selection!) - try? coredataContext.save() - carbs = 0 - fat = 0 - protein = 0 - } - selection = nil - } - - func removePresetFromNewMeal() { - let a = summation.firstIndex(where: { $0 == selection?.dish! }) - if a != nil, summation[a ?? 0] != "" { - summation.remove(at: a!) - } - } - - func addPresetToNewMeal() { - let test: String = selection?.dish ?? "dontAdd" - if test != "dontAdd" { - summation.append(test) - } - } - - func addNewPresetToWaitersNotepad(_ dish: String) { - summation.append(dish) - } - - func addToSummation() { - summation.append(selection?.dish ?? "") - } - - func waitersNotepad() -> String { - var filteredArray = summation.filter { !$0.isEmpty } - - if carbs == 0, protein == 0, fat == 0 { - filteredArray = [] - } - - guard filteredArray != [] else { - return "" - } - var carbs_: Decimal = 0.0 - var fat_: Decimal = 0.0 - var protein_: Decimal = 0.0 - var presetArray = [Presets]() - - coredataContext.performAndWait { - let requestPresets = Presets.fetchRequest() as NSFetchRequest - try? presetArray = coredataContext.fetch(requestPresets) - } - var waitersNotepad = [String]() - var stringValue = "" - - for each in filteredArray { - let countedSet = NSCountedSet(array: filteredArray) - let count = countedSet.count(for: each) - if each != stringValue { - waitersNotepad.append("\(count) \(each)") - } - stringValue = each - - for sel in presetArray { - if sel.dish == each { - carbs_ += (sel.carbs)! as Decimal - fat_ += (sel.fat)! as Decimal - protein_ += (sel.protein)! as Decimal - break - } - } - } - let extracarbs = carbs - carbs_ - let extraFat = fat - fat_ - let extraProtein = protein - protein_ - var addedString = "" - - if extracarbs > 0, filteredArray.isNotEmpty { - addedString += "Additional carbs: \(extracarbs) " - } else if extracarbs < 0 { addedString += "Removed carbs: \(extracarbs) " } - - if extraFat > 0, filteredArray.isNotEmpty { - addedString += "Additional fat: \(extraFat) " - } else if extraFat < 0 { addedString += "Removed fat: \(extraFat) " } - - if extraProtein > 0, filteredArray.isNotEmpty { - addedString += "Additional protein: \(extraProtein) " - } else if extraProtein < 0 { addedString += "Removed protein: \(extraProtein) " } - - if addedString != "" { - waitersNotepad.append(addedString) - } - var waitersNotepadString = "" - - if waitersNotepad.count == 1 { - waitersNotepadString = waitersNotepad[0] - } else if waitersNotepad.count > 1 { - for each in waitersNotepad { - if each != waitersNotepad.last { - waitersNotepadString += " " + each + "," - } else { waitersNotepadString += " " + each } - } - } - return waitersNotepadString - } - - func saveButtonText() -> String { - if carbs > maxCarbs { - return "\(NSLocalizedString("Max Carbs of", comment: "")) \(maxCarbs) \(NSLocalizedString("g", comment: "")) \(NSLocalizedString("exceeded", comment: ""))" - } else if fat > maxFat { - return "\(NSLocalizedString("Max Fat of", comment: "")) \(maxFat) \(NSLocalizedString("g", comment: "")) \(NSLocalizedString("exceeded", comment: ""))" - } else if protein > maxProtein { - return "\(NSLocalizedString("Max Protein of", comment: "")) \(maxProtein) \(NSLocalizedString("g", comment: "")) \(NSLocalizedString("exceeded", comment: ""))" - } else { - return NSLocalizedString("Save and continue", comment: "") - } - } - } -} diff --git a/Trio/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift b/Trio/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift deleted file mode 100644 index aa772c6d75..0000000000 --- a/Trio/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift +++ /dev/null @@ -1,307 +0,0 @@ -import CoreData -import SwiftUI -import Swinject - -extension AddCarbs { - struct RootView: BaseView { - let resolver: Resolver - @StateObject var state = StateModel() - @State var dish: String = "" - @State var isPromtPresented = false - @State var noteSaved = false - @State var mealSaved = false - @State private var showAlert = false - @FocusState private var isFocused: Bool - - @FetchRequest( - entity: Presets.entity(), - sortDescriptors: [NSSortDescriptor(key: "dish", ascending: true)] - ) var carbPresets: FetchedResults - - @Environment(\.managedObjectContext) var moc - - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - return formatter - } - - var body: some View { - Form { - if let carbsReq = state.carbsRequired { - Section { - HStack { - Text("Carbs required") - Spacer() - Text(formatter.string(from: carbsReq as NSNumber)! + " g") - } - } - } - Section { - HStack { - Text("Carbs").fontWeight(.semibold) - Spacer() - TextFieldWithToolBar( - text: $state.carbs, - placeholder: "0", - shouldBecomeFirstResponder: true, - numberFormatter: formatter - ) - Text(state.carbs > state.maxCarbs ? "⚠️" : "g").foregroundColor(.secondary) - .accessibilityLabel(Text( - state.carbs > state.maxCarbs - ? String(localized: "grams, over the maximum", comment: "Accessibility: value exceeds limit") - : String(localized: "grams", comment: "Gram unit, spoken") - )) - }.padding(.vertical) - - if state.useFPUconversion { - proteinAndFat() - } - VStack { - HStack { - Text("Note").foregroundColor(.secondary) - TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25) - if isFocused { - Button { isFocused = false } label: { Image(systemName: "keyboard.chevron.compact.down") } - .controlSize(.mini) - .accessibilityLabel(Text("Dismiss keyboard")) - } - }.focused($isFocused) - - HStack { - Spacer() - Text("\(state.note.count) / 25") - .foregroundStyle(.secondary) - } - } - HStack { - Button { - state.useFPUconversion.toggle() - } - label: { - Text( - state - .useFPUconversion ? NSLocalizedString("Hide Fat & Protein", comment: "") : - NSLocalizedString("Fat & Protein", comment: "") - ) } - .controlSize(.mini) - .buttonStyle(BorderlessButtonStyle()) - Button { - isPromtPresented = true - } - label: { Text("Save as Preset") } - .frame(maxWidth: .infinity, alignment: .trailing) - .controlSize(.mini) - .buttonStyle(BorderlessButtonStyle()) - .foregroundColor( - (state.carbs <= 0 && state.fat <= 0 && state.protein <= 0) || - ( - (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) == state - .carbs && (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) == state - .fat && (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) == - state - .protein - ) ? .secondary : .orange - ) - .disabled( - (state.carbs <= 0 && state.fat <= 0 && state.protein <= 0) || - ( - (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) == state - .carbs && (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) == state - .fat && (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) == state - .protein - ) - ) - } - .popover(isPresented: $isPromtPresented) { - presetPopover - } - } - - if state.useFPUconversion { - Section { - mealPresets - } - } - - Section { - DatePicker("Date", selection: $state.date) - } - - Section { - Button { - mealSaved = true - state.add() - } - label: { Text(state.saveButtonText()).font(.title3) } - .disabled( - mealSaved - || state.carbs > state.maxCarbs - || state.fat > state.maxFat - || state.protein > state.maxProtein - || (state.carbs <= 0 && state.fat <= 0 && state.protein <= 0) - ) - .foregroundStyle( - mealSaved || (state.carbs <= 0 && state.fat <= 0 && state.protein <= 0) ? .gray : - state.carbs > state.maxCarbs || state.fat > state.maxFat || state.protein > state - .maxProtein ? .red : .blue - ) - .frame(maxWidth: .infinity, alignment: .center) - } footer: { Text(state.waitersNotepad().description) } - - if !state.useFPUconversion { - Section { - mealPresets - } - } - } - .onAppear(perform: configureView) - .navigationBarItems(leading: Button("Close", action: state.hideModal)) - } - - var presetPopover: some View { - Form { - Section { - TextField("Name Of Dish", text: $dish) - Button { - noteSaved = true - if dish != "", noteSaved { - let preset = Presets(context: moc) - preset.dish = dish - preset.fat = state.fat as NSDecimalNumber - preset.protein = state.protein as NSDecimalNumber - preset.carbs = state.carbs as NSDecimalNumber - try? moc.save() - state.addNewPresetToWaitersNotepad(dish) - noteSaved = false - isPromtPresented = false - } - } - label: { Text("Save") } - Button { - dish = "" - noteSaved = false - isPromtPresented = false } - label: { Text("Cancel") } - } header: { Text("Enter Meal Preset Name") } - } - } - - var mealPresets: some View { - Section { - VStack { - Picker("Meal Presets", selection: $state.selection) { - Text("Empty").tag(nil as Presets?) - ForEach(carbPresets, id: \.self) { (preset: Presets) in - Text(preset.dish ?? "").tag(preset as Presets?) - } - } - .pickerStyle(.automatic) - ._onBindingChange($state.selection) { _ in - state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal - state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal - state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal - state.addToSummation() - } - } - HStack { - Button("Delete Preset") { - showAlert.toggle() - } - .disabled(state.selection == nil) - .accentColor(.orange) - .buttonStyle(BorderlessButtonStyle()) - .alert( - "Delete preset '\(state.selection?.dish ?? "")'?", - isPresented: $showAlert, - actions: { - Button("No", role: .cancel) {} - Button("Yes", role: .destructive) { - state.deletePreset() - - state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal - state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal - state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal - - state.addPresetToNewMeal() - } - } - ) - Button { - if state.carbs != 0, - (state.carbs - (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0 - { - state.carbs -= (((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal) - } else { state.carbs = 0 } - - if state.fat != 0, - (state.fat - (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0 - { - state.fat -= (((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal) - } else { state.fat = 0 } - - if state.protein != 0, - (state.protein - (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) as Decimal) >= 0 - { - state.protein -= (((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal) - } else { state.protein = 0 } - - state.removePresetFromNewMeal() - if state.carbs == 0, state.fat == 0, state.protein == 0 { state.summation = [] } - } - label: { Text("[ -1 ]") } - .accessibilityLabel(Text("Remove one serving")) - .disabled( - state - .selection == nil || - ( - !state.summation.contains(state.selection?.dish ?? "") && (state.selection?.dish ?? "") != "" - ) - ) - .buttonStyle(BorderlessButtonStyle()) - .frame(maxWidth: .infinity, alignment: .trailing) - .accentColor(.minus) - Button { - state.carbs += ((state.selection?.carbs ?? 0) as NSDecimalNumber) as Decimal - state.fat += ((state.selection?.fat ?? 0) as NSDecimalNumber) as Decimal - state.protein += ((state.selection?.protein ?? 0) as NSDecimalNumber) as Decimal - - state.addPresetToNewMeal() - } - label: { Text("[ +1 ]") } - .accessibilityLabel(Text("Add one serving")) - .disabled(state.selection == nil) - .buttonStyle(BorderlessButtonStyle()) - .accentColor(.blue) - } - } - } - - @ViewBuilder private func proteinAndFat() -> some View { - HStack { - Text("Fat").foregroundColor(.orange) // .fontWeight(.thin) - Spacer() - TextFieldWithToolBar(text: $state.fat, placeholder: "0", numberFormatter: formatter) - Text(state.fat > state.maxFat ? "⚠️" : "g").foregroundColor(.secondary) - .accessibilityLabel(Text( - state.fat > state.maxFat - ? String(localized: "grams, over the maximum", comment: "Accessibility: value exceeds limit") - : String(localized: "grams", comment: "Gram unit, spoken") - )) - } - HStack { - Text("Protein").foregroundColor(.red) // .fontWeight(.thin) - Spacer() - TextFieldWithToolBar(text: $state.protein, placeholder: "0", numberFormatter: formatter) - Text(state.protein > state.maxProtein ? "⚠️" : "g").foregroundColor(.secondary) - .accessibilityLabel(Text( - state.protein > state.maxProtein - ? String(localized: "grams, over the maximum", comment: "Accessibility: value exceeds limit") - : String(localized: "grams", comment: "Gram unit, spoken") - )) - } - } - } -} diff --git a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index 8b0e8b5d75..708b3fd372 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -37,17 +37,6 @@ extension Adjustments { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - func formattedGlucose(glucose: Decimal) -> String { - let formattedValue: String - if state.units == .mgdL { - formattedValue = Formatter.glucoseFormatter(for: state.units) - .string(from: glucose as NSDecimalNumber) ?? "\(glucose)" - } else { - formattedValue = glucose.formattedAsMmolL - } - return "\(formattedValue) \(state.units.rawValue)" - } - var body: some View { tempTargetDeleteConfirmation(overrideDeleteConfirmation(mainContent)) } diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift index 8661594a22..218b6aca29 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift @@ -31,6 +31,8 @@ struct AddOverrideForm: View { saveButton } .listSectionSpacing(10) + // iOS's default on iPhone; iPhone Duo defaults to 0, which put the first row under the sheet's bar + .contentMargins(.top, 35, for: .scrollContent) .padding(.top, 30) .ignoresSafeArea(edges: .top) .scrollContentBackground(.hidden) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 508831d705..7646aad72f 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -110,11 +110,12 @@ extension Adjustments.RootView { var stickyStopOverrideButton: some View { ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Button(action: { showCancelOverrideConfirmDialog = true @@ -123,7 +124,8 @@ extension Adjustments.RootView { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .disabled(!state.isOverrideEnabled) .background(!state.isOverrideEnabled ? Color(.systemGray4) : Color(.systemRed)) .tint(.white) @@ -148,7 +150,7 @@ extension Adjustments.RootView { let target: String = { guard let targetValue = preset.target, targetValue != 0 else { return "" } - return state.units == .mgdL ? targetValue.description : targetValue.decimalValue.formattedAsMmolL + return targetValue.decimalValue.formatted(for: state.units) }() let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)" diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index ca8a2e4993..340e9f1aca 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -117,6 +117,8 @@ struct EditOverrideForm: View { saveButton } .listSectionSpacing(10) + // iOS's default on iPhone; iPhone Duo defaults to 0, which put the first row under the sheet's bar + .contentMargins(.top, 35, for: .scrollContent) .padding(.top, 30) .ignoresSafeArea(edges: .top) .scrollContentBackground(.hidden) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/OverrideHelpView.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/OverrideHelpView.swift index eb62b37f17..1c5cba1363 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/OverrideHelpView.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/OverrideHelpView.swift @@ -36,14 +36,16 @@ struct OverrideHelpView: View { }.listRowBackground(Color.gray.opacity(0.1)) } .navigationBarTitle("Help", displayMode: .inline) + .padding(.horizontal) Button { state.isHelpSheetPresented.toggle() } label: { Text("Got it!").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center) } .buttonStyle(.bordered) .padding(.top) + .padding(.horizontal) } - .padding() + .padding(.vertical) .scrollContentBackground(.hidden) .presentationDetents( [.fraction(0.9), .large], diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index a71b5ed764..d51b6bfb07 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -34,6 +34,8 @@ struct AddTempTargetForm: View { saveButton } .listSectionSpacing(10) + // iOS's default on iPhone; iPhone Duo defaults to 0, which put the first row under the sheet's bar + .contentMargins(.top, 35, for: .scrollContent) .padding(.top, 30) .ignoresSafeArea(edges: .top) .scrollContentBackground(.hidden) @@ -350,16 +352,6 @@ struct AddTempTargetForm: View { return Formatter.integerFormatter.string(from: percentageNumber) ?? "\(value)" } - private func formattedGlucose(glucose: Decimal) -> String { - let formattedValue: String - if state.units == .mgdL { - formattedValue = Formatter.glucoseFormatter(for: state.units).string(from: glucose as NSDecimalNumber) ?? "\(glucose)" - } else { - formattedValue = glucose.formattedAsMmolL - } - return "\(formattedValue) \(state.units.rawValue)" - } - private func roundTargetToStep(_ target: Decimal, _ step: Decimal) -> Decimal { // Convert target and step to NSDecimalNumber guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?, diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift index 78d602e467..cc0d228fcc 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -119,11 +119,12 @@ extension Adjustments.RootView { var stickyStopTempTargetButton: some View { ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Button(action: { showCancelTempTargetConfirmDialog = true @@ -132,7 +133,8 @@ extension Adjustments.RootView { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .disabled(!state.isTempTargetEnabled) .background(!state.isTempTargetEnabled ? Color(.systemGray4) : Color(.systemRed)) .tint(.white) @@ -174,7 +176,7 @@ extension Adjustments.RootView { } } HStack(spacing: 2) { - Text(formattedGlucose(glucose: target as Decimal)) + Text((target as Decimal).formatted(withUnits: state.units)) .foregroundColor(.secondary) .font(.caption) Text("for") @@ -214,7 +216,7 @@ extension Adjustments.RootView { .accessibilityElement(children: .ignore) .accessibilityLabel(Text(tempTarget.name ?? "")) .accessibilityValue(Text( - formattedGlucose(glucose: target as Decimal) + " " + (target as Decimal).formatted(withUnits: state.units) + " " + String(localized: "for", comment: "duration connector") + " " + (Formatter.integerFormatter.string(from: (tempTarget.duration ?? 0) as NSNumber) ?? "0") + " " + String(localized: "min", comment: "minutes abbreviation") diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift index e468e69f23..347460b768 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -63,6 +63,8 @@ struct EditTempTargetForm: View { saveButton } .listSectionSpacing(10) + // iOS's default on iPhone; iPhone Duo defaults to 0, which put the first row under the sheet's bar + .contentMargins(.top, 35, for: .scrollContent) .padding(.top, 30) .ignoresSafeArea(edges: .top) .scrollContentBackground(.hidden) @@ -430,30 +432,8 @@ struct EditTempTargetForm: View { return formatter } - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if state.units == .mmolL { - formatter.maximumFractionDigits = 1 - } else { - formatter.maximumFractionDigits = 0 - } - formatter.roundingMode = .halfUp - return formatter - } - private func formattedPercentage(_ value: Double) -> String { let percentageNumber = NSNumber(value: value) return formatter.string(from: percentageNumber) ?? "\(value)" } - - private func formattedGlucose(glucose: Decimal) -> String { - let formattedValue: String - if state.units == .mgdL { - formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)" - } else { - formattedValue = glucose.formattedAsMmolL - } - return "\(formattedValue) \(state.units.rawValue)" - } } diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/TempTargetHelpView.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/TempTargetHelpView.swift index 498020dbdd..d3df7059fc 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/TempTargetHelpView.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/TempTargetHelpView.swift @@ -48,13 +48,15 @@ struct TempTargetHelpView: View { .listRowBackground(Color.gray.opacity(0.1)) } .navigationBarTitle("Help", displayMode: .inline) + .padding(.horizontal) Button { state.isHelpSheetPresented.toggle() } label: { Text("Got it!").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center) } .buttonStyle(.bordered) .padding(.top) + .padding(.horizontal) } - .padding() + .padding(.vertical) .scrollContentBackground(.hidden) .presentationDetents( [.fraction(0.9), .large], diff --git a/Trio/Sources/Modules/Adjustments/View/ViewElements/TargetPicker.swift b/Trio/Sources/Modules/Adjustments/View/ViewElements/TargetPicker.swift index d913444369..3df09b791c 100644 --- a/Trio/Sources/Modules/Adjustments/View/ViewElements/TargetPicker.swift +++ b/Trio/Sources/Modules/Adjustments/View/ViewElements/TargetPicker.swift @@ -14,20 +14,16 @@ struct TargetPicker: View { HStack { Text(label) Spacer() - Text( - (units == .mgdL ? selection.description : selection.formattedAsMmolL) + " " + units.rawValue - ) - .foregroundColor(!displayPickerTarget ? .primary : .accentColor) - .onTapGesture { - displayPickerTarget = toggleScrollWheel(displayPickerTarget) - } + Text(selection.formatted(withUnits: units)) + .foregroundColor(!displayPickerTarget ? .primary : .accentColor) + .onTapGesture { + displayPickerTarget = toggleScrollWheel(displayPickerTarget) + } } .contentShape(Rectangle()) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(label)) - .accessibilityValue(Text( - (units == .mgdL ? selection.description : selection.formattedAsMmolL) + " " + units.spokenValue - )) + .accessibilityValue(Text(selection.formatted(for: units) + " " + units.spokenValue)) .accessibilityHint(Text( displayPickerTarget ? String(localized: "Closes the value picker", comment: "Accessibility hint") @@ -42,8 +38,7 @@ struct TargetPicker: View { // Radio buttons for step iteration let stepChoices: [Decimal] = units == .mgdL ? [1, 5] : [1, 9] ForEach(stepChoices, id: \.self) { step in - let label = (units == .mgdL ? step.description : step.formattedAsMmolL) + " " + - units.rawValue + let label = step.formatted(withUnits: units) RadioButton( isSelected: targetStep == step, label: label @@ -67,7 +62,7 @@ struct TargetPicker: View { } ), label: Text("")) { ForEach(options, id: \.self) { option in - Text((units == .mgdL ? option.description : option.formattedAsMmolL) + " " + units.rawValue) + Text(option.formatted(withUnits: units)) .tag(option) } } diff --git a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift index ad4a7f7faf..e55b27a8da 100644 --- a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift +++ b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift @@ -86,17 +86,10 @@ extension AutosensSettings { HStack { Text("Calculated Sensitivity") Spacer() - if state.units == .mgdL { - Text( - !state.scope.preferences - .useNewFormula ? newISF!.description : (dynamicISF ?? 0).description - ) - } else { - Text(( - !state.scope.preferences - .useNewFormula ? newISF!.formattedAsMmolL : dynamicISF?.decimalValue.formattedAsMmolL - ) ?? "0") - } + Text( + (!state.scope.preferences.useNewFormula ? newISF! : dynamicISF?.decimalValue ?? 0) + .formattedISF(for: state.units) + ) Text(state.units.rawValue + "/U").foregroundColor(.secondary) } diff --git a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigDataFlow.swift b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigDataFlow.swift deleted file mode 100644 index 4bc028c047..0000000000 --- a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigDataFlow.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Combine - -enum AutotuneConfig { - enum Config {} -} - -protocol AutotuneConfigProvider: Provider { - var autotune: Autotune? { get } - func deleteAutotune() -} diff --git a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigProvider.swift b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigProvider.swift deleted file mode 100644 index 64f4cf6dd0..0000000000 --- a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigProvider.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Combine - -extension AutotuneConfig { - final class Provider: BaseProvider, AutotuneConfigProvider { - @Injected() private var apsManager: APSManager! - - var autotune: Autotune? { - storage.retrieve(OpenAPS.Settings.autotune, as: Autotune.self) - } - - func deleteAutotune() { - storage.remove(OpenAPS.Settings.autotune) - } - } -} diff --git a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift deleted file mode 100644 index 183f641e7f..0000000000 --- a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Combine -import LoopKit -import SwiftUI - -extension AutotuneConfig { - final class StateModel: BaseStateModel { - @Injected() var apsManager: APSManager! - @Injected() private var storage: FileStorage! - @Published var useAutotune = false - @Published var onlyAutotuneBasals = false - @Published var autotune: Autotune? - private(set) var units: GlucoseUnits = .mgdL - @Published var publishedDate = Date() - @Persisted(key: "lastAutotuneDate") private var lastAutotuneDate = Date() { - didSet { - DispatchQueue.main.async { - self.publishedDate = self.lastAutotuneDate - } - } - } - - override func subscribe() { - autotune = provider.autotune - units = settingsManager.settings.units - useAutotune = settingsManager.settings.useAutotune - publishedDate = lastAutotuneDate - subscribeSetting(\.onlyAutotuneBasals, on: $onlyAutotuneBasals) { onlyAutotuneBasals = $0 } - - $useAutotune - .removeDuplicates() - .flatMap { [weak self] use -> AnyPublisher in - guard let self = self else { - return Just(false).eraseToAnyPublisher() - } - self.settingsManager.settings.useAutotune = use - return Future { promise in - Task.init(priority: .background) { - do { - _ = try await self.apsManager.makeProfiles() - promise(.success(true)) - - } catch { - warning(.settings, "Rebuilding profiles after Autotune change failed", error: error) - promise(.success(false)) - } - } - } - .eraseToAnyPublisher() - } - .cancellable() - .store(in: &lifetime) - } - - func run() { - Task { - do { - if let result = await self.apsManager.autotune() { - autotune = result - _ = try await self.apsManager.makeProfiles() - lastAutotuneDate = Date() - } - } catch { - warning(.settings, "Running Autotune failed", error: error) - } - } - } - - func delete() async { - provider.deleteAutotune() - autotune = nil - do { - _ = try await apsManager.makeProfiles() - } catch { - warning(.settings, "Rebuilding profiles after deleting Autotune failed", error: error) - return - } - } - - func replace() { - if let autotunedBasals = autotune { - let basals = autotunedBasals.basalProfile - .map { basal -> BasalProfileEntry in - BasalProfileEntry( - start: String(basal.start.prefix(5)), - minutes: basal.minutes, - rate: basal.rate - ) - } - guard let pump = apsManager.pumpManager else { - scope.basalProfile = basals - info(.settings, "Basal profile replaced with Autotune basals") - return - } - let syncValues = basals.map { - RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) - } - pump.syncBasalRateSchedule(items: syncValues) { result in - switch result { - case .success: - self.scope.basalProfile = basals - info(.settings, "Basal profile replaced with Autotune basals and saved to pump") - case let .failure(syncError): - error(.settings, "Saving Autotune basals to pump failed", error: syncError) - } - } - } - } - } -} - -extension AutotuneConfig.StateModel: SettingsObserver { - func settingsDidChange(_: TrioSettings) { - units = settingsManager.settings.units - } -} diff --git a/Trio/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift b/Trio/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift deleted file mode 100644 index 08c2a03b05..0000000000 --- a/Trio/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift +++ /dev/null @@ -1,198 +0,0 @@ -import SwiftUI -import Swinject - -extension AutotuneConfig { - struct RootView: BaseView { - let resolver: Resolver - @StateObject var state = StateModel() - - @State private var shouldDisplayHint: Bool = false - @State var hintDetent = PresentationDetent.large - @State var selectedVerboseHint: AnyView? - @State var hintLabel: String? - @State private var decimalPlaceholder: Decimal = 0.0 - @State private var booleanPlaceholder: Bool = false - - @State var replaceAlert = false - - @Environment(\.colorScheme) var colorScheme - @Environment(AppState.self) var appState - - private var isfFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter - } - - private var rateFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter - } - - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter - } - - var body: some View { - Form { - SettingInputSection( - decimalValue: $decimalPlaceholder, - booleanValue: $state.useAutotune, - shouldDisplayHint: $shouldDisplayHint, - selectedVerboseHint: Binding( - get: { selectedVerboseHint }, - set: { - selectedVerboseHint = $0.map { AnyView($0) } - hintLabel = "Use Autotune" - } - ), - units: state.units, - type: .boolean, - label: "Use Autotune", - miniHint: "It is not advised to use Autotune with Trio.", - verboseHint: - VStack(alignment: .leading, spacing: 10) { - Text("It is not advised to use Autotune with Trio").bold() - Text("Autotune is not designed to work with Trio. It is best to keep Autotune off and do not use it.") - - }, - headerText: "Data-driven Adjustments" - ) - - if state.useAutotune { - SettingInputSection( - decimalValue: $decimalPlaceholder, - booleanValue: $state.onlyAutotuneBasals, - shouldDisplayHint: $shouldDisplayHint, - selectedVerboseHint: Binding( - get: { selectedVerboseHint }, - set: { - selectedVerboseHint = $0.map { AnyView($0) } - hintLabel = "Only Autotune Basal Insulin" - } - ), - units: state.units, - type: .boolean, - label: "Only Autotune Basal Insulin", - miniHint: "Restricts Autotune adjustments to only basal settings.", - verboseHint: Text("Restricts Autotune adjustments to only basal settings.") - ) - } - - Section( - header: HStack { - Text("Last run") - Spacer() - Text(dateFormatter.string(from: state.publishedDate)) - }, - content: { - Button { - state.run() - } label: { - Text("Run now") - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color(.systemBlue)) - .tint(.white) - } - ) - - if let autotune = state.autotune { - if !state.onlyAutotuneBasals { - Section { - HStack { - Text("Carb ratio") - Spacer() - Text(isfFormatter.string(from: autotune.carbRatio as NSNumber) ?? "0") - Text("g/U").foregroundColor(.secondary) - } - HStack { - Text("Sensitivity") - Spacer() - if state.units == .mmolL { - Text(isfFormatter.string(from: autotune.sensitivity.asMmolL as NSNumber) ?? "0") - } else { - Text(isfFormatter.string(from: autotune.sensitivity as NSNumber) ?? "0") - } - Text(state.units.rawValue + "/U").foregroundColor(.secondary) - } - } - .listRowBackground(Color.chart) - } - - Section(header: Text("Basal profile")) { - ForEach(0 ..< autotune.basalProfile.count, id: \.self) { index in - HStack { - Text(autotune.basalProfile[index].start).foregroundColor(.secondary) - Spacer() - Text(rateFormatter.string(from: autotune.basalProfile[index].rate as NSNumber) ?? "0") - Text("U/hr").foregroundColor(.secondary) - } - } - HStack { - Text("Total") - .bold() - .foregroundColor(.primary) - Spacer() - Text(rateFormatter.string(from: autotune.basalProfile.reduce(0) { $0 + $1.rate } as NSNumber) ?? "0") - .foregroundColor(.primary) + - Text(" U/day") - .foregroundColor(.secondary) - } - } - .listRowBackground(Color.chart) - - Section { - Button { - Task { - await state.delete() - } - } label: { - Text("Delete Autotune Data") - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color(.loopRed)) - .tint(.white) - } - - Section { - Button { - replaceAlert = true - } label: { - Text("Save as Normal Basal Rates") - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color(.systemGray4)) - .tint(.white) - } - } - } - .sheet(isPresented: $shouldDisplayHint) { - SettingInputHintView( - hintDetent: $hintDetent, - shouldDisplayHint: $shouldDisplayHint, - hintLabel: hintLabel ?? "", - hintText: selectedVerboseHint ?? AnyView(EmptyView()), - sheetTitle: "Help" - ) - } - .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) - .onAppear(perform: configureView) - .navigationTitle("Autotune") - .navigationBarTitleDisplayMode(.automatic) - .alert(Text("Are you sure?"), isPresented: $replaceAlert) { - Button("Yes", action: { - state.replace() - replaceAlert.toggle() - }) - Button("No", action: { replaceAlert.toggle() }) - } - } - } -} diff --git a/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift b/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift index e89b9c9305..5eedcb588d 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift @@ -98,11 +98,12 @@ extension BasalProfileEditor { let shouldDisableButton = state.syncInProgress || state.items.isEmpty || !state.hasChanges Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Group { HStack { @@ -117,10 +118,11 @@ extension BasalProfileEditor { } Text(state.syncInProgress ? "Saving..." : "Save") } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .disabled(shouldDisableButton) .background(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue)) .tint(.white) diff --git a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift index 8364eb69d7..d635bd9d5e 100644 --- a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift +++ b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift @@ -17,20 +17,6 @@ extension BolusCalculatorConfig { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var conversionFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - - return formatter - } - - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - } - var body: some View { List { SettingInputSection( @@ -175,10 +161,10 @@ extension BolusCalculatorConfig { verboseHint: VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "Triggers a confirmation dialog if you attempt to bolus when glucose is < \(state.units == .mgdL ? 54.description : 54.formattedAsMmolL) \(state.units.rawValue)." + "Triggers a confirmation dialog if you attempt to bolus when glucose is < \(54.formatted(for: state.units)) \(state.units.rawValue)." ) Text( - "Also triggered when the lowest forecasted glucose (minPredBG) is < \(state.units == .mgdL ? 54.description : 54.formattedAsMmolL) \(state.units.rawValue)." + "Also triggered when the lowest forecasted glucose (minPredBG) is < \(54.formatted(for: state.units)) \(state.units.rawValue)." ) Text( "Note: The forecast used for this warning does not include carbs or insulin that have been logged but not yet effective." diff --git a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift index bb2e17ae84..d61d530061 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift @@ -334,7 +334,7 @@ extension CGMSettings { Spacer() - Text(state.units == .mgdL ? centerValue.description : centerValue.formattedAsMmolL).bold() + Text(centerValue.formatted(for: state.units)).bold() Text(state.units.rawValue).foregroundStyle(Color.secondary) }.padding(.top) @@ -362,7 +362,7 @@ extension CGMSettings { Spacer() Text("±") - Text(state.units == .mgdL ? amplitude.description : amplitude.formattedAsMmolL).bold() + Text(amplitude.formatted(for: state.units)).bold() Text(state.units.rawValue).foregroundStyle(Color.secondary) }.padding(.top) @@ -375,7 +375,7 @@ extension CGMSettings { .padding(.vertical) Text( - "Range: \(state.units == .mgdL ? (centerValue - amplitude).description : (centerValue - amplitude).formattedAsMmolL)–\(state.units == .mgdL ? (centerValue + amplitude).description : (centerValue + amplitude).formattedAsMmolL) \(state.units.rawValue)" + "Range: \((centerValue - amplitude).formatted(for: state.units))–\((centerValue + amplitude).formatted(for: state.units)) \(state.units.rawValue)" ) .bold() .font(.footnote) @@ -426,7 +426,7 @@ extension CGMSettings { Text("±") - Text(state.units == .mgdL ? noiseAmplitude.description : noiseAmplitude.formattedAsMmolL).bold() + Text(noiseAmplitude.formatted(for: state.units)).bold() Text(state.units.rawValue).foregroundStyle(Color.secondary) }.padding(.top) @@ -492,7 +492,7 @@ extension CGMSettings { }) .frame(maxWidth: .infinity, alignment: .center) .tint(.white) - }.listRowBackground(Color.accentColor) + }.listRowBackground(Color(.systemBlue)) }.listSectionSpacing(sectionSpacing) } @@ -500,12 +500,13 @@ extension CGMSettings { var stickyDeleteButton: some View { ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 120) + .frame(height: 120) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) .padding(.bottom, -55) + .ignoresSafeArea(edges: .horizontal) Button(action: { shouldDisplayDeletionConfirmation.toggle() @@ -514,7 +515,8 @@ extension CGMSettings { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .background(Color(.systemRed)) .tint(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) diff --git a/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift index 41dbad2f52..0e6f9b51d9 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift @@ -13,23 +13,17 @@ extension CarbRatioEditor { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - return formatter - } - var saveButton: some View { ZStack { let shouldDisableButton = state.shouldDisplaySaving || state.items.isEmpty || !state.hasChanges Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Group { HStack { @@ -50,11 +44,11 @@ extension CarbRatioEditor { } Text(state.shouldDisplaySaving ? "Saving..." : "Save") } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .padding(10) } } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .disabled(shouldDisableButton) .background(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue)) .tint(.white) diff --git a/Trio/Sources/Modules/ContactImage/View/AddContactImageSheet.swift b/Trio/Sources/Modules/ContactImage/View/AddContactImageSheet.swift index dce6e8738b..c7a904b442 100644 --- a/Trio/Sources/Modules/ContactImage/View/AddContactImageSheet.swift +++ b/Trio/Sources/Modules/ContactImage/View/AddContactImageSheet.swift @@ -204,11 +204,12 @@ struct AddContactImageSheet: View { var stickySaveButton: some View { ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Button(action: { saveNewEntry() @@ -217,7 +218,7 @@ struct AddContactImageSheet: View { .frame(maxWidth: .infinity, alignment: .center) .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .background(Color(.systemBlue)) .tint(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) diff --git a/Trio/Sources/Modules/ContactImage/View/ContactImageDetailView.swift b/Trio/Sources/Modules/ContactImage/View/ContactImageDetailView.swift index fd92b2bbc4..9d1cb963f9 100644 --- a/Trio/Sources/Modules/ContactImage/View/ContactImageDetailView.swift +++ b/Trio/Sources/Modules/ContactImage/View/ContactImageDetailView.swift @@ -171,11 +171,12 @@ struct ContactImageDetailView: View { return ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Button(action: { saveChanges() @@ -184,7 +185,7 @@ struct ContactImageDetailView: View { .frame(maxWidth: .infinity, alignment: .center) .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .background(isUnchanged ? Color(.systemGray4) : Color(.systemBlue)) .disabled(isUnchanged) .tint(.white) diff --git a/Trio/Sources/Modules/ContactImage/View/ContactImageHelpView.swift b/Trio/Sources/Modules/ContactImage/View/ContactImageHelpView.swift index 2823da0e40..d53436319c 100644 --- a/Trio/Sources/Modules/ContactImage/View/ContactImageHelpView.swift +++ b/Trio/Sources/Modules/ContactImage/View/ContactImageHelpView.swift @@ -72,13 +72,15 @@ struct ContactImageHelpView: View { } .scrollContentBackground(.hidden) .navigationBarTitle("Help", displayMode: .inline) + .padding(.horizontal) Button { state.isHelpSheetPresented.toggle() } label: { Text("Got it!").bold().frame(maxWidth: .infinity, minHeight: 30, alignment: .center) } .buttonStyle(.bordered) .padding(.top) + .padding(.horizontal) } - .padding() + .padding(.vertical) .presentationDetents( [.fraction(0.9), .large], selection: helpSheetDetent diff --git a/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift b/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift index d5cab2f3fc..3bb42a4dc6 100644 --- a/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift +++ b/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift @@ -43,33 +43,9 @@ extension DynamicSettings { ) } - private var conversionFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - - return formatter - } - @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - } - - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if state.units == .mmolL { - formatter.maximumFractionDigits = 1 - } else { formatter.maximumFractionDigits = 0 } - formatter.roundingMode = .halfUp - return formatter - } - var body: some View { List { Section( diff --git a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift index 4f0fcfbcd3..03875821b3 100644 --- a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift +++ b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import HealthKit import LoopKit import LoopKitUI @@ -34,8 +33,8 @@ extension UnitsLimitsSettings { return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() } let limits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), - maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(settings.maxBolus)) + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), + maximumBolus: LoopQuantity(unit: .internationalUnit, doubleValue: Double(settings.maxBolus)) ) return Future { promise in self.processQueue.async { @@ -49,7 +48,7 @@ extension UnitsLimitsSettings { insulinActionCurve: settings.insulinActionCurve, maxBolus: Decimal( actual.maximumBolus? - .doubleValue(for: .internationalUnit()) ?? Double(settings.maxBolus) + .doubleValue(for: .internationalUnit) ?? Double(settings.maxBolus) ), maxBasal: Decimal( actual.maximumBasalRate? diff --git a/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift b/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift index 0e578a6043..d3506f0d05 100644 --- a/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift +++ b/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift @@ -194,14 +194,14 @@ extension UnitsLimitsSettings { VStack(alignment: .leading, spacing: 5) { Text("Example:").bold() Text( - "If your glucose target is \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue) and ratio = 0.5, ratioFloor = \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue). Setting Minimum Safety Threshold below \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) has no effect; setting it above raises the cutoff." + "If your glucose target is \(110.formatted(for: state.units)) \(state.units.rawValue) and ratio = 0.5, ratioFloor = \(75.formatted(for: state.units)) \(state.units.rawValue). Setting Minimum Safety Threshold below \(75.formatted(for: state.units)) has no effect; setting it above raises the cutoff." ) Text( - "\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) − 0.5 × (\(state.units == .mgdL ? "110" : 110.formattedAsMmolL) − \(state.units == .mgdL ? "40" : 40.formattedAsMmolL)) = \(state.units == .mgdL ? "75" : 75.formattedAsMmolL)" + "\(110.formatted(for: state.units)) − 0.5 × (\(110.formatted(for: state.units)) − \(40.formatted(for: state.units))) = \(75.formatted(for: state.units))" ) } Text( - "This setting is limited to values between \(state.units == .mgdL ? "60" : 60.formattedAsMmolL) - \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue)." + "This setting is limited to values between \(60.formatted(for: state.units)) - \(120.formatted(for: state.units)) \(state.units.rawValue)." ) Text( "Note: Basal may be resumed if there is negative IOB and glucose is rising faster than the forecast." diff --git a/Trio/Sources/Modules/History/HistoryDataFlow+Models.swift b/Trio/Sources/Modules/History/HistoryDataFlow+Models.swift index b29a3e806e..6003907fe6 100644 --- a/Trio/Sources/Modules/History/HistoryDataFlow+Models.swift +++ b/Trio/Sources/Modules/History/HistoryDataFlow+Models.swift @@ -17,20 +17,6 @@ extension History { let isSMB: Bool? let isExternal: Bool? - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter - } - - private var tempTargetFormater: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - return formatter - } - init( units: GlucoseUnits, type: DataType, @@ -69,57 +55,6 @@ extension History { hasher.combine(id) } - var amountText: String { - guard let amount = amount else { - return "" - } - - if amount == 0, duration == 0 { - return "Cancel temp" - } - - switch type { - case .carbs: - return numberFormatter - .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carbs") - case .fpus: - return numberFormatter - .string(from: amount as NSNumber)! + String(localized: " g", comment: "gram of carb equilvalents") - case .bolus: - var bolusText = " " - if isSMB ?? false {} - else if isExternal ?? false { - bolusText += String(localized: "External", comment: "External Insulin") - } else { - bolusText += String(localized: "Manual", comment: "Manual Bolus") - } - - return numberFormatter - .string(from: amount as NSNumber)! + String(localized: " U", comment: "Insulin unit") + bolusText - case .tempBasal: - return numberFormatter - .string(from: amount as NSNumber)! + String(localized: " U/hr", comment: "Unit insulin per hour") - case .tempTarget: - var converted = amount - if units == .mmolL { - converted = converted.asMmolL - } - - guard var secondAmount = secondAmount else { - return numberFormatter.string(from: converted as NSNumber)! + " \(units.rawValue)" - } - if units == .mmolL { - secondAmount = secondAmount.asMmolL - } - - return tempTargetFormater.string(from: converted as NSNumber)! + " - " + tempTargetFormater - .string(from: secondAmount as NSNumber)! + " \(units.rawValue)" - case .resume, - .suspend: - return type.name - } - } - var color: Color { switch type { case .carbs: @@ -136,13 +71,6 @@ extension History { return .loopGray } } - - var durationText: String? { - guard let duration = duration, duration > 0 else { - return nil - } - return numberFormatter.string(from: duration as NSNumber)! + " min" - } } class Glucose: Identifiable, Hashable, Equatable { diff --git a/Trio/Sources/Modules/History/HistoryDeletionTarget.swift b/Trio/Sources/Modules/History/HistoryDeletionTarget.swift index 2c3609991f..62676689d3 100644 --- a/Trio/Sources/Modules/History/HistoryDeletionTarget.swift +++ b/Trio/Sources/Modules/History/HistoryDeletionTarget.swift @@ -38,9 +38,7 @@ extension History { func message(units: GlucoseUnits) -> String { switch self { case let .glucose(glucose): - let glucoseToDisplay = units == .mgdL - ? glucose.glucose.description - : Int(glucose.glucose).formattedAsMmolL + let glucoseToDisplay = Decimal(glucose.glucose).formatted(for: units) return Formatter.dateFormatter.string(from: glucose.date ?? Date()) + ", " + glucoseToDisplay + " " + units.rawValue case let .insulin(pumpEvent): diff --git a/Trio/Sources/Modules/History/View/CarbEntryEditorView.swift b/Trio/Sources/Modules/History/View/CarbEntryEditorView.swift index d95d949821..65e9e0b64f 100644 --- a/Trio/Sources/Modules/History/View/CarbEntryEditorView.swift +++ b/Trio/Sources/Modules/History/View/CarbEntryEditorView.swift @@ -94,11 +94,12 @@ struct CarbEntryEditorView: View { var stickyButton: some View { ZStack { Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Button( action: { @@ -120,7 +121,8 @@ struct CarbEntryEditorView: View { .padding(10) } ) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .disabled(isButtonDisabled) .background(buttonBackgroundColor) .tint(.white) diff --git a/Trio/Sources/Modules/History/View/HistoryRootView.swift b/Trio/Sources/Modules/History/View/HistoryRootView.swift index 389c87bf5a..a60abeaeb3 100644 --- a/Trio/Sources/Modules/History/View/HistoryRootView.swift +++ b/Trio/Sources/Modules/History/View/HistoryRootView.swift @@ -813,9 +813,7 @@ extension History { if state.settingsManager.settings.smoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 { - let smoothedGlucoseForDisplay = state.units == .mgdL ? smoothedGlucose - .description : smoothedGlucose.decimalValue - .formattedAsMmolL + let smoothedGlucoseForDisplay = smoothedGlucose.decimalValue.formatted(for: state.units) ( Text("(") + @@ -1094,10 +1092,9 @@ extension History { } private func formatGlucose(_ value: Decimal, isManual: Bool) -> String { - let formatter = isManual ? manualGlucoseFormatter : Formatter.glucoseFormatter(for: state.units) + guard isManual else { return value.formatted(for: state.units) } let glucoseValue = state.units == .mmolL ? value.asMmolL : value - let formattedValue = formatter.string(from: glucoseValue as NSNumber) ?? "--" - return formattedValue + return manualGlucoseFormatter.string(from: glucoseValue as NSNumber) ?? "--" } } diff --git a/Trio/Sources/Modules/Home/HomeProvider.swift b/Trio/Sources/Modules/Home/HomeProvider.swift index 6a733fabaa..1ca59cc82e 100644 --- a/Trio/Sources/Modules/Home/HomeProvider.swift +++ b/Trio/Sources/Modules/Home/HomeProvider.swift @@ -26,7 +26,7 @@ extension Home { } func pumpReservoir() async -> Decimal? { - await storage.retrieveAsync(OpenAPS.Monitor.reservoir, as: Decimal.self) + apsManager.pumpManager?.currentReservoir } func getBasalProfile() async -> [BasalProfileEntry] { diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 4773c609ce..068b8ac119 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -1161,7 +1161,7 @@ extension Home.StateModel: } } - func pumpReservoirDidChange(_: Decimal) { + func pumpReservoirDidChange(_: Decimal?) { setupReservoir() displayPumpStatusHighlightMessage() displayPumpStatusBadge() diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift index 882e5142ac..24f3c123e4 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift @@ -76,26 +76,6 @@ struct ChartSelectionRow: View { @Environment(\.colorScheme) private var colorScheme - private var glucoseToDisplay: Decimal { - units == .mgdL ? Decimal(selectedGlucose.glucose) : Decimal(selectedGlucose.glucose).asMmolL - } - - /// mmol/L is written to one decimal even when it is a whole number — 8 reads as 8.0, the - /// way the rest of the app writes it — and both units go through a formatter so the - /// decimal separator follows the locale rather than `Decimal.description`'s hard dot. - private static let glucoseFormatter: (GlucoseUnits) -> NumberFormatter = { units in - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.locale = .current - formatter.minimumFractionDigits = units == .mgdL ? 0 : 1 - formatter.maximumFractionDigits = units == .mgdL ? 0 : 1 - return formatter - } - - private func glucoseString(_ value: Decimal) -> String { - Self.glucoseFormatter(units).string(from: value as NSDecimalNumber) ?? value.description - } - private var pointMarkColor: Color { selectionMarkColor( for: selectedGlucose, @@ -175,13 +155,13 @@ struct ChartSelectionRow: View { @ViewBuilder private var glucoseGroup: some View { // verbatim: brackets have nothing to translate, and Xcode would otherwise extract // them into the string catalog - let reading = Text(glucoseString(glucoseToDisplay)).foregroundStyle(pointMarkColor) + let reading = Text(Decimal(selectedGlucose.glucose).formatted(for: units)).foregroundStyle(pointMarkColor) let smoothed = smoothedToDisplay.map { ( Text(verbatim: "(") + Text(Image(systemName: "sparkles")) + Text(verbatim: " ") - + Text(glucoseString($0)) + + Text($0.formatted(for: units)) + Text(verbatim: ")") ).foregroundStyle(.secondary) } @@ -196,10 +176,10 @@ struct ChartSelectionRow: View { .shadow(color: colorScheme == .light ? Color.black.opacity(0.5) : Color.clear, radius: 1, x: 1, y: 1) } - /// The smoothed reading in display units, or nil with smoothing off or no smoothed value. + /// The smoothed reading in mg/dL, or nil with smoothing off or no smoothed value. private var smoothedToDisplay: Decimal? { guard isSmoothingEnabled, let smoothed = selectedGlucose.smoothedGlucose else { return nil } - return units == .mgdL ? smoothed.decimalValue : smoothed.decimalValue.asMmolL + return smoothed.decimalValue } /// One value plus its glyph. `customImage` takes a template asset (e.g. the COB "premeal" diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/PeakLabelsOverlay.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/PeakLabelsOverlay.swift index 4f12226ac3..913f62cd6f 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/PeakLabelsOverlay.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/PeakLabelsOverlay.swift @@ -231,11 +231,7 @@ struct PeakLabelsOverlay: View { } private func formattedGlucose(_ glucose: Int) -> String { - if units == .mgdL { - return "\(glucose)" - } else { - return glucose.formattedAsMmolL - } + glucose.formatted(for: units) } } diff --git a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift index 30baad86da..529625f26d 100644 --- a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift +++ b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift @@ -232,7 +232,7 @@ struct MainChartView: View { } let unitLabel = units.spokenValue func format(_ value: Int16) -> String { - units == .mgdL ? Decimal(value).description : Decimal(value).formattedAsMmolL + Decimal(value).formatted(for: units) } let latestString = format(latest.glucose) let values = sample.map(\.glucose) diff --git a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index 2a286ce37c..0ac7839c4a 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -28,21 +28,6 @@ struct CurrentGlucoseView: View { @Environment(\.colorScheme) var colorScheme - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if units == .mmolL { - formatter.maximumFractionDigits = 1 - formatter.minimumFractionDigits = 1 - formatter.roundingMode = .halfUp - } else { - formatter.maximumFractionDigits = 0 - } - formatter.positivePrefix = " +" - formatter.negativePrefix = " -" - return formatter - } - var body: some View { let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) @@ -177,14 +162,9 @@ struct CurrentGlucoseView: View { return "--" } - var lastGlucose = Decimal(glucose.last?.glucose ?? 0) - var secondLastGlucose = Decimal(glucose.first?.glucose ?? 0) - if units == .mmolL { - lastGlucose = lastGlucose.asMmolL - secondLastGlucose = secondLastGlucose.asMmolL - } - let delta = lastGlucose - secondLastGlucose - return deltaFormatter.string(from: delta as NSNumber) ?? "--" + let lastGlucose = Decimal(glucose.last?.glucose ?? 0) + let secondLastGlucose = Decimal(glucose.first?.glucose ?? 0) + return (lastGlucose - secondLastGlucose).formattedDelta(for: units) } /// Spoken trend from the CGM direction arrow (which is otherwise image-only). @@ -231,7 +211,7 @@ struct CurrentGlucoseView: View { } let valueString = glucoseValue == 400 ? String(localized: "high", comment: "Accessibility: glucose is at the top of the scale") - : (units == .mgdL ? Decimal(glucoseValue).description : Decimal(glucoseValue).formattedAsMmolL) + : Decimal(glucoseValue).formatted(for: units) var parts = [String(localized: "Glucose", comment: "Accessibility: glucose label") + " \(valueString) \(units.rawValue)"] parts.append(rangeDescription(for: glucoseValue)) if let trend = trendDescription { parts.append(trend) } @@ -250,9 +230,7 @@ struct CurrentGlucoseView: View { @ViewBuilder private func bobbleContent() -> some View { HStack { if let glucoseValue = glucose.last?.glucose, isReadingFresh { - let displayGlucose = units == .mgdL - ? Decimal(glucoseValue).description - : Decimal(glucoseValue).formattedAsMmolL + let displayGlucose = Decimal(glucoseValue).formatted(for: units) Text(glucoseValue == 400 ? "HIGH" : displayGlucose) .font(.system(size: 40, weight: .bold, design: .rounded)) .foregroundStyle(glucoseColor(for: glucoseValue)) diff --git a/Trio/Sources/Modules/Home/View/Header/PumpView.swift b/Trio/Sources/Modules/Home/View/Header/PumpView.swift index a23423f9a1..b2c08abf5a 100644 --- a/Trio/Sources/Modules/Home/View/Header/PumpView.swift +++ b/Trio/Sources/Modules/Home/View/Header/PumpView.swift @@ -13,12 +13,6 @@ struct PumpView: View { let NORMAL_PATCH_AGE = TimeInterval.hours(80) - private var batteryFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - return formatter - } - private var hourglassIcon: String { if activatedAtDate != nil { return "hourglass.badge.plus" } guard let expiration = expiresAtDate else { return "hourglass" } diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 5ecd92cc0b..11880b7123 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -8,15 +8,6 @@ extension Home.RootView { Formatter.bolusProgressFormatter(for: state.bolusIncrement) } - private var fetchedTargetFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if state.units == .mmolL { - formatter.maximumFractionDigits = 1 - } else { formatter.maximumFractionDigits = 0 } - return formatter - } - var overrideString: String? { guard let latestOverride = latestOverride.first else { return nil @@ -30,11 +21,9 @@ extension Home.RootView { let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %" let unit = state.units - var target = (latestOverride.target ?? 0) as Decimal - target = unit == .mmolL ? target.asMmolL : target + let target = (latestOverride.target ?? 0) as Decimal - var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit - .rawValue + var targetString = target == 0 ? "" : target.formatted(withUnits: unit) if tempTargetString != nil { targetString = "" } @@ -112,7 +101,7 @@ extension Home.RootView { ) var durationString = "" var percentageString = "" - var target = (latestTempTarget.target ?? 100) as Decimal + let target = (latestTempTarget.target ?? 100) as Decimal // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT) let effectiveHBT = TempTargetCalculations.computeEffectiveHBT( tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue, @@ -127,9 +116,7 @@ extension Home.RootView { percentageString = " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%" } - target = state.units == .mmolL ? target.asMmolL : target - let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + - state.units.rawValue + percentageString + let targetString = target == 0 ? "" : target.formatted(withUnits: state.units) + percentageString if newDuration >= 1 { durationString = @@ -469,14 +456,6 @@ extension Home.RootView { } } - private func averageGlucoseString(_ meanGlucose: Double?) -> String { - guard let mean = meanGlucose else { return "--" } - if state.units == .mmolL { - return Decimal(mean).asMmolL.formatted(.number.precision(.fractionLength(1))) + " " + GlucoseUnits.mmolL.rawValue - } - return "\(Int(mean.rounded())) " + GlucoseUnits.mgdL.rawValue - } - private func gmiString(_ meanGlucose: Double?) -> String { guard let mean = meanGlucose else { return "--" } let gmiPercentage = 3.31 + 0.02392 * mean @@ -493,7 +472,7 @@ extension Home.RootView { let range = state.homeStatsPanelRange let stats = state.statsPanelStats let hasData = stats.hasData - let averageString = averageGlucoseString(stats.meanGlucose) + let averageString = stats.meanGlucose?.formatted(withUnits: state.units) ?? "--" let gmiValueString = gmiString(stats.meanGlucose) let tirString = hasData ? stats.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift b/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift index 972403f616..37660c10a3 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift @@ -113,8 +113,7 @@ extension Home.RootView { .foregroundColor(.loopGreen) let isfValue = state.enactedAndNonEnactedDeterminations.first?.insulinSensitivity ?? NSDecimalNumber.zero let isfValueDecimal = isfValue.decimalValue - let convertedISF = state.units == .mgdL ? isfValueDecimal.description : isfValueDecimal - .formattedAsMmolL + let convertedISF = isfValueDecimal.formattedISF(for: state.units) Text(convertedISF) .font(.system(size: 16, weight: .bold, design: .rounded)) // Text("\(state.units.rawValue)/U") @@ -129,13 +128,13 @@ extension Home.RootView { "⇢" ).font(.system(size: 18, weight: .bold)) .foregroundColor(.secondary) - Text(state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL) + Text(eventualGlucose.formatted(for: state.units)) .font(.system(size: 16, weight: .bold, design: .rounded)) } .accessibilityElement(children: .ignore) .accessibilityLabel(Text("Eventual glucose")) .accessibilityValue(Text( - (state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL) + eventualGlucose.formatted(for: state.units) + " " + state.units.spokenValue )) } else { diff --git a/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift index abd1d4a225..e248928886 100644 --- a/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift +++ b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift @@ -25,11 +25,12 @@ extension ISFEditor { let shouldDisableButton = state.items.isEmpty || !state.hasChanges Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Group { HStack { @@ -54,11 +55,11 @@ extension ISFEditor { } Text(state.shouldDisplaySaving ? "Saving..." : "Save") } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .padding(10) } } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .disabled(shouldDisableButton) .background(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue)) .tint(.white) diff --git a/Trio/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift b/Trio/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift deleted file mode 100644 index 35be3167f9..0000000000 --- a/Trio/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import LibreTransmitter -import SwiftUI -import Swinject - -extension LibreConfig { - struct RootView: BaseView { - let resolver: Resolver - @StateObject var state = StateModel() - - @Environment(\.colorScheme) var colorScheme - var color: LinearGradient { - colorScheme == .dark ? LinearGradient( - gradient: Gradient(colors: [ - Color.bgDarkBlue, - Color.bgDarkerDarkBlue - ]), - startPoint: .top, - endPoint: .bottom - ) - : - LinearGradient( - gradient: Gradient(colors: [Color.gray.opacity(0.1)]), - startPoint: .top, - endPoint: .bottom - ) - } - - var body: some View { - Group { - if state.configured, let manager = state.source.manager { - LibreTransmitterSettingsView( - manager: manager, - glucoseUnit: state.unit - ) { - self.state.source.manager = nil - self.state.configured = false - } completion: { - state.hideModal() - } - } else { - LibreTransmitterSetupView { manager in - self.state.source.manager = manager - self.state.configured = true - } completion: { - state.hideModal() - } - } - } - .scrollContentBackground(.hidden).background(color) - .navigationBarTitle("") - .navigationBarHidden(true) - .onAppear(perform: configureView) - } - } -} diff --git a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift index 67a49969ff..162f0da015 100644 --- a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift +++ b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySimpleWidgetConfiguration.swift @@ -99,11 +99,11 @@ struct LiveActivitySimpleWidgetConfiguration: BaseView { } private var previewGlucoseText: String { - LiveActivityAttributes.ContentState.formatGlucose(Self.previewGlucose, units: state.units, forceSign: false) + Self.previewGlucose.formatted(for: state.units) } private var previewDeltaText: String { - LiveActivityAttributes.ContentState.formatGlucose(Self.previewDelta, units: state.units, forceSign: true) + Decimal(Self.previewDelta).formattedDelta(for: state.units) } private var previewTimeText: String { diff --git a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift index a88d768081..bc0a0e8b26 100644 --- a/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift +++ b/Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift @@ -645,7 +645,7 @@ struct DummyChartGroupBoxStyle: GroupBoxStyle { .padding() .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .background(Color.chart, in: RoundedRectangle(cornerRadius: 12)) - .frame(width: UIScreen.main.bounds.width * 0.9) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } } } diff --git a/Trio/Sources/Modules/Main/View/MainLoadingView.swift b/Trio/Sources/Modules/Main/View/MainLoadingView.swift index 8d78a861bb..94270ba25e 100644 --- a/Trio/Sources/Modules/Main/View/MainLoadingView.swift +++ b/Trio/Sources/Modules/Main/View/MainLoadingView.swift @@ -68,7 +68,8 @@ extension Main { Image(systemName: "arrow.clockwise") Text("Retry") } - .frame(width: UIScreen.main.bounds.width - 60, height: 50) + .containerRelativeFrame(.horizontal) { width, _ in width - 60 } + .frame(height: 50) .font(.title3).bold() .background( Capsule() diff --git a/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift b/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift index ce938e30ae..ccaf17fb26 100644 --- a/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift +++ b/Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift @@ -102,7 +102,8 @@ extension Main { Button(action: onConfirm) { Text("I understand! Proceed") - .frame(width: UIScreen.main.bounds.width - 60, height: 50) + .containerRelativeFrame(.horizontal) { width, _ in width - 60 } + .frame(height: 50) .background( Capsule() .fill(Color.blue) diff --git a/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift b/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift index 6e7f35280a..04b364a6a0 100644 --- a/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift +++ b/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift @@ -20,26 +20,6 @@ extension MealSettings { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var conversionFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - - return formatter - } - - private var intFormater: NumberFormatter { - let formatter = NumberFormatter() - formatter.allowsFloats = false - return formatter - } - - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - } - var body: some View { List { Section( diff --git a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift index d4a130165e..f5f0fdb1a3 100644 --- a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift +++ b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import HealthKit import LoopKit import LoopKitUI @@ -34,8 +33,8 @@ extension NightscoutConfig { return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() } let limits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), - maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(settings.maxBolus)) + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), + maximumBolus: LoopQuantity(unit: .internationalUnit, doubleValue: Double(settings.maxBolus)) ) return Future { promise in self.processQueue.async { @@ -49,7 +48,7 @@ extension NightscoutConfig { insulinActionCurve: settings.insulinActionCurve, maxBolus: Decimal( actual.maximumBolus? - .doubleValue(for: .internationalUnit()) ?? Double(settings.maxBolus) + .doubleValue(for: .internationalUnit) ?? Double(settings.maxBolus) ), maxBasal: Decimal( actual.maximumBasalRate? diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift index 5c811eadd8..f1f8b24206 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift @@ -311,8 +311,7 @@ struct AlgorithmSettingsSubstepView \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(100.formatted(for: units)) \(units.rawValue) is set." ) case .lowTempTargetLowersSensitivity: return String( - localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(100.formatted(for: units)) \(units.rawValue) is set." ) case .sensitivityRaisesTarget: return String(localized: "Raise target glucose when Autosens Ratio is less than 1.") case .resistanceLowersTarget: return String(localized: "Lower target glucose when Autosens Ratio is greater than 1.") @@ -244,7 +244,7 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable { return VStack(alignment: .leading, spacing: 8) { Text("Default: OFF").bold().foregroundStyle(Color.primary) Text( - "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) at times when a manual Temporary Target under \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) at times when a manual Temporary Target under \(100.formatted(for: units)) \(units.rawValue) is set." ) Text( "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." @@ -274,7 +274,7 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable { return VStack(alignment: .leading, spacing: 8) { Text("Default: OFF").bold().foregroundStyle(Color.primary) Text( - "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) when a manual Temporary Target above \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) when a manual Temporary Target above \(100.formatted(for: units)) \(units.rawValue) is set." ) Text( "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." @@ -367,10 +367,10 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable { return VStack(alignment: .leading, spacing: 8) { Text("Default: OFF").bold().foregroundStyle(Color.primary) Text( - "When this feature is enabled, manually setting a temporary target above \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used." + "When this feature is enabled, manually setting a temporary target above \(100.formatted(for: units)) \(units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used." ) Text( - "If Half Basal Exercise Target is set to \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), a temp target of \(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(units == .mgdL ? "140" : 140.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 0.6." + "If Half Basal Exercise Target is set to \(160.formatted(for: units)) \(units.rawValue), a temp target of \(120.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(140.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 0.6." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") } @@ -378,10 +378,10 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable { return VStack(alignment: .leading, spacing: 8) { Text("Default: OFF").bold().foregroundStyle(Color.primary) Text( - "When this feature is enabled, setting a temporary target below \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) will increase the Autosens Ratio used for ISF and basal adjustments, resulting in more insulin delivered overall. This scales with the temporary target set; the lower the Temp Target, the higher the Autosens Ratio used. It requires Algorithm Settings > Autosens > Autosens Max to be set to > 100% to work." + "When this feature is enabled, setting a temporary target below \(100.formatted(for: units)) \(units.rawValue) will increase the Autosens Ratio used for ISF and basal adjustments, resulting in more insulin delivered overall. This scales with the temporary target set; the lower the Temp Target, the higher the Autosens Ratio used. It requires Algorithm Settings > Autosens > Autosens Max to be set to > 100% to work." ) Text( - "If Half Basal Exercise Target is \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), a Temp Target of \(units == .mgdL ? "95" : 95.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(units == .mgdL ? "85" : 85.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 1.33." + "If Half Basal Exercise Target is \(160.formatted(for: units)) \(units.rawValue), a Temp Target of \(95.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(85.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 1.33." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") } @@ -402,14 +402,14 @@ enum AlgorithmSettingsSubstep: Int, CaseIterable, Identifiable { case .halfBasalTarget: return VStack(alignment: .leading, spacing: 8) { Text( - "Default: \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue)" + "Default: \(160.formatted(for: units)) \(units.rawValue)" ) .bold().foregroundStyle(Color.primary) Text( "The Half Basal Exercise Target allows you to scale down your basal insulin during exercise or scale up your basal insulin when eating soon when a temporary glucose target is set." ) Text( - "For example, at a temp target of \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue), 60% at \(units == .mgdL ? "140" : 140.formattedAsMmolL) \(units.rawValue))." + "For example, at a temp target of \(160.formatted(for: units)) \(units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(120.formatted(for: units)) \(units.rawValue), 60% at \(140.formatted(for: units)) \(units.rawValue))." ) Text( "Note: This setting is only utilized if the settings \"Low Temp Target Lowers Sensitivity\" OR \"High Temp Target Raises Sensitivity\" are enabled." diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift index 9f54fc34f4..a694ffe145 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift @@ -452,14 +452,14 @@ enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable { } VStack(alignment: .leading, spacing: 5) { Text( - "If your glucose target is \(units == .mgdL ? "110" : 110.formattedAsMmolL) \(units.rawValue), Trio will use a safety threshold of \(units == .mgdL ? "75" : 75.formattedAsMmolL) \(units.rawValue), unless you set Minimum Safety Threshold to something > \(units == .mgdL ? "75" : 75.formattedAsMmolL) \(units.rawValue)." + "If your glucose target is \(110.formatted(for: units)) \(units.rawValue), Trio will use a safety threshold of \(75.formatted(for: units)) \(units.rawValue), unless you set Minimum Safety Threshold to something > \(75.formatted(for: units)) \(units.rawValue)." ) Text( - "\(units == .mgdL ? "110" : 110.formattedAsMmolL) - 0.5 × (\(units == .mgdL ? "110" : 110.formattedAsMmolL) - \(units == .mgdL ? "40" : 40.formattedAsMmolL)) = \(units == .mgdL ? "75" : 75.formattedAsMmolL)" + "\(110.formatted(for: units)) - 0.5 × (\(110.formatted(for: units)) - \(40.formatted(for: units))) = \(75.formatted(for: units))" ) } Text( - "This setting is limited to values between \(units == .mgdL ? "60" : 60.formattedAsMmolL) - \(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue)" + "This setting is limited to values between \(60.formatted(for: units)) - \(120.formatted(for: units)) \(units.rawValue)" ) Text( "Note: Basal may be resumed if there is negative IOB and glucose is rising faster than the forecast." diff --git a/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift b/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift index 57406b10c0..b772626e65 100644 --- a/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift +++ b/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift @@ -335,13 +335,14 @@ struct TherapySettingEditorView: View { case .mmolL, .mmolLPer10Gram, .mmolLPerUnit: - return decimalValue.formattedAsMmolL + return decimalValue.formatted(for: .mmolL) + case .mgdL, + .mgdLPerUnit: + return decimalValue.formatted(for: .mgdL) case .unitPerHour: return basalFormatter.string(from: decimalValue as NSNumber) ?? "" case .gramPerUnit, - .mgdL, - .mgdLPer10Gram, - .mgdLPerUnit: + .mgdLPer10Gram: return decimalValue.description } } diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift index 2beff43fa0..8126bccdb4 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift @@ -41,8 +41,8 @@ extension PumpConfig { return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() } let limits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), - maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(settings.maxBolus)) + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), + maximumBolus: LoopQuantity(unit: .internationalUnit, doubleValue: Double(settings.maxBolus)) ) return Future { promise in self.processQueue.async { @@ -56,7 +56,7 @@ extension PumpConfig { insulinActionCurve: settings.insulinActionCurve, maxBolus: Decimal( actual.maximumBolus? - .doubleValue(for: .internationalUnit()) ?? Double(settings.maxBolus) + .doubleValue(for: .internationalUnit) ?? Double(settings.maxBolus) ), maxBasal: Decimal( actual.maximumBasalRate? diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootViewModel.swift b/Trio/Sources/Modules/Settings/View/SettingsRootViewModel.swift deleted file mode 100644 index 3392b7d3f1..0000000000 --- a/Trio/Sources/Modules/Settings/View/SettingsRootViewModel.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import SwiftUI -import Swinject - -class SettingsRootViewModel: ObservableObject { - @Published var headerText: String = "" - - init() { - let buildDetails = BuildDetails.default - let versionNumber = Bundle.main.releaseVersionNumber ?? "Unknown" - let buildNumber = Bundle.main.buildVersionNumber ?? "Unknown" - let branch = buildDetails.branchAndSha - - let headerBase = "Trio v\(versionNumber) (\(buildNumber))\nBranch: \(branch)" - - if let expirationDate = buildDetails.calculateExpirationDate() { - let formattedDate = DateFormatter.localizedString(from: expirationDate, dateStyle: .medium, timeStyle: .none) - headerText = "\(headerBase)\n\(buildDetails.expirationHeaderString): \(formattedDate)" - } else { - headerText = headerBase - } - } -} diff --git a/Trio/Sources/Modules/Settings/View/TidepoolConfigView.swift b/Trio/Sources/Modules/Settings/View/TidepoolConfigView.swift index 80d77ff3ea..ed7fe2f4f5 100644 --- a/Trio/Sources/Modules/Settings/View/TidepoolConfigView.swift +++ b/Trio/Sources/Modules/Settings/View/TidepoolConfigView.swift @@ -12,7 +12,8 @@ struct TidepoolSetupView: UIViewControllerRepresentable { func makeUIViewController(context _: UIViewControllerRepresentableContext) -> UIViewController { let result = serviceUIType.setupViewController( colorPalette: .default, - pluginHost: pluginHost + pluginHost: pluginHost, + allowDebugFeatures: false ) switch result { case let .createdAndOnboarded(serviceUI): @@ -35,7 +36,7 @@ struct TidepoolSettingsView: UIViewControllerRepresentable { let serviceDelegate: CompletionDelegate? func makeUIViewController(context _: UIViewControllerRepresentableContext) -> UIViewController { - var vc = serviceUI.settingsViewController(colorPalette: .default) + var vc = serviceUI.settingsViewController(colorPalette: .default, allowDebugFeatures: false) vc.completionDelegate = serviceDelegate vc.serviceOnboardingDelegate = serviceOnBoardDelegate return vc diff --git a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift index 4e6f6e449b..db90d36294 100644 --- a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift +++ b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift @@ -1273,15 +1273,11 @@ extension SettingsExport { let presetName = preset.name ?? "Unknown Temp Target" if let target = preset.target { - let targetValue = trioSettings.units == .mgdL - ? target.description - : target.decimalValue.formattedAsMmolL - processedTempTargetPresets.append(.init( category: category, subcategory: presetName, name: "Target", - value: targetValue, + value: String(describing: target.decimalValue.asUnit(trioSettings.units)), unit: trioSettings.units.rawValue )) } @@ -1297,10 +1293,10 @@ extension SettingsExport { } if let halfBasalTarget = preset.halfBasalTarget { - let halfBasalValue = trioSettings.units == .mgdL - ? halfBasalTarget.description - : halfBasalTarget.decimalValue.formattedAsMmolL - + let halfBasalValue = String( + describing: halfBasalTarget.decimalValue + .asUnit(trioSettings.units) + ) processedTempTargetPresets.append(.init( category: category, subcategory: presetName, @@ -1368,9 +1364,7 @@ extension SettingsExport { category: category, subcategory: presetName, name: "Target", - value: trioSettings.units == .mgdL - ? target.description - : target.decimalValue.formattedAsMmolL, + value: String(describing: target.decimalValue.asUnit(trioSettings.units)), unit: trioSettings.units.rawValue )) } diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift index fdc05354b7..350a3b3748 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift @@ -32,28 +32,28 @@ struct GlucoseDistributionChart: View { ]) .chartLegend(position: .bottom, alignment: .leading, spacing: 12) { let legendItems: [(String, Color)] = [ - ("<\(units == .mgdL ? Decimal(54) : 54.asMmolL)", .dynamicRed.opacity(0.8)), + ("<\(54.formatted(for: units))", .dynamicRed.opacity(0.8)), ( - "\(units == .mgdL ? Decimal(54) : 54.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)", + "\(54.formatted(for: units))-\(timeInRangeType.bottomThreshold.formatted(for: units))", .dynamicOrange.opacity(0.8) ), ( - "\(units == .mgdL ? Decimal(timeInRangeType.bottomThreshold) : timeInRangeType.bottomThreshold.asMmolL)-\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)", + "\(timeInRangeType.bottomThreshold.formatted(for: units))-\(timeInRangeType.topThreshold.formatted(for: units))", .dynamicGreen.opacity(0.8) ), ( - "\(units == .mgdL ? Decimal(timeInRangeType.topThreshold) : timeInRangeType.topThreshold.asMmolL)-\(units == .mgdL ? Decimal(180) : 180.asMmolL)", + "\(timeInRangeType.topThreshold.formatted(for: units))-\(180.formatted(for: units))", .dynamicTeal.opacity(0.8) ), ( - "\(units == .mgdL ? Decimal(180) : 180.asMmolL)-\(units == .mgdL ? Decimal(200) : 200.asMmolL)", + "\(180.formatted(for: units))-\(200.formatted(for: units))", .dynamicBlue.opacity(0.8) ), ( - "\(units == .mgdL ? Decimal(200) : 200.asMmolL)-\(units == .mgdL ? Decimal(220) : 220.asMmolL)", + "\(200.formatted(for: units))-\(220.formatted(for: units))", .dynamicIndigo.opacity(0.8) ), - (">\(units == .mgdL ? Decimal(220) : 220.asMmolL)", .dynamicPurple.opacity(0.8)) + (">\(220.formatted(for: units))", .dynamicPurple.opacity(0.8)) ] let columns = [GridItem(.adaptive(minimum: 65), spacing: 4)] diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift index a1cd190649..a4f4963415 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift @@ -132,9 +132,9 @@ struct GlucosePercentileChart: View { ("10-90%", Color.blue.opacity(0.3)), ("25-75%", Color.blue.opacity(0.5)), (String(localized: "Median"), Color.blue), - (String(localized: "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"), Color.staticLow), - (String(localized: "\(timeInRangeType.topThreshold.formatted(withUnits: units))"), Color.staticInRange), - (String(localized: "\(highLimit.formatted(withUnits: units))"), Color.staticHigh) + (timeInRangeType.bottomThreshold.formatted(withUnits: units), Color.staticLow), + (timeInRangeType.topThreshold.formatted(withUnits: units), Color.staticInRange), + (highLimit.formatted(withUnits: units), Color.staticHigh) ] let columns = [GridItem(.adaptive(minimum: 100), spacing: 4)] diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift index 84c37050d8..791d302518 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift @@ -293,7 +293,7 @@ struct GlucoseSectorChart: View { ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), - (String(localized: "SD"), formatSD(standardDeviation)) + (String(localized: "SD"), standardDeviation.formatted(for: units)) ] ) @@ -322,7 +322,7 @@ struct GlucoseSectorChart: View { ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), - (String(localized: "SD"), formatSD(standardDeviation)) + (String(localized: "SD"), standardDeviation.formatted(for: units)) ] ) @@ -350,7 +350,7 @@ struct GlucoseSectorChart: View { ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), - (String(localized: "SD"), formatSD(standardDeviation)) + (String(localized: "SD"), standardDeviation.formatted(for: units)) ] ) } @@ -387,15 +387,6 @@ struct GlucoseSectorChart: View { let standardDeviation = sqrt(sumOfSquares / Double(values.count)) return (average, median, standardDeviation) } - - /// Formats the standard deviation value based on glucose units. - /// - Parameter sd: The standard deviation as a Double. - /// - Returns: A formatted string representing the standard deviation. - private func formatSD(_ sd: Double) -> String { - units == .mgdL ? sd.formatted( - .number.grouping(.never).rounded().precision(.fractionLength(0)) - ) : sd.formattedAsMmolL - } } /// Represents details about a specific glucose range category including title, color and percentage breakdowns diff --git a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift index fdd5bb9a4c..c091d334f4 100644 --- a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift +++ b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift @@ -25,11 +25,12 @@ extension TargetsEditor { let shouldDisableButton = state.shouldDisplaySaving || state.items.isEmpty || !state.hasChanges Rectangle() - .frame(width: UIScreen.main.bounds.width, height: 65) + .frame(height: 65) .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) .background(.thinMaterial) .opacity(0.8) .clipShape(Rectangle()) + .ignoresSafeArea(edges: .horizontal) Group { HStack { @@ -49,10 +50,11 @@ extension TargetsEditor { } Text(state.shouldDisplaySaving ? "Saving..." : "Save") } - .frame(width: UIScreen.main.bounds.width * 0.9, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } .padding(10) }) - .frame(width: UIScreen.main.bounds.width * 0.9, height: 40, alignment: .center) + .containerRelativeFrame(.horizontal) { width, _ in width * 0.9 } + .frame(height: 40) .disabled(shouldDisableButton) .background(shouldDisableButton ? Color(.systemGray4) : Color(.systemBlue)) .tint(.white) diff --git a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift index fc96c3a361..f77549f80f 100644 --- a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift +++ b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift @@ -106,11 +106,9 @@ struct ForecastChart: View { @ViewBuilder private func eventualGlucoseBadge(for eventualBG: Int) -> some View { HStack { - Text( - state.units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL - ) - .font(.footnote) - .foregroundStyle(.primary) + Text(eventualBG.formatted(for: state.units)) + .font(.footnote) + .foregroundStyle(.primary) Text("\(state.units.rawValue)") .font(.footnote) .foregroundStyle(.secondary) @@ -210,7 +208,7 @@ struct ForecastChart: View { private var forecastChartAccessibilityLabel: String { let unitLabel = state.units == .mgdL ? String(localized: "mg/dL") : String(localized: "mmol/L") func format(_ value: Int) -> String { - state.units == .mgdL ? Decimal(value).description : Decimal(value).formattedAsMmolL + value.formatted(for: state.units) } var parts = [String(localized: "Glucose and forecast chart", comment: "Accessibility: chart summary")] if let latest = state.latestGlucose { @@ -246,20 +244,17 @@ struct ForecastChart: View { glucoseColorScheme: state.glucoseColorScheme ) HStack { - Text("CGM: ") + Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL) + Text("CGM: ") + Text(Decimal(sgv).formatted(for: state.units)) .bold() - + Text(" \(state.units.rawValue)") + + Text(" " + state.units.rawValue) }.foregroundStyle( Color(glucoseColor) ).font(.footnote) if state.isSmoothingEnabled, let smoothedGlucose = selectedGlucose?.smoothedGlucose { - let smoothedGlucoseToDisplay: Decimal = state.units == .mgdL - ? smoothedGlucose.decimalValue - : smoothedGlucose.decimalValue.asMmolL HStack { Image(systemName: "sparkles") - Text(smoothedGlucoseToDisplay.description) + Text(" \(state.units.rawValue)") + Text(smoothedGlucose.decimalValue.formatted(withUnits: state.units)) }.font(.footnote) } } diff --git a/Trio/Sources/Modules/Treatments/View/PopupView.swift b/Trio/Sources/Modules/Treatments/View/PopupView.swift index 87c26cc888..2a911a6232 100644 --- a/Trio/Sources/Modules/Treatments/View/PopupView.swift +++ b/Trio/Sources/Modules/Treatments/View/PopupView.swift @@ -233,17 +233,17 @@ struct PopupView: View { GridRow { Text("(") .operatorStyle() - Text(state.units == .mmolL ? state.currentBG.formattedAsMmolL : state.currentBG.description) + Text(state.currentBG.formatted(for: state.units)) .valueStyle() Text("−") .operatorStyle() - Text(state.units == .mmolL ? state.target.formattedAsMmolL : state.target.description) + Text(state.target.formatted(for: state.units)) .valueStyle() Text(")") .operatorStyle() Text("/") .operatorStyle() - Text(state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description) + Text(state.isf.formattedISF(for: state.units)) .valueStyle() Text("=") .operatorStyle() @@ -462,11 +462,11 @@ struct PopupView: View { // Row 2: The delta calculation formula GridRow { - Text(state.units == .mmolL ? state.deltaBG.formattedAsMmolL : state.deltaBG.description) + Text(state.deltaBG.formattedDelta(for: state.units)) .valueStyle() Text("/") .operatorStyle() - Text(state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description) + Text(state.isf.formattedISF(for: state.units)) .valueStyle() Text("=") .operatorStyle() diff --git a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift index 9a18b5d371..e54fb89501 100644 --- a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift +++ b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift @@ -60,25 +60,6 @@ extension Treatments { return formatter } - private var gluoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if state.units == .mmolL { - formatter.maximumIntegerDigits = 2 - formatter.maximumFractionDigits = 1 - } else { - formatter.maximumIntegerDigits = 3 - formatter.maximumFractionDigits = 0 - } - return formatter - } - - private var fractionDigits: Int { - if state.units == .mmolL { - return 1 - } else { return 0 } - } - /// Handles macro input (carb, fat, protein) in a debounced fashion. func handleDebouncedInput() { debounce?.cancel() @@ -426,6 +407,8 @@ extension Treatments { treatmentButton } .listSectionSpacing(sectionSpacing) + // iOS's default on iPhone; iPhone Duo defaults to 0, which put the first row under the sheet's bar + .contentMargins(.top, 35, for: .scrollContent) .onChange(of: focusedField) { _, newValue in scrollFocusedFieldIntoView(newValue, proxy: proxy) } diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift index 5f58844e85..aa4e121b3b 100644 --- a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift +++ b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift @@ -21,24 +21,6 @@ extension UserInterfaceSettings { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if state.units == .mmolL { - formatter.maximumFractionDigits = 1 - } - formatter.roundingMode = .halfUp - return formatter - } - - private var carbsFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - return formatter - } - var body: some View { List { Section( @@ -388,7 +370,7 @@ extension UserInterfaceSettings { Spacer() Group { - Text(state.units == .mgdL ? state.low.description : state.low.asMmolL.description) + Text(state.low.formatted(for: state.units)) .foregroundColor(!displayPickerLowThreshold ? .primary : .accentColor) Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary) @@ -401,8 +383,7 @@ extension UserInterfaceSettings { .accessibilityElement(children: .ignore) .accessibilityLabel(Text("Low Threshold")) .accessibilityValue(Text( - (state.units == .mgdL ? state.low.description : state.low.asMmolL.description) - + " " + state.units.spokenValue + state.low.formatted(for: state.units) + " " + state.units.spokenValue )) .accessibilityHint(Text( displayPickerLowThreshold @@ -425,8 +406,7 @@ extension UserInterfaceSettings { PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units), id: \.self ) { value in - let displayValue = state.units == .mgdL ? value : value.asMmolL - Text("\(displayValue.description)").tag(value) + Text(value.formatted(for: state.units)).tag(value) } } .pickerStyle(WheelPickerStyle()) @@ -440,7 +420,7 @@ extension UserInterfaceSettings { Spacer() Group { - Text(state.units == .mgdL ? state.high.description : state.high.asMmolL.description) + Text(state.high.formatted(for: state.units)) .foregroundColor(!displayPickerHighThreshold ? .primary : .accentColor) Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary) @@ -453,8 +433,7 @@ extension UserInterfaceSettings { .accessibilityElement(children: .ignore) .accessibilityLabel(Text("High Threshold")) .accessibilityValue(Text( - (state.units == .mgdL ? state.high.description : state.high.asMmolL.description) - + " " + state.units.spokenValue + state.high.formatted(for: state.units) + " " + state.units.spokenValue )) .accessibilityHint(Text( displayPickerHighThreshold @@ -476,8 +455,7 @@ extension UserInterfaceSettings { PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units), id: \.self ) { value in - let displayValue = state.units == .mgdL ? value : value.asMmolL - Text("\(displayValue.description)").tag(value) + Text(value.formatted(for: state.units)).tag(value) } } .pickerStyle(WheelPickerStyle()) @@ -502,7 +480,7 @@ extension UserInterfaceSettings { AnyView( VStack(alignment: .leading, spacing: 10) { Text( - "Default values are based on internationally accepted Time in Range values of \(state.units == .mgdL ? "70" : 70.formattedAsMmolL)-\(state.units == .mgdL ? "180" : 180.formattedAsMmolL) \(state.units.rawValue)." + "Default values are based on internationally accepted Time in Range values of \(70.formatted(for: state.units))-\(180.formatted(for: state.units)) \(state.units.rawValue)." ).bold() Text( "These thresholds drive the chart band colors, statistics in-range buckets, Live Activity colors, and Contact Image colors." @@ -857,11 +835,6 @@ private struct GlucoseColorGradientPreview: View { return min(max(CGFloat(truncating: raw as NSNumber), 0), 1) } - private func axisValue(_ value: Decimal) -> String { - let display = units == .mgdL ? value : value.asMmolL - return "\(display)" - } - var body: some View { VStack(alignment: .leading, spacing: 3) { RoundedRectangle(cornerRadius: 8) @@ -891,7 +864,7 @@ private struct GlucoseColorGradientPreview: View { Image(systemName: "arrowtriangle.up.fill") .font(.system(size: 7)) .foregroundColor(.primary.opacity(0.55)) - Text(axisValue(value)) + Text(value.formatted(for: units)) .font(.caption2) .foregroundColor(.secondary) } diff --git a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift index 7be38bd426..629a010569 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift @@ -1,11 +1,9 @@ import LoopKit -/// Trio-side bridge of the upstream `AlertCatalogVendor` protocol. Pump -/// plugins don't currently set `interruptionLevel` on the alerts they issue -/// — every emission lands at LoopKit's default (`.timeSensitive`). Trio -/// overrides the level by looking up the alert's identifier in this -/// registry. As plugins adopt `AlertCatalogVendor` upstream, their entries -/// move out of here and into the plugin repo. +/// Trio's own catalog of the alerts device plugins issue. Pump plugins don't +/// currently set `interruptionLevel` on the alerts they issue — every +/// emission lands at LoopKit's default (`.timeSensitive`). Trio overrides the +/// level by looking up the alert's identifier in this registry. /// /// Scope covers pump + CGM-issued alerts (LibreLoop sensor expiry / attention /// / reconnect, G7 sensor lifecycle). G5/G6/One don't go through `issueAlert` diff --git a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift index 43234781a9..d4cd35709a 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift @@ -1,11 +1,10 @@ import Foundation import LoopKit -/// Plugin-side declaration of every alert a device manager may issue. -/// Mirrors LoopKit's `AlertSoundVendor` pattern (static, boot-time, no -/// side effects). Trio uses the union catalog to drive per-alert severity, -/// the Settings UI, and per-alert mute. When upstream LoopKit adopts this, -/// Trio's bridge extensions move into the plugin repos verbatim. +/// Trio's declaration of every alert a device manager may issue. Modeled on +/// LoopKit's `AlertSoundVendor` pattern (static, boot-time, no side effects), +/// but defined in Trio only; LoopKit has no counterpart. Trio uses the union +/// catalog to drive per-alert severity, the Settings UI, and per-alert mute. protocol AlertCatalogVendor { var alertCatalog: [Alert.CatalogEntry] { get } } diff --git a/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift b/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift index 40bc3a71bb..f9dc01ea59 100644 --- a/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift +++ b/Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift @@ -1,10 +1,9 @@ import CGMBLEKit import G7SensorKit -import LibreTransmitter import LoopKit -/// Trio-side stand-in for LoopKit next-dev's `CGMManager.providesOwnGlucoseAlerts`. -/// Collapses to `manager?.providesOwnGlucoseAlerts ?? false` once the fork bumps. +/// Trio-side stand-in for `CGMManager.providesOwnGlucoseAlerts`, which the +/// Dexcom kits don't set for eavesdropped sessions. enum CGMManagerAlertOwnership { struct OwningApp { let name: String @@ -37,8 +36,6 @@ enum CGMManagerAlertOwnership { // pairing requires that app to be gone, so nothing else alerts. guard manager.sessionMode == .eavesdropping else { return nil } return OwningApp(name: "Dexcom G7 / One+", deepLink: URL(string: "dexcomg7://")) - case is LibreTransmitterManagerV3: - return OwningApp(name: "FreeStyle Libre", deepLink: nil) default: return nil } diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift index 11cbcc3146..f7ab3b2a04 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import LoopKit @@ -21,9 +22,17 @@ import LoopKit /// Ported from Loop's `CriticalAlertAlarmScheduler`. @MainActor final class CriticalAlertAlarmScheduler { - /// Maps an issued alert to the AlarmKit alarm scheduled for it, so the - /// alarm can be cancelled when the alert is acknowledged or retracted. - private var alarmsByAlert: [Alert.Identifier: UUID] = [:] + /// The AlarmKit alarm ID for an alert, derived from the alert identifier so + /// a relaunched app can still stop an alarm scheduled by an earlier process. + static func alarmID(for identifier: Alert.Identifier) -> UUID { + var bytes = Array(SHA256.hash(data: Data(identifier.value.utf8)).prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + )) + } /// True only if AlarmKit is available (iOS 26+) AND the user authorized it. /// When false, callers should use the `CriticalAlertAudioPlayer` fallback. @@ -89,20 +98,25 @@ final class CriticalAlertAlarmScheduler { sound: sound ) - let id = UUID() - alarmsByAlert[alert.identifier] = id + let id = Self.alarmID(for: alert.identifier) let identifier = alert.identifier - Task { [weak self] in + // AlarmKit rings until stopped, so end it after the same burst the + // in-app player would play. Best-effort: if iOS suspends Trio, it + // stops on Trio's next wake (the next CGM reading at the latest). + let burst = alert.sound?.filename + .map(CriticalAlertAudioPlayer.burstDuration(forSoundNamed:)) ?? CriticalAlertAudioPlayer.minBurstDuration + Task { do { _ = try await AlarmManager.shared.schedule(id: id, configuration: configuration) info(.alerts, "AlarmKit alarm scheduled for \(identifier.value)") + try? await Task.sleep(for: .seconds(Self.immediateFireDelay + burst)) + stopIfAlerting(id: id, identifier: identifier) } catch let scheduleError { warning( .alerts, "Scheduling AlarmKit alarm for \(identifier.value) failed, falling back to audio", error: scheduleError ) - self?.alarmsByAlert.removeValue(forKey: identifier) onScheduleFailure() } } @@ -112,15 +126,33 @@ final class CriticalAlertAlarmScheduler { #endif } + @available(iOS 26, *) private func stopIfAlerting(id: UUID, identifier: Alert.Identifier) { + #if canImport(AlarmKit) + guard let alarm = try? AlarmManager.shared.alarms.first(where: { $0.id == id }), + alarm.state == .alerting + else { return } + try? AlarmManager.shared.stop(id: id) + debug(.alerts, "AlarmKit alarm for \(identifier.value) stopped after its burst") + #endif + } + /// Cancel/stop the AlarmKit alarm scheduled for the given alert, if any. func cancelAlarm(for identifier: Alert.Identifier) { guard #available(iOS 26, *) else { return } #if canImport(AlarmKit) - guard let id = alarmsByAlert.removeValue(forKey: identifier) else { return } - // The alarm may be ringing or merely scheduled; try both and ignore errors. - try? AlarmManager.shared.stop(id: id) - try? AlarmManager.shared.cancel(id: id) - debug(.alerts, "AlarmKit alarm cancelled for \(identifier.value)") + let id = Self.alarmID(for: identifier) + do { + guard let alarm = try AlarmManager.shared.alarms.first(where: { $0.id == id }) else { return } + // stop() ends a ringing alarm; cancel() removes a scheduled one + if alarm.state == .alerting { + try AlarmManager.shared.stop(id: id) + } else { + try AlarmManager.shared.cancel(id: id) + } + debug(.alerts, "AlarmKit alarm cancelled for \(identifier.value)") + } catch let cancelError { + warning(.alerts, "Cancelling AlarmKit alarm for \(identifier.value) failed", error: cancelError) + } #endif } } diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift index 896e1b0922..ca752cbbe7 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift @@ -6,6 +6,7 @@ import UIKit @MainActor final class CriticalAlertAudioPlayer { private var player: AVAudioPlayer? + private var stopTimer: Timer? private var vibrationTimer: Timer? private let vibrationInterval: TimeInterval = 2.0 @@ -18,22 +19,50 @@ final class CriticalAlertAudioPlayer { var isPlaying: Bool { player?.isPlaying ?? false } - /// Start (or restart) looping playback of a bundled critical alert sound. - /// Loops until `stop()` is called from `retractAlert` / - /// `handleAcknowledgement` in `BaseTrioAlertManager` — modeled on the - /// LoopFollow / Jonas loop-failure alarm. `.playback` audio session gives - /// the app a privileged background state so the alarm continues even if - /// the user has the screen locked. - func play(soundNamed soundName: String = "critical.caf") { - stop() + /// Shortest an alarm rings. A floor, not a cap: whole passes of the sound + /// run until it is reached, so a short tone repeats and a long one plays + /// through once rather than being cut off mid-phrase. + nonisolated static let minBurstDuration: TimeInterval = 6 - startVibration() + /// Longest an alarm rings, so an unexpectedly long sound can't run on. + nonisolated static let maxBurstDuration: TimeInterval = 30 + + /// Whole passes of `duration`, enough to reach `minBurstDuration`, capped. + nonisolated static func burstDuration(forSoundOfDuration duration: TimeInterval) -> TimeInterval { + guard duration > 0 else { return minBurstDuration } + let passes = max(1, Int((minBurstDuration / duration).rounded(.up))) + return min(maxBurstDuration, duration * Double(passes)) + } + /// Burst length for a bundled sound, or the floor if it can't be read. + nonisolated static func burstDuration(forSoundNamed soundName: String) -> TimeInterval { + guard let url = soundURL(named: soundName), let p = try? AVAudioPlayer(contentsOf: url) else { + return minBurstDuration + } + return burstDuration(forSoundOfDuration: p.duration) + } + + nonisolated private static func soundURL(named soundName: String) -> URL? { let resource = (soundName as NSString).deletingPathExtension let ext = (soundName as NSString).pathExtension.isEmpty ? "caf" : (soundName as NSString).pathExtension - let url = Bundle.main.url(forResource: resource, withExtension: ext) + return Bundle.main.url(forResource: resource, withExtension: ext) ?? Bundle.main.url(forResource: "critical", withExtension: "caf") - guard let url else { + } + + /// Play one burst of a bundled critical alert sound (see + /// `burstDuration`), then stop on its own. `retractAlert` / + /// `handleAcknowledgement` in `BaseTrioAlertManager` can end it sooner. + /// `.playback` audio session gives the app a privileged background state + /// so the alarm plays even if the user has the screen locked. + func play(soundNamed soundName: String = "critical.caf") { + stop() + + startVibration() + // Bounds vibration if the audio setup below fails; re-armed to the + // sound's burst length once it is known. + scheduleStop(after: Self.minBurstDuration) + + guard let url = Self.soundURL(named: soundName) else { error(.alerts, "Critical alert sound \(soundName) and fallback sound missing, audio fallback unavailable") return } @@ -62,12 +91,24 @@ final class CriticalAlertAudioPlayer { return } player = p - info(.alerts, "Critical alert audio started, sound length \(String(format: "%.2f", p.duration)) s") + let burst = Self.burstDuration(forSoundOfDuration: p.duration) + scheduleStop(after: burst) + info( + .alerts, + "Critical alert audio started, sound length \(String(format: "%.2f", p.duration)) s, burst \(String(format: "%.2f", burst)) s" + ) } catch let playbackError { error(.alerts, "Critical alert audio playback failed", error: playbackError) } } + private func scheduleStop(after interval: TimeInterval) { + stopTimer?.invalidate() + stopTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + Task { @MainActor in self?.stop() } + } + } + private func startVibration() { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) vibrationTimer = Timer.scheduledTimer(withTimeInterval: vibrationInterval, repeats: true) { _ in @@ -77,9 +118,11 @@ final class CriticalAlertAudioPlayer { /// Stop playback if any. Safe to call when not playing. func stop() { - guard player != nil || vibrationTimer != nil else { return } + guard player != nil || stopTimer != nil || vibrationTimer != nil else { return } player?.stop() player = nil + stopTimer?.invalidate() + stopTimer = nil vibrationTimer?.invalidate() vibrationTimer = nil volumeBooster.restore() diff --git a/Trio/Sources/Services/Alerts/TrioAlertManager.swift b/Trio/Sources/Services/Alerts/TrioAlertManager.swift index 4add9f2b4a..b688e4cb1d 100644 --- a/Trio/Sources/Services/Alerts/TrioAlertManager.swift +++ b/Trio/Sources/Services/Alerts/TrioAlertManager.swift @@ -56,6 +56,9 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { /// is `@MainActor` (its `MPVolumeView` + `Timer` members require main), /// but `BaseTrioAlertManager.init` runs on the Swinject resolve thread. @MainActor private var criticalAudioPlayer: CriticalAlertAudioPlayer? + /// The alert `criticalAudioPlayer` is sounding for. Retracting or + /// acknowledging any other alert must leave the audio running. + @MainActor private var criticalAudioOwner: Alert.Identifier? /// Preferred audible channel on iOS 26+. Falls back to `criticalAudioPlayer` /// when AlarmKit is unavailable or the user hasn't authorized it. @MainActor private var alarmScheduler: CriticalAlertAlarmScheduler? @@ -190,19 +193,26 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { // AlarmKit pierces silent/Focus and survives app suspension. if alarmScheduler == nil { alarmScheduler = CriticalAlertAlarmScheduler() } let scheduled = alarmScheduler?.scheduleAlarm(for: alert) { [weak self] in - Task { @MainActor in self?.playAudioFallback(soundNamed: soundName) } + Task { @MainActor in self?.playAudioFallback(soundNamed: soundName, for: alert.identifier) } } ?? false guard !scheduled else { return } // Fallback: in-process audio. Only sounds while Trio is running. - playAudioFallback(soundNamed: soundName) + playAudioFallback(soundNamed: soundName, for: alert.identifier) } } - @MainActor private func playAudioFallback(soundNamed soundName: String) { + @MainActor private func playAudioFallback(soundNamed soundName: String, for identifier: Alert.Identifier) { if criticalAudioPlayer == nil { criticalAudioPlayer = CriticalAlertAudioPlayer() } + criticalAudioOwner = identifier criticalAudioPlayer?.play(soundNamed: soundName) } + @MainActor private func stopCriticalAudio(for identifier: Alert.Identifier) { + guard criticalAudioOwner == identifier else { return } + criticalAudioOwner = nil + criticalAudioPlayer?.stop() + } + // MARK: - Issue / Retract func issueAlert(_ alert: Alert) { @@ -315,7 +325,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { userNotificationScheduler.unschedule(identifier: identifier) throttler.reset(identifier: identifier) Task { @MainActor in - criticalAudioPlayer?.stop() + stopCriticalAudio(for: identifier) alarmScheduler?.cancelAlarm(for: identifier) } alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier) @@ -356,7 +366,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { modalScheduler.unschedule(identifier: identifier) userNotificationScheduler.unschedule(identifier: identifier) Task { @MainActor in - criticalAudioPlayer?.stop() + stopCriticalAudio(for: identifier) alarmScheduler?.cancelAlarm(for: identifier) } // All matching entries — replay + a genuine re-issue can coexist. @@ -378,8 +388,10 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable { private func acknowledgeOnDevice(identifier: Alert.Identifier) { guard let responder = queue.sync(execute: { responders[identifier.managerIdentifier]?.ref as? AlertResponder }) else { return } - responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { error in - if let error = error { + Task { + do { + try await responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) + } catch { warning(.alerts, "Acknowledging alert \(identifier.value) on device failed", error: error) } } diff --git a/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift index 2e30f215b4..6872767be6 100644 --- a/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift +++ b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift @@ -5,12 +5,12 @@ import Swinject protocol BluetoothStateManager: BluetoothProvider {} -public class BaseBluetoothStateManager: NSObject, BluetoothStateManager, Injectable { +@MainActor public class BaseBluetoothStateManager: NSObject, BluetoothStateManager, Injectable { private var completion: ((BluetoothAuthorization) -> Void)? private var centralManager: CBCentralManager? private var bluetoothObservers = WeakSynchronizedSet() - init(resolver: Resolver) { + nonisolated init(resolver: Resolver) { super.init() injectServices(resolver) if bluetoothAuthorization != .notDetermined { @@ -18,7 +18,7 @@ public class BaseBluetoothStateManager: NSObject, BluetoothStateManager, Injecta } } - public var bluetoothAuthorization: BluetoothAuthorization { + public nonisolated var bluetoothAuthorization: BluetoothAuthorization { BluetoothAuthorization(CBCentralManager.authorization) } diff --git a/Trio/Sources/Services/Calendar/CalendarManager.swift b/Trio/Sources/Services/Calendar/CalendarManager.swift index c616e04346..47977572a6 100644 --- a/Trio/Sources/Services/Calendar/CalendarManager.swift +++ b/Trio/Sources/Services/Calendar/CalendarManager.swift @@ -26,26 +26,6 @@ final class BaseCalendarManager: CalendarManager, Injectable { private var subscriptions = Set() private var previousDeterminationId: NSManagedObjectID? - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if settingsManager.settings.units == .mmolL { - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - } - formatter.roundingMode = .halfUp - return formatter - } - - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - formatter.positivePrefix = "+" - return formatter - } - private var iobFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal @@ -262,16 +242,10 @@ final class BaseCalendarManager: CalendarManager, Injectable { glucoseIcon = freshLoop > 15 ? "🚫" : glucoseIcon } - let glucoseText = glucoseFormatter - .string(from: Double( - settingsManager.settings.units == .mmolL ? Int(lastGlucoseValue) - .asMmolL : Decimal(lastGlucoseValue) - ) as NSNumber)! - + let glucoseText = Decimal(lastGlucoseValue).formatted(for: settingsManager.settings.units) let directionText = lastGlucoseObject.directionEnum?.symbol ?? "↔︎" - let deltaValue = settingsManager.settings.units == .mmolL ? delta.asMmolL : delta - let deltaText = deltaFormatter.string(from: deltaValue as NSNumber) ?? "--" + let deltaText = delta.formattedDelta(for: settingsManager.settings.units) let iobText = iobFormatter.string(from: (iobService.currentIOB ?? 0) as NSNumber) ?? "" let cobText = cobFormatter.string(from: determinationObject.cob as NSNumber) ?? "" diff --git a/Trio/Sources/Services/ContactImage/ContactImageManager.swift b/Trio/Sources/Services/ContactImage/ContactImageManager.swift index 91d1959a16..b362d175d0 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -37,23 +37,11 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { private var coreDataPublisher: AnyPublisher, Never>? private var subscriptions = Set() - private var units: GlucoseUnits = .mgdL - - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = settingsManager.settings.units == .mmolL ? 1 : 0 - formatter.positivePrefix = "+" - formatter.negativePrefix = "-" - return formatter - } - weak var delegate: ContactImageManagerDelegate? init(resolver: Resolver) { super.init() injectServices(resolver) - units = settingsManager.settings.units coreDataPublisher = CoreDataStack.shared.entityChangePublisher .receive(on: queue) @@ -194,6 +182,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { /// - Important: This function must be called on the main actor to ensure thread safety. Otherwise, we would need to ensure thread safety by either using an actor or a perform closure @MainActor func updateContactImageState() async { do { + let units = settingsManager.settings.units // Get NSManagedObjectIDs on backgroundContext let glucoseValuesIds = try await fetchGlucose() let determinationIds = try await fetchlastDetermination() @@ -206,21 +195,22 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { let lastDetermination = determinationObjects.last if let firstGlucoseValue = glucoseObjects.first { - let value = settingsManager.settings.units == .mgdL + let value = units == .mgdL ? Decimal(firstGlucoseValue.glucose) : Decimal(firstGlucoseValue.glucose).asMmolL - state.glucose = Formatter.glucoseFormatter(for: units).string(from: value as NSNumber) + state.glucose = Decimal(firstGlucoseValue.glucose).formatted(for: units) state.glucoseValue = value state.trend = firstGlucoseValue.directionEnum?.symbol state.direction = firstGlucoseValue.directionEnum state.glucoseDate = firstGlucoseValue.date - let delta = glucoseObjects.count >= 2 - ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects.dropFirst().first?.glucose ?? 0) - : 0 - let deltaConverted = settingsManager.settings.units == .mgdL ? delta : delta.asMmolL - state.delta = deltaFormatter.string(from: deltaConverted as NSNumber) + if glucoseObjects.count >= 2 { + let delta = Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects[1].glucose) + state.delta = delta.formattedDelta(for: units) + } else { + state.delta = nil + } } else { // fetchGlucose() only looks back 20 minutes, so an empty result means dropout. state.glucose = nil @@ -247,12 +237,8 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { state.cobText = "0" } - if let eventualBG = settingsManager.settings.units == .mgdL ? lastDetermination? - .eventualBG : lastDetermination? - .eventualBG?.decimalValue.asMmolL as NSDecimalNumber? - { - let eventualBGAsString = Formatter.decimalFormatterWithOneFractionDigit.string(from: eventualBG) - state.eventualBG = eventualBGAsString.map { "⇢ " + $0 } + if let eventualBG = lastDetermination?.eventualBG { + state.eventualBG = "⇢ " + eventualBG.decimalValue.formatted(for: units) } // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose diff --git a/Trio/Sources/Services/HealthKit/HealthKitManager.swift b/Trio/Sources/Services/HealthKit/HealthKitManager.swift index 19ae6d48e7..84879a1314 100644 --- a/Trio/Sources/Services/HealthKit/HealthKitManager.swift +++ b/Trio/Sources/Services/HealthKit/HealthKitManager.swift @@ -544,6 +544,16 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { // Calculate the end date based on the event duration let endDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(event.duration ?? 0))) + // HealthKit raises an uncatchable exception for a sample that ends before it starts, which + // terminates the app, so one misdated row would crash every upload attempt. + guard endDate >= event.timestamp else { + warning( + .healthKit, + "Skipping insulin sample \(event.id): ends \(endDate) before it starts \(event.timestamp)" + ) + return nil + } + // Create the HealthKit quantity sample with the appropriate metadata let sample = HKQuantitySample( type: sampleType, @@ -578,6 +588,15 @@ final class BaseHealthKitManager: HealthKitManager, Injectable { // Check if the predecessor temp basal overlaps with the next event if predecessorEndDate > nextEventTimestamp { + // Same uncatchable-exception guard as `createSample`: the adjustment can only shorten + // the span, never invert it. + guard nextEventTimestamp >= predecessorTimestamp else { + warning( + .healthKit, + "Skipping temporary basal \(predecessorEntryId): ends \(nextEventTimestamp) before it starts \(predecessorTimestamp)" + ) + return nil + } // Adjust the end date to the start of the next event to prevent overlap let adjustedEndDate = nextEventTimestamp // Precise duration in seconds diff --git a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift index 6bc456a76a..8058e26c58 100644 --- a/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift +++ b/Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift @@ -26,44 +26,12 @@ extension LiveActivityAttributes.SimpleViewStyle { } extension LiveActivityAttributes.ContentState { - static func formatGlucose(_ value: Int, units: GlucoseUnits, forceSign: Bool) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if units == .mmolL { - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - } - if forceSign { - formatter.positivePrefix = formatter.plusSign - } - formatter.roundingMode = .halfUp - - return formatter - .string(from: units == .mmolL ? value.asMmolL as NSNumber : NSNumber(value: value))! - } - static func calculateChange(chart: [GlucoseData], units: GlucoseUnits) -> String { - guard chart.count > 2 else { return "" } - - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - formatter.positivePrefix = " +" - formatter.negativePrefix = " -" - - var lastGlucose = Decimal(chart.first?.glucose ?? 0) - var secondLastGlucose = Decimal(chart.dropFirst().first?.glucose ?? 0) - if units == .mmolL { - lastGlucose = lastGlucose.asMmolL - secondLastGlucose = secondLastGlucose.asMmolL - - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - } + guard chart.count >= 2 else { return "" } - let delta = lastGlucose - secondLastGlucose - return formatter.string(from: delta as NSNumber) ?? "--" + let lastGlucose = Decimal(chart.first?.glucose ?? 0) + let secondLastGlucose = Decimal(chart.dropFirst().first?.glucose ?? 0) + return (lastGlucose - secondLastGlucose).formattedDelta(for: units) } init( @@ -79,7 +47,7 @@ extension LiveActivityAttributes.ContentState { widgetItems: [LiveActivityAttributes.LiveActivityItem]? ) { let glucose = bg.glucose - let formattedBG = Self.formatGlucose(Int(glucose), units: units, forceSign: false) + let formattedBG = glucose.formatted(for: units) var rotationDegrees = 0.0 switch bg.direction { diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index 9e84f1cbc4..438f68f186 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -31,6 +31,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { @Injected() private var overridesStorage: OverrideStorage! @Injected() private var carbsStorage: CarbsStorage! @Injected() private var pumpHistoryStorage: PumpHistoryStorage! + @Injected() private var pumpStatusStorage: PumpStatusStorage! @Injected() private var storage: FileStorage! @Injected() private var settingsManager: SettingsManager! @Injected() private var broadcaster: Broadcaster! @@ -499,13 +500,16 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { } } - /// The pump status at upload time, read from the pump manager. + /// The pump status at upload time, read from the pump manager, or the last stored status without one. private func pumpStatusForUpload() async -> PumpStatus? { guard let liveStatus = apsManager.pumpManager?.status else { - return await storage.retrieveAsync(OpenAPS.Monitor.status, as: PumpStatus.self) + return await pumpStatusStorage.lastPumpStatus() } - guard case let .active(deliveryActiveSince) = liveStatus.basalDeliveryState else { - return liveStatus.pumpStatus + let deliveryState = PumpStatusStored.DeliveryState(liveStatus.basalDeliveryState) + let pumpStatus = PumpStatus(deliveryState: deliveryState, bolusing: liveStatus.bolusState != .noBolus, timestamp: Date()) + // The suspend lookback applies to delivery active since a real date. + guard deliveryState == .active, case let .active(deliveryActiveSince) = liveStatus.basalDeliveryState else { + return pumpStatus } let context = CoreDataStack.shared.newTaskContext() @@ -528,7 +532,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { } } - return liveStatus.pumpStatus.includingSuspend(deliveryActiveSince: deliveryActiveSince, lastPumpEvent: lastEvent) + return pumpStatus.includingSuspend(deliveryActiveSince: deliveryActiveSince, lastPumpEvent: lastEvent) } /// Asynchronously uploads the current status to Nightscout, including OpenAPS status, pump status, and uploader details. @@ -577,7 +581,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // OpenAPS Status async let fetchedBattery = fetchBattery() - async let fetchedReservoir = Decimal(from: storage.retrieveRawAsync(OpenAPS.Monitor.reservoir) ?? "0") async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self) async let fetchedPumpStatus = pumpStatusForUpload() @@ -675,12 +678,12 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // Gather all relevant data for NS Status let battery = await fetchedBattery - let reservoir = await fetchedReservoir + let reservoir = apsManager.pumpManager?.currentReservoir let pumpStatus = await fetchedPumpStatus let pump = NSPumpStatus( clock: Date(), battery: battery, - reservoir: reservoir != 0xDEAD_BEEF ? reservoir : nil, + reservoir: reservoir.flatMap { $0 != 0xDEAD_BEEF ? $0 : nil }, status: pumpStatus, bolusIncrement: bolusIncrement ) diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 8b033d54c6..5b00c9fc3f 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -257,7 +257,7 @@ extension BaseTidepoolManager: ServiceDelegate { startDate _: Date? ) async throws {} - func deliverRemoteBolus(amountInUnits _: Double) async throws {} + func deliverRemoteBolus(amountInUnits _: Double, decisionId _: UUID?) async throws {} } /// Carb Upload and Deletion Functionality @@ -287,8 +287,8 @@ extension BaseTidepoolManager { let syncCarb: [SyncCarbObject] = Array(chunk).map { $0.convertSyncCarb() } - let result = await TidepoolUploadSerializer.awaitUpload("carbs") { completion in - tidepoolService.uploadCarbData(created: syncCarb, updated: [], deleted: [], completion: completion) + let result = await TidepoolUploadSerializer.awaitUpload("carbs") { + try await tidepoolService.uploadCarbData(created: syncCarb, updated: [], deleted: []) } switch result { @@ -347,8 +347,8 @@ extension BaseTidepoolManager { Task { [weak self] in await self?.uploadSerializer.enqueue(.carbsDelete) { _ in - let result = await TidepoolUploadSerializer.awaitUpload("carbs-delete") { completion in - tidepoolService.uploadCarbData(created: [], updated: [], deleted: syncCarb, completion: completion) + let result = await TidepoolUploadSerializer.awaitUpload("carbs-delete") { + try await tidepoolService.uploadCarbData(created: [], updated: [], deleted: syncCarb) } switch result { case .success: @@ -426,6 +426,7 @@ extension BaseTidepoolManager { endDate: event.timestamp, value: Double(programmed), unit: .units, + decisionId: nil, deliveredUnits: Double(amount), syncIdentifier: event.id, scheduledBasalRate: nil, @@ -471,8 +472,8 @@ extension BaseTidepoolManager { return (insulinDoseEvents, pumpEvents) } - let doseResult = await TidepoolUploadSerializer.awaitUpload("dose") { completion in - tidepoolService.uploadDoseData(created: insulinDoseEvents, deleted: [], completion: completion) + let doseResult = await TidepoolUploadSerializer.awaitUpload("dose") { + try await tidepoolService.uploadDoseData(created: insulinDoseEvents, deleted: []) } switch doseResult { case .success: @@ -487,8 +488,8 @@ extension BaseTidepoolManager { guard await uploadSerializer.isCurrent(generation) else { return } - let pumpResult = await TidepoolUploadSerializer.awaitUpload("pumpEvents") { completion in - tidepoolService.uploadPumpEventData(pumpEvents, completion: completion) + let pumpResult = await TidepoolUploadSerializer.awaitUpload("pumpEvents") { + try await tidepoolService.uploadPumpEventData(pumpEvents) } switch pumpResult { case .success: @@ -534,13 +535,14 @@ extension BaseTidepoolManager { startDate: at, value: Double(amount), unit: .units, + decisionId: nil, syncIdentifier: id )] Task { [weak self] in await self?.uploadSerializer.enqueue(.doseDelete) { _ in - let result = await TidepoolUploadSerializer.awaitUpload("dose-delete") { completion in - tidepoolService.uploadDoseData(created: [], deleted: doseDataToDelete, completion: completion) + let result = await TidepoolUploadSerializer.awaitUpload("dose-delete") { + try await tidepoolService.uploadDoseData(created: [], deleted: doseDataToDelete) } switch result { case .success: @@ -616,6 +618,7 @@ extension BaseTidepoolManager { endDate: adjustedEndDate, value: adjustedDeliveredUnits, unit: .units, + decisionId: nil, deliveredUnits: adjustedDeliveredUnits, syncIdentifier: predecessorEntrySyncIdentifier, insulinType: insulinType(for: predecessorEntry.insulinType), @@ -637,9 +640,10 @@ extension BaseTidepoolManager { endDate: currentEndDate, value: Double(value), unit: .units, + decisionId: nil, deliveredUnits: Double(value), syncIdentifier: event.id, - scheduledBasalRate: HKQuantity( + scheduledBasalRate: LoopQuantity( unit: .internationalUnitsPerHour, doubleValue: Double(currentBasalRate.rate) ), @@ -731,8 +735,8 @@ extension BaseTidepoolManager { for chunk in glucose.chunks(ofCount: tidepoolService.glucoseDataLimit ?? 100) { guard await uploadSerializer.isCurrent(generation) else { return } - let result = await TidepoolUploadSerializer.awaitUpload("glucose") { completion in - tidepoolService.uploadGlucoseData(chunk, completion: completion) + let result = await TidepoolUploadSerializer.awaitUpload("glucose") { + try await tidepoolService.uploadGlucoseData(chunk) } switch result { @@ -815,8 +819,8 @@ extension BaseTidepoolManager { return } - let result = await TidepoolUploadSerializer.awaitUpload("settings") { completion in - tidepoolService.uploadSettingsData([settings], completion: completion) + let result = await TidepoolUploadSerializer.awaitUpload("settings") { + try await tidepoolService.uploadSettingsData([settings]) } switch result { case .success: @@ -886,7 +890,7 @@ extension BaseTidepoolManager { let glucoseTargetRangeSchedule = convertBGTargets(bgTargets) let pumpDevice = apsManager.pumpManager?.status.device - let bgUnit: HKUnit = settingsManager.settings.units == .mmolL ? .millimolesPerLiter : .milligramsPerDeciliter + let bgUnit: LoopUnit = settingsManager.settings.units == .mmolL ? .millimolesPerLiter : .milligramsPerDeciliter // threshold_setting is always stored in mg/dL; TidepoolServiceKit calls // convertTo(unit:) internally, so we pass it through in its native unit @@ -901,10 +905,7 @@ extension BaseTidepoolManager { dosingEnabled: settingsManager.settings.dosingMode.automation != .off, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, preMealTargetRange: nil, - workoutTargetRange: nil, - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: nil, + overridePresets: [], maximumBasalRatePerHour: Double(pumpSettings.maxBasal), maximumBolus: Double(pumpSettings.maxBolus), suspendThreshold: suspendThreshold, @@ -943,12 +944,12 @@ extension BaseTidepoolManager { let startTime = TimeInterval(entry.offset * 60) return RepeatingScheduleValue(startTime: startTime, value: Double(entry.ratio)) } - return CarbRatioSchedule(unit: .gram(), dailyItems: items, timeZone: TimeZone.current) + return CarbRatioSchedule(unit: .gram, dailyItems: items, timeZone: TimeZone.current) } private func convertInsulinSensitivities(_ sensitivities: InsulinSensitivities) -> InsulinSensitivitySchedule? { // sensitivities.units comes from the data model itself, not the user's display preference - let unit: HKUnit = sensitivities.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter + let unit: LoopUnit = sensitivities.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter let items = sensitivities.sensitivities.map { entry in let startTime = TimeInterval(entry.offset * 60) return RepeatingScheduleValue(startTime: startTime, value: Double(entry.sensitivity)) @@ -958,7 +959,7 @@ extension BaseTidepoolManager { private func convertBGTargets(_ bgTargets: BGTargets) -> GlucoseRangeSchedule? { // bgTargets.units comes from the data model itself, not the user's display preference - let unit: HKUnit = bgTargets.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter + let unit: LoopUnit = bgTargets.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter let items = bgTargets.targets.map { entry in let startTime = TimeInterval(entry.offset * 60) let minValue = Double(entry.low) diff --git a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift index f452c3a9c1..5b21c1729a 100644 --- a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift +++ b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift @@ -175,4 +175,22 @@ extension TidepoolUploadSerializer { } } } + + /// Async variant for uploads that throw instead of calling back; keeps the same timeout. + static func awaitUpload( + _ label: String, + timeout: TimeInterval = 120, + _ operation: @escaping @Sendable() async throws -> Void + ) async -> Result { + await awaitUpload(label, timeout: timeout) { completion in + Task { + do { + try await operation() + completion(.success(true)) + } catch { + completion(.failure(error)) + } + } + } + } } diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index 7b4a3e0be0..dbd35bbe9c 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -229,7 +229,7 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { ) { let userInfo = notification.request.content.userInfo if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String { - completionHandler([.badge, .list]) + completionHandler([.badge, .sound, .list]) return } completionHandler([.banner, .badge, .sound, .list]) diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index c060c11980..c13ed8c825 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -242,13 +242,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana // Assign currentGlucose and its color /// Set current glucose with proper formatting - if self.units == .mgdL { - watchState.currentGlucose = "\(latestGlucose.glucose)" - } else { - let mgdlValue = Decimal(latestGlucose.glucose) - let latestGlucoseValue = mgdlValue.formattedAsMmolL - watchState.currentGlucose = "\(latestGlucoseValue)" - } + watchState.currentGlucose = Decimal(latestGlucose.glucose).formatted(for: self.units) /// Calculate latest color let hardCodedLow = Decimal(55) @@ -327,17 +321,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana // Calculate delta if we have at least 2 readings if glucoseObjects.count >= 2 { - var glucoseLast = Decimal(glucoseObjects[0].glucose) - var glucoseSecondLast = Decimal(glucoseObjects[1].glucose) - if self.units == .mmolL { - glucoseLast = glucoseLast.asMmolL - glucoseSecondLast = glucoseSecondLast.asMmolL - } - - let deltaValue = glucoseLast - glucoseSecondLast - let formattedDelta = Formatter.glucoseFormatter(for: self.units) - .string(from: deltaValue as NSNumber) ?? "0" - watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)" + let glucoseLast = Decimal(glucoseObjects[0].glucose) + let glucoseSecondLast = Decimal(glucoseObjects[1].glucose) + watchState.delta = (glucoseLast - glucoseSecondLast).formattedDelta(for: self.units) } // Set temp target presets with their enabled status diff --git a/Trio/Sources/Shortcuts/State/ListStateView.swift b/Trio/Sources/Shortcuts/State/ListStateView.swift index 1b7dd74de5..14670e14cf 100644 --- a/Trio/Sources/Shortcuts/State/ListStateView.swift +++ b/Trio/Sources/Shortcuts/State/ListStateView.swift @@ -12,27 +12,6 @@ struct ListStateView: View { return formatter } - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if state.unit == "mmolL" { - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - } - formatter.roundingMode = .halfUp - return formatter - } - - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - formatter.positivePrefix = " +" - formatter.negativePrefix = " -" - return formatter - } - private var timaAgoFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift index 2070129106..1bf8e42d10 100644 --- a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift +++ b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift @@ -76,21 +76,11 @@ final class StateIntentRequest: BaseIntentsRequest { let delta = results.count > 1 ? (lastGlucose - (secondLastGlucose ?? 0)) : nil /// formatting let units = settingsManager.settings.units - let glucoseAsString = glucoseFormatter.string(from: Double( - units == .mmolL ? Decimal(lastGlucose) - .asMmolL : Decimal(lastGlucose) - ) as NSNumber)! + let glucoseAsString = Decimal(lastGlucose).formatted(for: units) let directionAsString = lastValue.direction ?? String(localized: "none") - let deltaAsString = delta - .map { - self.deltaFormatter - .string(from: Double( - units == .mmolL ? Decimal($0) - .asMmolL : Decimal($0) - ) as NSNumber)! - } ?? "--" + let deltaAsString = delta.map { Decimal($0).formattedDelta(for: units) } ?? "--" return (lastValue.date ?? Date(), glucoseAsString, directionAsString, deltaAsString) } catch { warning(.storage, "Fetching latest glucose for shortcut failed", error: error) @@ -113,24 +103,4 @@ final class StateIntentRequest: BaseIntentsRequest { return (iobAsDouble, cobAsDouble) } - - private var glucoseFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - if settingsManager.settings.units == .mmolL { - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - } - formatter.roundingMode = .halfUp - return formatter - } - - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - formatter.positivePrefix = "+" - return formatter - } } diff --git a/Trio/Sources/Views/AlgorithmSettingHints.swift b/Trio/Sources/Views/AlgorithmSettingHints.swift index fc92f49f8c..5d11467e6b 100644 --- a/Trio/Sources/Views/AlgorithmSettingHints.swift +++ b/Trio/Sources/Views/AlgorithmSettingHints.swift @@ -88,7 +88,7 @@ enum AlgorithmSettingHints { ) static func highTempTargetRaisesSensitivityMini(units: GlucoseUnits) -> String { String( - localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set.", + localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(100.formatted(for: units)) \(units.rawValue) is set.", comment: "Mini-hint for High Temp Target Raises Sensitivity — interpolated values are the threshold number and the glucose unit" ) } @@ -97,10 +97,10 @@ enum AlgorithmSettingHints { VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "When this feature is enabled, manually setting a temporary target above \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used." + "When this feature is enabled, manually setting a temporary target above \(100.formatted(for: units)) \(units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used." ) Text( - "If Half Basal Exercise Target is set to \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), a temp target of \(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(units == .mgdL ? "140" : 140.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 0.6." + "If Half Basal Exercise Target is set to \(160.formatted(for: units)) \(units.rawValue), a temp target of \(120.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(140.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 0.6." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") } @@ -112,7 +112,7 @@ enum AlgorithmSettingHints { ) static func lowTempTargetLowersSensitivityMini(units: GlucoseUnits) -> String { String( - localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set.", + localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(100.formatted(for: units)) \(units.rawValue) is set.", comment: "Mini-hint for Low Temp Target Lowers Sensitivity — interpolated values are the threshold number and the glucose unit" ) } @@ -121,10 +121,10 @@ enum AlgorithmSettingHints { VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "When this feature is enabled, setting a temporary target below \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) will increase the Autosens Ratio used for ISF and basal adjustments, resulting in more insulin delivered overall. This scales with the temporary target set; the lower the Temp Target, the higher the Autosens Ratio used. It requires Algorithm Settings > Autosens > Autosens Max to be set to > 100% to work." + "When this feature is enabled, setting a temporary target below \(100.formatted(for: units)) \(units.rawValue) will increase the Autosens Ratio used for ISF and basal adjustments, resulting in more insulin delivered overall. This scales with the temporary target set; the lower the Temp Target, the higher the Autosens Ratio used. It requires Algorithm Settings > Autosens > Autosens Max to be set to > 100% to work." ) Text( - "If Half Basal Exercise Target is \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), a Temp Target of \(units == .mgdL ? "95" : 95.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(units == .mgdL ? "85" : 85.formattedAsMmolL) \(units.rawValue) uses an Autosens Ratio of 1.33." + "If Half Basal Exercise Target is \(160.formatted(for: units)) \(units.rawValue), a Temp Target of \(95.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(85.formatted(for: units)) \(units.rawValue) uses an Autosens Ratio of 1.33." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") } @@ -175,14 +175,14 @@ enum AlgorithmSettingHints { @ViewBuilder static func halfBasalExerciseTargetVerbose(units: GlucoseUnits) -> some View { VStack(alignment: .leading, spacing: 10) { Text( - "Default: \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue)" + "Default: \(160.formatted(for: units)) \(units.rawValue)" ) .bold() Text( "The Half Basal Exercise Target allows you to scale down your basal insulin during exercise or scale up your basal insulin when eating soon when a temporary glucose target is set." ) Text( - "For example, at a temp target of \(units == .mgdL ? "160" : 160.formattedAsMmolL) \(units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue), 60% at \(units == .mgdL ? "140" : 140.formattedAsMmolL) \(units.rawValue))." + "For example, at a temp target of \(160.formatted(for: units)) \(units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(120.formatted(for: units)) \(units.rawValue), 60% at \(140.formatted(for: units)) \(units.rawValue))." ) Text( "Note: This setting is only utilized if the settings \"Low Temp Target Lowers Sensitivity\" OR \"High Temp Target Raises Sensitivity\" are enabled." @@ -233,7 +233,7 @@ enum AlgorithmSettingHints { ) static func enableSMBWithTemptargetMini(units: GlucoseUnits) -> String { String( - localized: "Allow SMB when a manual Temporary Target is set under \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue).", + localized: "Allow SMB when a manual Temporary Target is set under \(100.formatted(for: units)) \(units.rawValue).", comment: "Mini-hint for Enable SMB With Temptarget — interpolated values are the threshold number and the glucose unit" ) } @@ -242,7 +242,7 @@ enum AlgorithmSettingHints { VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) at times when a manual Temporary Target under \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) at times when a manual Temporary Target under \(100.formatted(for: units)) \(units.rawValue) is set." ) Text( "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." @@ -297,7 +297,7 @@ enum AlgorithmSettingHints { ) static func allowSMBWithHighTemptargetMini(units: GlucoseUnits) -> String { String( - localized: "Allow SMB when a manual Temporary Target is set greater than \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue).", + localized: "Allow SMB when a manual Temporary Target is set greater than \(100.formatted(for: units)) \(units.rawValue).", comment: "Mini-hint for Allow SMB With High Temptarget — interpolated values are the threshold number and the glucose unit" ) } @@ -306,7 +306,7 @@ enum AlgorithmSettingHints { VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) when a manual Temporary Target above \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) is set." + "Enabling this feature allows Trio to deliver insulin required using Super Micro Boluses (SMB) when a manual Temporary Target above \(100.formatted(for: units)) \(units.rawValue) is set." ) Text( "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." @@ -713,12 +713,12 @@ enum AlgorithmSettingHints { @ViewBuilder static func oddTargetDisablesSMBVerbose(units: GlucoseUnits) -> some View { VStack(alignment: .leading, spacing: 10) { Text( - "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of \(units == .mgdL ? "121" : 121.formattedAsMmolL) \(units.rawValue) will prevent any SMB's in that time window. Schedule a TT of \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) at 3:20 am and from then on SMB's can be enacted." + "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of \(121.formatted(for: units)) \(units.rawValue) will prevent any SMB's in that time window. Schedule a TT of \(100.formatted(for: units)) \(units.rawValue) at 3:20 am and from then on SMB's can be enacted." ) Divider() Text("Full-Loop mode").bold() Text( - "A special case: setting an even-numbered temp target below \(units == .mgdL ? "100" : 100.formattedAsMmolL) \(units.rawValue) (e.g. \(units == .mgdL ? "80 or 90" : "\(80.formattedAsMmolL) or \(90.formattedAsMmolL)") \(units.rawValue)) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." + "A special case: setting an even-numbered temp target below \(100.formatted(for: units)) \(units.rawValue) (e.g. \(units == .mgdL ? "80 or 90" : "\(80.formatted(for: units)) or \(90.formatted(for: units))") \(units.rawValue)) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." ) Text( "In full-loop mode, the SMB delivery ratio becomes the greater of the fixed SMB DeliveryRatio and the linearly-rising ramp value — so the fixed ratio acts as a floor the ramp can only raise, never lower." @@ -1018,14 +1018,14 @@ enum AlgorithmSettingHints { ) static func smbDeliveryRatioBGrangeMini(units: GlucoseUnits) -> String { String( - localized: "How far above your BG target the delivery ratio ramps up. Sensible values are \(units == .mgdL ? "40" : 40.formattedAsMmolL)–\(units == .mgdL ? "120" : 120.formattedAsMmolL) \(units.rawValue). Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead.", + localized: "How far above your BG target the delivery ratio ramps up. Sensible values are \(40.formatted(for: units))–\(120.formatted(for: units)) \(units.rawValue). Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead.", comment: "SMB DeliveryRatio BG Range miniHint" ) } @ViewBuilder static func smbDeliveryRatioBGrangeVerbose(units: GlucoseUnits) -> some View { VStack(alignment: .leading, spacing: 10) { - Text("Typical: \(units == .mgdL ? "90" : 90.formattedAsMmolL) \(units.rawValue)") + Text("Typical: \(90.formatted(for: units)) \(units.rawValue)") .bold() Text( "Instead of a single fixed ratio, the delivery ratio can rise linearly with glucose: starting cautiously at SMB DeliveryRatio BG Minimum when glucose is at your BG target, and reaching the more ambitious SMB DeliveryRatio BG Maximum once glucose is this many points above target." @@ -1254,7 +1254,7 @@ enum AlgorithmSettingHints { "Min 5m Carb Impact sets the expected glucose rise from carbs over 5 minutes when absorption isn't obvious from glucose data." ) Text( - "The default is an expected \(units == .mgdL ? "8" : 8.formattedAsMmolL) \(units.rawValue)/5min. This affects how fast COB is decayed in situations when carb absorption is not visible in BG deviations. The default of \(units == .mgdL ? "8" : 8.formattedAsMmolL) \(units.rawValue)/5min corresponds to a minimum carb absorption rate of 24 g/hr at a CSF of \(units == .mgdL ? "4" : 4.formattedAsMmolL) \(units.rawValue)/g." + "The default is an expected \(8.formatted(for: units)) \(units.rawValue)/5min. This affects how fast COB is decayed in situations when carb absorption is not visible in BG deviations. The default of \(8.formatted(for: units)) \(units.rawValue)/5min corresponds to a minimum carb absorption rate of 24 g/hr at a CSF of \(4.formatted(for: units)) \(units.rawValue)/g." ) Text( "This setting helps the system estimate how much glucose your body is absorbing, even when it's not immediately visible in your glucose data, ensuring more accurate insulin dosing during carb absorption." @@ -1363,7 +1363,7 @@ enum AlgorithmSettingHints { @ViewBuilder static func b30iTimeTargetVerbose(units: GlucoseUnits) -> some View { Text( String( - localized: "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like \(units == .mgdL ? "80" : 80.formattedAsMmolL) \(units.rawValue). Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions.", + localized: "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like \(80.formatted(for: units)) \(units.rawValue). Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions.", comment: "B30 TT Level VerboseHint" ) ) @@ -1417,7 +1417,7 @@ enum AlgorithmSettingHints { static let b30upperLimitLabel = String(localized: "Upper BG Limit for B30", comment: "B30 Upper BG Limit") static func b30upperLimitMini(units: GlucoseUnits) -> String { String( - localized: "B30 will only run & supress SMB as long as BG stays underneath that level. Default is \(units == .mgdL ? "130" : 130.formattedAsMmolL) \(units.rawValue).", + localized: "B30 will only run & supress SMB as long as BG stays underneath that level. Default is \(130.formatted(for: units)) \(units.rawValue).", comment: "B30 Upper BG Limit miniHint" ) } @@ -1425,7 +1425,7 @@ enum AlgorithmSettingHints { @ViewBuilder static func b30upperLimitVerbose(units: GlucoseUnits) -> some View { Text( String( - localized: "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is \(units == .mgdL ? "130" : 130.formattedAsMmolL) \(units.rawValue).", + localized: "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is \(130.formatted(for: units)) \(units.rawValue).", comment: "B30 Upper BG Limit VerboseHint" ) ) @@ -1434,7 +1434,7 @@ enum AlgorithmSettingHints { static let b30upperDeltaLabel = String(localized: "Upper Delta Limit for B30", comment: "B30 Upper Delta") static func b30upperDeltaMini(units: GlucoseUnits) -> String { String( - localized: "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is \(units == .mgdL ? "8" : 8.formattedAsMmolL) \(units.rawValue).", + localized: "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is \(8.formatted(for: units)) \(units.rawValue).", comment: "B30 Upper Delta miniHint" ) } @@ -1442,7 +1442,7 @@ enum AlgorithmSettingHints { @ViewBuilder static func b30upperDeltaVerbose(units: GlucoseUnits) -> some View { Text( String( - localized: "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is \(units == .mgdL ? "8" : 8.formattedAsMmolL) \(units.rawValue).", + localized: "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is \(8.formatted(for: units)) \(units.rawValue).", comment: "B30 Upper Delta VerboseHint" ) ) diff --git a/Trio/Sources/Views/BolusProgressViewStyle.swift b/Trio/Sources/Views/BolusProgressViewStyle.swift deleted file mode 100644 index a0049c3092..0000000000 --- a/Trio/Sources/Views/BolusProgressViewStyle.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -public struct BolusProgressViewStyle: ProgressViewStyle { - public func makeBody(configuration: LinearProgressViewStyle.Configuration) -> some View { - ZStack { - Circle() - .stroke(lineWidth: 4.0) - .opacity(0.3) - .foregroundColor(.secondary) - .frame(width: 22, height: 22) - - Rectangle().fill(Color.insulin) - .frame(width: 8, height: 8) - - Circle() - .trim(from: 0.0, to: CGFloat(configuration.fractionCompleted ?? 0)) - .stroke(style: StrokeStyle(lineWidth: 4.0, lineCap: .butt, lineJoin: .round)) - .foregroundColor(.insulin) - .rotationEffect(Angle(degrees: -90)) - .frame(width: 22, height: 22) - }.frame(width: 30, height: 30) - } -} diff --git a/Trio/Sources/Views/SettingInputHintView.swift b/Trio/Sources/Views/SettingInputHintView.swift index fcc062b839..bb23426ddd 100644 --- a/Trio/Sources/Views/SettingInputHintView.swift +++ b/Trio/Sources/Views/SettingInputHintView.swift @@ -19,6 +19,7 @@ struct SettingInputHintView: View { } .scrollContentBackground(.hidden) .navigationBarTitle(sheetTitle, displayMode: .inline) + .padding(.horizontal) Spacer() @@ -29,8 +30,9 @@ struct SettingInputHintView: View { } .buttonStyle(.bordered) .padding(.top) + .padding(.horizontal) } - .padding() + .padding(.vertical) .presentationDetents( [.fraction(0.9), .large], selection: $hintDetent diff --git a/Trio/Sources/Views/SettingInputSection.swift b/Trio/Sources/Views/SettingInputSection.swift index 9b4c059dc6..7a47cd2729 100644 --- a/Trio/Sources/Views/SettingInputSection.swift +++ b/Trio/Sources/Views/SettingInputSection.swift @@ -157,8 +157,7 @@ struct SettingInputSection: View { private func displayString(for setting: PickerSetting, decimalValue: Decimal) -> String { switch setting.type { case .glucose: - let displayValue = units == .mmolL ? decimalValue.asMmolL : decimalValue - return "\(displayValue.description) \(units.rawValue)" + return decimalValue.formatted(withUnits: units) case .factor: return "\(decimalValue * 100) \(String(localized: "%", comment: "Percentage symbol"))" case .factorRaw: @@ -182,8 +181,7 @@ struct SettingInputSection: View { private func accessibilityValueString(for setting: PickerSetting, decimalValue: Decimal) -> String { switch setting.type { case .glucose: - let displayValue = units == .mmolL ? decimalValue.asMmolL : decimalValue - return "\(displayValue.description) \(units.spokenValue)" + return decimalValue.formatted(for: units) + " " + units.spokenValue case .factor: return "\(decimalValue * 100) \(UnitSpelling.spoken("%"))" case .factorRaw: diff --git a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift index 855dfc5bd5..5ac215076d 100644 --- a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift +++ b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift @@ -1,5 +1,6 @@ import CoreData import Foundation +import LoopKit import Swinject import Testing @@ -57,6 +58,7 @@ import Testing startDate: date, value: units, unit: .units, + decisionId: nil, deliveredUnits: deliveredUnits, description: nil, syncIdentifier: syncIdentifier, @@ -89,6 +91,7 @@ import Testing endDate: end, value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: deliveredUnits, description: nil, syncIdentifier: syncIdentifier, @@ -746,6 +749,7 @@ import Testing endDate: start.addingTimeInterval(claimedDuration), value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: syncIdentifier, diff --git a/TrioTests/CoreDataTests/PumpEventPredicateTests.swift b/TrioTests/CoreDataTests/PumpEventPredicateTests.swift index 877c7f789f..89d9334868 100644 --- a/TrioTests/CoreDataTests/PumpEventPredicateTests.swift +++ b/TrioTests/CoreDataTests/PumpEventPredicateTests.swift @@ -42,6 +42,7 @@ import Testing startDate: date, value: 1.0, unit: .units, + decisionId: nil, deliveredUnits: 1.0, description: nil, syncIdentifier: "predicate-bolus", @@ -66,6 +67,7 @@ import Testing endDate: end, value: 0.8, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: "predicate-sbr", diff --git a/TrioTests/CoreDataTests/PumpHistoryStorageTests.swift b/TrioTests/CoreDataTests/PumpHistoryStorageTests.swift index ed212b2d73..cbc87f6ea2 100644 --- a/TrioTests/CoreDataTests/PumpHistoryStorageTests.swift +++ b/TrioTests/CoreDataTests/PumpHistoryStorageTests.swift @@ -59,6 +59,7 @@ import Testing startDate: date, value: 0.5, unit: .units, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: nil, @@ -146,6 +147,7 @@ import Testing startDate: tenMinAgo, value: 0.4, unit: .units, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: nil, @@ -168,6 +170,7 @@ import Testing endDate: halfHourInFuture, value: 1.2, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: nil, @@ -238,6 +241,7 @@ import Testing startDate: date, value: 4, unit: .units, + decisionId: nil, deliveredUnits: nil, description: nil, syncIdentifier: nil, diff --git a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift new file mode 100644 index 0000000000..2e02037c44 --- /dev/null +++ b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift @@ -0,0 +1,186 @@ +import CoreData +import Foundation +import HealthKit +import Swinject +import Testing + +@testable import LoopKit +@testable import Trio + +@Suite("Pump Status Storage Tests", .serialized) struct PumpStatusStorageTests: Injectable { + @Injected() var storage: PumpStatusStorage! + let resolver: Resolver + var coreDataStack: CoreDataStack! + var testContext: NSManagedObjectContext! + + init() async throws { + coreDataStack = try await CoreDataStack.createForTests() + testContext = coreDataStack.newTaskContext() + + let assembler = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: testContext) + ]) + + resolver = assembler.resolver + injectServices(resolver) + } + + private func status(_ basalDeliveryState: PumpManagerStatus.BasalDeliveryState?) -> PumpManagerStatus { + PumpManagerStatus( + timeZone: .current, + device: HKDevice( + name: "Pump", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, + softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil + ), + pumpBatteryChargeRemaining: nil, + basalDeliveryState: basalDeliveryState, + bolusState: .noBolus, + insulinType: nil, + deliveryIsUncertain: false + ) + } + + private func tempBasal() -> PumpManagerStatus.BasalDeliveryState { + .tempBasal(DoseEntry( + type: .tempBasal, + startDate: Date(), + endDate: Date().addingTimeInterval(30 * 60), + value: 1.2, + unit: .unitsPerHour, + decisionId: nil + )) + } + + /// Stored rows, oldest first. Runs on the storage's context, after the stores it has queued. + private func storedRows() async -> [(state: String?, date: Date?)] { + await testContext.perform { + let request = PumpStatusStored.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpStatusStored.date, ascending: true)] + let rows = (try? testContext.fetch(request)) ?? [] + return rows.map { ($0.basalDeliveryState, $0.date) } + } + } + + @Test("Storage is correctly initialized") func testStorageInitialization() { + #expect(storage is BasePumpStatusStorage) + } + + @Test("No stored status returns nil") func testNoStoredStatus() async { + #expect(await storage.lastPumpStatus() == nil) + } + + @Test("Temp basals and their transitions are stored as one active status") func testTempBasalsCollapse() async { + storage.storePumpStatus(status(.active(Date()))) + storage.storePumpStatus(status(.initiatingTempBasal)) + storage.storePumpStatus(status(tempBasal())) + storage.storePumpStatus(status(.cancelingTempBasal)) + storage.storePumpStatus(status(.active(Date()))) + + let rows = await storedRows() + #expect(rows.map(\.state) == ["active"]) + } + + @Test("A suspend and resume are stored in order") func testSuspendResumeHistory() async { + let suspendDate = Date() + storage.storePumpStatus(status(.active(Date()))) + storage.storePumpStatus(status(.suspending)) + storage.storePumpStatus(status(.suspended(suspendDate))) + storage.storePumpStatus(status(.suspended(suspendDate))) + storage.storePumpStatus(status(.resuming)) + storage.storePumpStatus(status(.active(Date()))) + + let rows = await storedRows() + #expect(rows.map(\.state) == ["active", "suspending", "suspended", "resuming", "active"]) + let dates = rows.compactMap(\.date) + #expect(dates.count == rows.count) + #expect(dates == dates.sorted()) + } + + @Test("Delivery active since the distant past is stored as inactive") func testInactive() async throws { + storage.storePumpStatus(status(.active(Date()))) + storage.storePumpStatus(status(.active(.distantPast))) + + #expect(await storedRows().map(\.state) == ["active", "inactive"]) + let inactive = try #require(await storage.lastPumpStatus()) + #expect(inactive.suspended) + } + + @Test("A removed pump is stored as unknown") func testPumpRemoved() async throws { + storage.storePumpStatus(status(.active(Date()))) + storage.storePumpRemoved() + storage.storePumpRemoved() + + #expect(await storedRows().map(\.state) == ["active", "unknown"]) + let removed = try #require(await storage.lastPumpStatus()) + #expect(removed.suspended) + } + + @Test("Statuses older than 90 days are purged, keeping the latest") func testPurge() async throws { + let old = Date().addingTimeInterval(-91 * 24 * 60 * 60) + await testContext.perform { + for (offset, state) in [(-3600.0, "active"), (0.0, "suspended")] { + let stored = PumpStatusStored(context: testContext) + stored.date = old.addingTimeInterval(offset) + stored.basalDeliveryState = state + } + try? testContext.save() + } + + await storage.deleteStatuses(olderThan: 90) + #expect(await storedRows().map(\.state) == ["suspended"]) + + storage.storePumpStatus(status(.active(Date()))) + await storage.deleteStatuses(olderThan: 90) + #expect(await storedRows().map(\.state) == ["active"]) + } + + @Test("The legacy status file is migrated into a row") func testLegacyMigration() async throws { + let fileStorage = try #require(resolver.resolve(FileStorage.self)) + let legacyDate = Date().addingTimeInterval(-3600) + fileStorage.save( + PumpStatus(status: .suspended, bolusing: false, suspended: true, timestamp: legacyDate), + as: "monitor/status.json" + ) + + _ = BasePumpStatusStorage(resolver: resolver, contextProvider: { testContext }) + + let rows = await storedRows() + #expect(rows.map(\.state) == ["suspended"]) + let date = try #require(rows.first?.date) + #expect(abs(date.timeIntervalSince(legacyDate)) < 1) + #expect(fileStorage.retrieve("monitor/status.json", as: PumpStatus.self) == nil) + } + + @Test("The last status maps to a pump status") func testLastPumpStatus() async throws { + storage.storePumpStatus(status(.suspended(Date()))) + let suspended = try #require(await storage.lastPumpStatus()) + #expect(suspended.suspended) + #expect(suspended.status == .suspended) + #expect(!suspended.bolusing) + let suspendedDate = await storedRows().last?.date + #expect(suspended.timestamp == suspendedDate) + + storage.storePumpStatus(status(.resuming)) + let resuming = try #require(await storage.lastPumpStatus()) + #expect(!resuming.suspended) + #expect(resuming.status == .normal) + + storage.storePumpStatus(status(nil)) + let unknown = try #require(await storage.lastPumpStatus()) + #expect(unknown.suspended) + #expect(await storedRows().map(\.state) == ["suspended", "resuming", "unknown"]) + } + + @Test("An inoperable pump blocks the loop like a suspended one") func testInoperableBlocksLoop() { + #expect(status(.pumpInoperable).pumpStatus.suspended) + #expect(status(.suspended(Date())).pumpStatus.suspended) + #expect(!status(.active(Date())).pumpStatus.suspended) + #expect(!status(tempBasal()).pumpStatus.suspended) + } +} diff --git a/TrioTests/CoreDataTests/TestAssembly.swift b/TrioTests/CoreDataTests/TestAssembly.swift index d593f9b219..7e15168946 100644 --- a/TrioTests/CoreDataTests/TestAssembly.swift +++ b/TrioTests/CoreDataTests/TestAssembly.swift @@ -28,6 +28,10 @@ class TestAssembly: Assembly { BasePumpHistoryStorage(resolver: r, contextProvider: { self.testContext }) }.inObjectScope(.container) + container.register(PumpStatusStorage.self) { r in + BasePumpStatusStorage(resolver: r, contextProvider: { self.testContext }) + }.inObjectScope(.container) + // Override DeterminationStorage registration for tests container.register(DeterminationStorage.self) { r in BaseDeterminationStorage(resolver: r, contextProvider: { self.testContext }) diff --git a/TrioTests/DeliveryLimitsSyncTests.swift b/TrioTests/DeliveryLimitsSyncTests.swift index be186c13a6..72fca0b36e 100644 --- a/TrioTests/DeliveryLimitsSyncTests.swift +++ b/TrioTests/DeliveryLimitsSyncTests.swift @@ -1,5 +1,4 @@ import Foundation -import HealthKit import LoopKit import Testing @@ -11,8 +10,8 @@ import Testing /// expression in `DeviceDataManager.swift` so a regression that re-introduces /// the default-collapse bug breaks here too. @Suite("Delivery Limits Sync Tests") struct DeliveryLimitsSyncTests { - private let basalUnit = HKUnit.internationalUnitsPerHour - private let bolusUnit = HKUnit.internationalUnit() + private let basalUnit = LoopUnit.internationalUnitsPerHour + private let bolusUnit = LoopUnit.internationalUnit private func makeSettings(maxBasal: Decimal, maxBolus: Decimal) -> PumpSettings { PumpSettings(insulinActionCurve: 6, maxBolus: maxBolus, maxBasal: maxBasal) @@ -20,8 +19,8 @@ import Testing private func deliveryLimits(from settings: PumpSettings) -> DeliveryLimits { DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), - maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(settings.maxBolus)) + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(settings.maxBasal)), + maximumBolus: LoopQuantity(unit: .internationalUnit, doubleValue: Double(settings.maxBolus)) ) } diff --git a/TrioTests/DeviceCatalogTests.swift b/TrioTests/DeviceCatalogTests.swift index d0dfacbf9f..b7f76c678f 100644 --- a/TrioTests/DeviceCatalogTests.swift +++ b/TrioTests/DeviceCatalogTests.swift @@ -11,6 +11,18 @@ import Testing import UIKit @Suite("Device Catalog Tests") struct DeviceCatalogTests { + /// Persisted `pluginIdentifier` per CGM manager type; LoopKit only exposes it per instance. + private static let cgmPluginIdentifiers = [ + "LibreTransmitterManagerV3": "LibreTransmitterManagerV3", + "LibreLoopCGMManager": "LibreLoopCGMManager", + "G5CGMManager": "DexG5Transmitter", + "G6CGMManager": "DexG6Transmitter", + "G7CGMManager": "G7CGMManager", + "AccuChekCgmManager": "AccuChek", + "EversenseCGMManager": "EversenseKit", + "SyaiCGMManager": "SyaiCGMManager" + ] + // MARK: - Integrity @Test("Catalog identifiers are unique") func testIdentifiersAreUnique() { @@ -21,12 +33,18 @@ import UIKit #expect(Set(pumpIDs).count == pumpIDs.count, "Duplicate pump identifier in catalog: \(pumpIDs)") } - @Test("Pump entry ids match their manager's plugin identifier") func testPumpIdentifiersMatchManagers() { + /// LoopKit only exposes `pluginIdentifier` per instance, so the catalog states it; these are persisted values. + @Test("Pump entry ids are their manager's persisted plugin identifier") func testPumpIdentifiersMatchManagers() { + let expected = [ + "OmniPumpManager": "Omni", + "MinimedPumpManager": "Minimed500", + "MedtrumPumpManager": "Medtrum", + "DanaKitPumpManager": "Dana", + "MockPumpManager": "MockPumpManager" + ] for entry in DeviceCatalog.pumps { - #expect( - entry.id == entry.manager.pluginIdentifier, - "\(entry.name) id \(entry.id) != \(entry.manager.pluginIdentifier)" - ) + let manager = String(describing: entry.manager) + #expect(entry.id == expected[manager], "\(manager) id \(entry.id) != \(expected[manager] ?? "nil")") } } @@ -65,7 +83,7 @@ import UIKit for entry in DeviceCatalog.cgms { if entry.managerType != nil { #expect(entry.cgmType == .plugin, "\(entry.name) is manager-backed so it must persist as .plugin") - #expect(entry.id == entry.managerType?.pluginIdentifier) + #expect(entry.id == entry.managerType.flatMap { Self.cgmPluginIdentifiers[String(describing: $0)] }) } else { #expect(entry.cgmType.rawValue == entry.id, "\(entry.name) id must be its CGMType raw value") } diff --git a/TrioTests/GlucoseFormattingTests.swift b/TrioTests/GlucoseFormattingTests.swift new file mode 100644 index 0000000000..30e0d43d25 --- /dev/null +++ b/TrioTests/GlucoseFormattingTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import Trio + +/// Every case runs in English and French; locale should only swap the decimal point for a comma. +@Suite("Glucose Formatting") struct GlucoseFormattingTests { + private let english = Locale(identifier: "en_US") + private let french = Locale(identifier: "fr_FR") + + @Test("mg/dL shows whole numbers and rounds halves away from zero", arguments: [ + (Decimal(120), "120"), (120.4, "120"), (120.5, "121") + ]) func mgdL(input: Decimal, expected: String) { + #expect(input.formatted(for: .mgdL, locale: english) == expected) + #expect(input.formatted(for: .mgdL, locale: french) == expected) + } + + @Test("mmol/L converts from mg/dL and always shows one decimal", arguments: [ + (Decimal(70), "3.9"), (108, "6.0"), (180, "10.0") + ]) func mmolL(input: Decimal, expected: String) { + #expect(input.formatted(for: .mmolL, locale: english) == expected) + #expect(input.formatted(for: .mmolL, locale: french) == expected.replacing(".", with: ",")) + } + + @Test("Delta always shows its sign, including zero", arguments: [ + (Decimal(0), "+0.0"), (18, "+1.0"), (-9, "-0.5") + ]) func delta(input: Decimal, expected: String) { + #expect(input.formattedDelta(for: .mmolL, locale: english) == expected) + #expect(input.formattedDelta(for: .mmolL, locale: french) == expected.replacing(".", with: ",")) + } + + @Test("ISF keeps its mg/dL decimal so the bolus math on screen adds up", arguments: [ + (Decimal(43.6), "43.6"), (44, "44") + ]) func isf(input: Decimal, expected: String) { + #expect(input.formattedISF(for: .mgdL, locale: english) == expected) + #expect(input.formattedISF(for: .mgdL, locale: french) == expected.replacing(".", with: ",")) + } +} diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift index 7a83000d1c..17660bd5ec 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -146,7 +146,7 @@ import UIKit timestamp: $0 ) } - #expect(PersistentDeviceLog(storageFile: file).addStoredDeviceLogEntries(entries: seeded) == nil) + try await PersistentDeviceLog(storageFile: file).addStoredDeviceLogEntries(entries: seeded) let store = DeviceLogStore( storageURL: file, diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 6b7a73ea31..98a7b5cb11 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -8,18 +8,18 @@ import Testing @Suite("Log Export Tests", .serialized) struct LogExportTests { private class StubManager: DeviceManager { - class var pluginIdentifier: String { "com.example.pump" } + var pluginIdentifier: String { "com.example.pump" } var localizedTitle: String { "Example Pump" } var debugDescription: String { "## StubManager\nserial: SYN-PUMP-0042" } var rawState: RawStateValue { [:] } var isOnboarded: Bool { true } + var inSignalLoss: Bool { false } + var isInoperable: Bool { false } init() {} required init?(rawState _: RawStateValue) {} - func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - } + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier) async throws {} func getSoundBaseURL() -> URL? { nil } func getSounds() -> [Alert.Sound] { [] } @@ -27,7 +27,7 @@ import Testing } private final class StubCGMManager: StubManager { - override class var pluginIdentifier: String { "com.example.cgm" } + override var pluginIdentifier: String { "com.example.cgm" } override var localizedTitle: String { "Example CGM" } override var debugDescription: String { "## StubCGMManager\nstate: synthetic" } } diff --git a/TrioTests/Logging/LoggingLongRunStressTests.swift b/TrioTests/Logging/LoggingLongRunStressTests.swift index 4c038f0c67..5d3316dff9 100644 --- a/TrioTests/Logging/LoggingLongRunStressTests.swift +++ b/TrioTests/Logging/LoggingLongRunStressTests.swift @@ -14,18 +14,18 @@ import Testing private typealias Fixtures = LoggingStressFixtures private class StubManager: DeviceManager { - class var pluginIdentifier: String { LoggingStressFixtures.pumpManager } + var pluginIdentifier: String { LoggingStressFixtures.pumpManager } var localizedTitle: String { "Stress Pump" } var debugDescription: String { "## StubManager\npumpSN: \(LoggingStressFixtures.pumpSerial)" } var rawState: RawStateValue { [:] } var isOnboarded: Bool { true } + var inSignalLoss: Bool { false } + var isInoperable: Bool { false } init() {} required init?(rawState _: RawStateValue) {} - func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - } + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier) async throws {} func getSoundBaseURL() -> URL? { nil } func getSounds() -> [Alert.Sound] { [] } @@ -33,7 +33,7 @@ import Testing } private final class StubCGMManager: StubManager { - override class var pluginIdentifier: String { LoggingStressFixtures.cgmManager } + override var pluginIdentifier: String { LoggingStressFixtures.cgmManager } override var localizedTitle: String { "Stress CGM" } override var debugDescription: String { "## StubCGMManager\ntransmitterID: \(LoggingStressFixtures.transmitterID)" } } diff --git a/TrioTests/Logging/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift index 86f8e57ff1..0bdaffa815 100644 --- a/TrioTests/Logging/LoggingRobustnessStressTests.swift +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -75,18 +75,18 @@ private extension Duration { } private class StubManager: DeviceManager { - class var pluginIdentifier: String { "com.example.pump" } + var pluginIdentifier: String { "com.example.pump" } var localizedTitle: String { "Example Pump" } var debugDescription: String { "## StubManager\nserial: SYN-DEV-0042" } var rawState: RawStateValue { [:] } var isOnboarded: Bool { true } + var inSignalLoss: Bool { false } + var isInoperable: Bool { false } init() {} required init?(rawState _: RawStateValue) {} - func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - } + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier) async throws {} func getSoundBaseURL() -> URL? { nil } func getSounds() -> [Alert.Sound] { [] } @@ -94,7 +94,7 @@ private extension Duration { } private final class StubCGMManager: StubManager { - override class var pluginIdentifier: String { "com.example.cgm" } + override var pluginIdentifier: String { "com.example.cgm" } override var localizedTitle: String { "Example CGM" } } diff --git a/TrioTests/Logging/LoggingVolumeStressTests.swift b/TrioTests/Logging/LoggingVolumeStressTests.swift index cde4c9d676..49ae06f985 100644 --- a/TrioTests/Logging/LoggingVolumeStressTests.swift +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -13,18 +13,18 @@ import Testing private typealias Fixtures = LoggingStressFixtures private class StubManager: DeviceManager { - class var pluginIdentifier: String { LoggingStressFixtures.pumpManager } + var pluginIdentifier: String { LoggingStressFixtures.pumpManager } var localizedTitle: String { "Stress Pump" } var debugDescription: String { "## StubManager\npumpSN: \(LoggingStressFixtures.pumpSerial)" } var rawState: RawStateValue { [:] } var isOnboarded: Bool { true } + var inSignalLoss: Bool { false } + var isInoperable: Bool { false } init() {} required init?(rawState _: RawStateValue) {} - func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - } + func acknowledgeAlert(alertIdentifier _: Alert.AlertIdentifier) async throws {} func getSoundBaseURL() -> URL? { nil } func getSounds() -> [Alert.Sound] { [] } @@ -32,7 +32,7 @@ import Testing } private final class StubCGMManager: StubManager { - override class var pluginIdentifier: String { LoggingStressFixtures.cgmManager } + override var pluginIdentifier: String { LoggingStressFixtures.cgmManager } override var localizedTitle: String { "Stress CGM" } override var debugDescription: String { "## StubCGMManager\ntransmitterID: \(LoggingStressFixtures.transmitterID)" } } diff --git a/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift b/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift index 84e8b6c1db..3feadb9550 100644 --- a/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift +++ b/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift @@ -10,7 +10,6 @@ import Testing iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData: Decimal, glucoseStatus: GlucoseStatus, microBolusAllowed: Bool, trioCustomOrefVariables: TrioCustomOrefVariables, @@ -124,7 +123,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: 100, glucoseStatus: glucoseStatus, microBolusAllowed: true, trioCustomOrefVariables: trioCustomOrefVariables, @@ -141,7 +139,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -159,7 +156,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -178,7 +174,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -205,7 +200,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -227,7 +221,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -255,7 +248,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -277,7 +269,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -305,7 +296,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -327,7 +317,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -344,7 +333,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -363,7 +351,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -391,7 +378,6 @@ import Testing iobData: mutableIobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -418,7 +404,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -446,7 +431,6 @@ import Testing iobData: mutableIobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -472,7 +456,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -489,7 +472,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -508,7 +490,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -534,7 +515,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -561,7 +541,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -578,7 +557,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -610,7 +588,7 @@ import Testing @Test("Basal testing holds the scheduled rate where other modes would trim it") func basalTestingHoldsScheduledBasal() throws { let ( defaultProfile, preferences, _, iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, currentTime + _, microBolusAllowed, trioCustomOrefVariables, currentTime ) = createDefaultInputs() let glucoseStatus = fallingGlucoseInputs(currentTime: currentTime) let currentTemp = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: currentTime) @@ -626,7 +604,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, @@ -649,7 +626,7 @@ import Testing @Test("Basal testing still suspends for a genuine low") func basalTestingStillSuspends() throws { let ( defaultProfile, preferences, _, iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, currentTime + _, microBolusAllowed, trioCustomOrefVariables, currentTime ) = createDefaultInputs() var profile = defaultProfile profile.suspendOnly = true @@ -674,7 +651,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, autoISFStatus: nil, diff --git a/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift b/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift index 48cda91e37..d7a3f45ab9 100644 --- a/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift +++ b/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift @@ -10,7 +10,6 @@ import Testing iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData: Decimal, glucoseStatus: GlucoseStatus, trioCustomOrefVariables: TrioCustomOrefVariables, microBolusAllowed: Bool, @@ -127,7 +126,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: 0, glucoseStatus: glucoseStatus, trioCustomOrefVariables: trioCustomOrefVariables, microBolusAllowed: true, @@ -146,7 +144,6 @@ import Testing iobData: inputs.iobData, mealData: inputs.mealData, autosensData: inputs.autosensData, - reservoirData: inputs.reservoirData, glucoseStatus: inputs.glucoseStatus, microBolusAllowed: inputs.microBolusAllowed, autoISFStatus: nil, @@ -169,7 +166,6 @@ import Testing iobData: inputs.iobData, mealData: inputs.mealData, autosensData: inputs.autosensData, - reservoirData: inputs.reservoirData, glucoseStatus: inputs.glucoseStatus, microBolusAllowed: inputs.microBolusAllowed, autoISFStatus: nil, @@ -222,7 +218,6 @@ import Testing iobData: inputs.iobData, mealData: inputs.mealData, autosensData: inputs.autosensData, - reservoirData: inputs.reservoirData, glucoseStatus: inputs.glucoseStatus, microBolusAllowed: inputs.microBolusAllowed, autoISFStatus: autoISFStatus, diff --git a/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift b/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift index f8d75ef544..924e05e79c 100644 --- a/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift +++ b/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift @@ -11,7 +11,6 @@ struct ParityScenario { let preferences: Preferences let orefVariables: TrioCustomOrefVariables let currentTemp: TempBasal - let reservoir: Decimal let pumpSettings: PumpSettings let bgTargets: BGTargets let basalProfile: [BasalProfileEntry] @@ -205,7 +204,6 @@ enum ParityScenarios { preferences: preferences, orefVariables: orefVariables, currentTemp: currentTemp, - reservoir: 150, pumpSettings: ParityInputs.pumpSettings(), bgTargets: ParityInputs.bgTargets(), basalProfile: ParityInputs.basalProfile(), @@ -282,7 +280,6 @@ enum ParityScenarios { iobData: iob, mealData: mealData, autosensData: autosens, - reservoirData: scenario.reservoir, glucose: scenario.glucose, pumpHistory: scenario.pumpHistory, microBolusAllowed: true, diff --git a/TrioTests/PumpStatusSuspendTests.swift b/TrioTests/PumpStatusSuspendTests.swift index eb2d1027d3..b5927fa890 100644 --- a/TrioTests/PumpStatusSuspendTests.swift +++ b/TrioTests/PumpStatusSuspendTests.swift @@ -1,4 +1,5 @@ import Foundation +import LoopKit import Testing @testable import Trio @@ -54,4 +55,17 @@ import Testing let suspended = status(suspended: true) #expect(suspended.includingSuspend(deliveryActiveSince: .distantPast, lastPumpEvent: nil) == suspended) } + + @Test("Delivery states without delivery map to suspended") func deliveryStateMapping() { + for state in [PumpStatusStored.DeliveryState.suspended, .inactive, .unknown] { + #expect(PumpStatus(deliveryState: state, bolusing: false, timestamp: nil).suspended) + } + for state in [PumpStatusStored.DeliveryState.active, .suspending, .resuming] { + #expect(!PumpStatus(deliveryState: state, bolusing: false, timestamp: nil).suspended) + } + #expect(PumpStatus(deliveryState: .active, bolusing: true, timestamp: nil).status == .bolusing) + #expect(PumpStatus(deliveryState: .suspended, bolusing: true, timestamp: nil).status == .suspended) + #expect(PumpStatusStored.DeliveryState(.active(.distantPast)) == .inactive) + #expect(PumpStatusStored.DeliveryState(nil) == .unknown) + } } diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index c07a935764..4c8696733b 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -8,9 +8,9 @@ import TidepoolKit @testable import Trio // Both Trio and TidepoolServiceKit define mgPerDL, -// causing ambiguity. Use HealthKit's native API to avoid the conflict. -private let mgPerDL = HKUnit.gramUnit(with: .milli).unitDivided(by: HKUnit.literUnit(with: .deci)) -private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) +// causing ambiguity. Name the LoopKit units directly to avoid the conflict. +private let mgPerDL = LoopUnit.milligramsPerDeciliter +private let mmolPerL = LoopUnit.millimolesPerLiter // MARK: - StoredSettings → Tidepool Datum Tests @@ -38,7 +38,6 @@ private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassB "\"bgTargets\"", "\"carbRatios\"", "\"insulinSensitivities\"", - "\"automatedDelivery\"", "\"name\" : \"Trio\"", "\"version\" : \"0.6.0\"" ] @@ -147,19 +146,6 @@ private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassB #expect(datum.bolus?.amountMaximum?.value == 1.0, "Should preserve low max bolus") } - // MARK: - Automated Delivery Flag - - @Test("Automated delivery flag reflects dosing state") func automatedDeliveryFlag() { - let enabled = makeSettings(dosingEnabled: true) - let disabled = makeSettings(dosingEnabled: false) - - let enabledDatum = enabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") - let disabledDatum = disabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") - - #expect(enabledDatum.automatedDelivery == true) - #expect(disabledDatum.automatedDelivery == false) - } - // MARK: - Unit Conversion @Test("mmol/L values are converted to mg/dL by Tidepool") func mmolLUnitConversion() { @@ -240,7 +226,7 @@ private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassB insulinModel: StoredInsulinModel? = nil, cgmDevice: HKDevice? = nil, pumpDevice: HKDevice? = nil, - bgUnit: HKUnit = mgPerDL + bgUnit: LoopUnit = mgPerDL ) -> StoredSettings { let tz = TimeZone(secondsFromGMT: 0)! @@ -268,7 +254,7 @@ private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassB )! let defaultCarb = CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )! @@ -279,10 +265,7 @@ private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassB dosingEnabled: dosingEnabled, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule ?? defaultTarget, preMealTargetRange: nil, - workoutTargetRange: nil, - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: nil, + overridePresets: [], maximumBasalRatePerHour: maxBasal, maximumBolus: maxBolus, suspendThreshold: suspendThreshold, @@ -499,10 +482,7 @@ private extension StoredSettings { override: nil ), preMealTargetRange: nil, - workoutTargetRange: nil, - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: nil, + overridePresets: [], maximumBasalRatePerHour: 5.0, maximumBolus: 10.0, suspendThreshold: nil, @@ -520,7 +500,7 @@ private extension StoredSettings { timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, @@ -549,10 +529,7 @@ private extension StoredSettings { override: nil ), preMealTargetRange: nil, - workoutTargetRange: nil, - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: nil, + overridePresets: [], maximumBasalRatePerHour: nil, maximumBolus: nil, suspendThreshold: nil, @@ -568,7 +545,7 @@ private extension StoredSettings { timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, diff --git a/dexcom-share-client-swift b/dexcom-share-client-swift index ff0d714334..e7eda40295 160000 --- a/dexcom-share-client-swift +++ b/dexcom-share-client-swift @@ -1 +1 @@ -Subproject commit ff0d71433447af3c93dac450d95c14b63f58f417 +Subproject commit e7eda402954511016c53443270778d688736040e diff --git a/scripts/define_common_trio.sh b/scripts/define_common_trio.sh index 287937cb89..837c71fb86 100755 --- a/scripts/define_common_trio.sh +++ b/scripts/define_common_trio.sh @@ -10,28 +10,28 @@ # source scripts/define_commont_trio.sh # define the TRIO_PROJECTS used by Trio where for Trio the .gitmodules points -# to the downstream fork of loopandlearn in all cases +# to the downstream fork of loopandlearn (SyaiKit: nightscout) # -# LoopKit needs its trio branch, and LibreLoop its legacy-loopkit-compat branch, -# which builds against that LoopKit +# Kits track trio-next-dev (upstream next-dev plus Trio carries); OmnipodKit and +# dexcom-share-client-swift track next-dev and RoundWhiteDiscKit main # # There are scripts available in LoopWorkspace that update translations and make # sure the loopandlearn branches are up to date TRIO_PROJECTS=( \ - loopandlearn:AccuChekKit:master \ - loopandlearn:CGMBLEKit:dev \ - loopandlearn:DanaKit:dev \ - loopandlearn:dexcom-share-client-swift:dev \ - loopandlearn:EversenseKit:dev \ - loopandlearn:G7SensorKit:feat/trio-g7-direct \ - loopandlearn:LibreLoop:legacy-loopkit-compat \ - loopandlearn:LibreTransmitter:main \ - loopandlearn:LoopKit:trio \ - loopandlearn:MedtrumKit:dev \ - loopandlearn:MinimedKit:main \ - loopandlearn:OmnipodKit:dev \ - loopandlearn:RileyLinkKit:dev \ + loopandlearn:AccuChekKit:trio-next-dev \ + loopandlearn:CGMBLEKit:trio-next-dev \ + loopandlearn:DanaKit:trio-next-dev \ + loopandlearn:dexcom-share-client-swift:next-dev \ + loopandlearn:EversenseKit:trio-next-dev \ + loopandlearn:G7SensorKit:trio-next-dev \ + loopandlearn:LibreLoop:trio-next-dev \ + loopandlearn:LibreTransmitter:trio-next-dev \ + loopandlearn:LoopKit:trio-next-dev \ + loopandlearn:MedtrumKit:trio-next-dev \ + loopandlearn:MinimedKit:trio-next-dev \ + loopandlearn:OmnipodKit:next-dev \ + loopandlearn:RileyLinkKit:trio-next-dev \ loopandlearn:RoundWhiteDiscKit:main \ - loopandlearn:TidepoolService:dev \ - loopandlearn:LoopAlgorithm:main \ + nightscout:SyaiKit:trio-next-dev \ + loopandlearn:TidepoolService:trio-next-dev \ ) diff --git a/scripts/swiftformat.sh b/scripts/swiftformat.sh index 7b0d467e3c..14b3f85420 100755 --- a/scripts/swiftformat.sh +++ b/scripts/swiftformat.sh @@ -122,7 +122,6 @@ trailingClosures \ --exclude OmnipodKit \ --exclude LibreLoop \ --exclude RoundWhiteDiscKit \ ---exclude LoopAlgorithm \ --exclude AccuChekKit \ --exclude EversenseKit \ --exclude SyaiKit