From aeafc774673ef72020763275bcfb8ec9f8f1c391 Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 08:01:18 -0500 Subject: [PATCH 01/69] fix(therapy): Size editor save bars relative to their containers Replace screen-based widths with container-relative sizing in basal profile, carb ratio, ISF, and target editors. Extend save bar backgrounds across horizontal safe areas. --- .../View/BasalProfileEditorRootView.swift | 8 +++++--- .../CarbRatioEditor/View/CarbRatioEditorRootView.swift | 7 ++++--- .../Modules/ISFEditor/View/ISFEditorRootView.swift | 7 ++++--- .../TargetsEditor/View/TargetsEditorRootView.swift | 8 +++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift b/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift index 3382687dcf..2a4edea7c3 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/View/BasalProfileEditorRootView.swift @@ -88,11 +88,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 { @@ -107,10 +108,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/CarbRatioEditor/View/CarbRatioEditorRootView.swift b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift index 720c2274bc..128611c883 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift @@ -25,11 +25,12 @@ extension CarbRatioEditor { 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 +51,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/ISFEditor/View/ISFEditorRootView.swift b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift index 634e3e9673..60c9f058be 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/TargetsEditor/View/TargetsEditorRootView.swift b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift index 5882a20c2c..edefa97f31 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) From 35e60ec34f827d231a3b24786e720ec99994f04e Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 20:00:01 -0500 Subject: [PATCH 02/69] fix(sheets): Pin list top margin in Treatments and adjustment forms Pin the List's top content margin to 35 pt in the Treatments sheet and the add and edit override and temp target forms. The iPhone Duo's closed display defaults it to 0, which put the first row under the sheet's bar. --- .../Modules/Adjustments/View/Overrides/AddOverrideForm.swift | 2 ++ .../Modules/Adjustments/View/Overrides/EditOverrideForm.swift | 2 ++ .../Adjustments/View/TempTargets/AddTempTargetForm.swift | 2 ++ .../Adjustments/View/TempTargets/EditTempTargetForm.swift | 2 ++ Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift | 2 ++ 5 files changed, 10 insertions(+) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift index ff1f175db9..420a85abc3 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift @@ -28,6 +28,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/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index e70dd9f858..98267c9ab2 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -85,6 +85,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/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index 875e5afd92..62cbee078b 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) diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift index 2265a71860..4d870754f2 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) diff --git a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift index 1c9730174d..3971b6ffcf 100644 --- a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift +++ b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift @@ -439,6 +439,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) } From 262cf39cfb877e1b03fa14a3e0431783a07a024e Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 20:00:02 -0500 Subject: [PATCH 03/69] fix(help): Pad help sheets inside their navigation stacks Keep only vertical padding on the NavigationStack in SettingInputHintView and the override, temp target, and contact image help views. Pad the List and Got it! button horizontally instead, so the sheet's horizontal safe-area inset applies once. --- .../Modules/Adjustments/View/Overrides/OverrideHelpView.swift | 4 +++- .../Adjustments/View/TempTargets/TempTargetHelpView.swift | 4 +++- .../Modules/ContactImage/View/ContactImageHelpView.swift | 4 +++- Trio/Sources/Views/SettingInputHintView.swift | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) 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/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/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/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 From 0b23a263f790d8d5fd8f4d90ca4f51cf4b15877f Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 20:00:03 -0500 Subject: [PATCH 04/69] fix(ui): Size sticky action bars relative to their containers Replace screen-based widths with container-relative sizing in the Add Contact, Edit Contact, CGM options, Adjustments stop and History meal editor bars. Extend the bar backgrounds across horizontal safe areas. --- .../View/Overrides/AdjustmentsRootView+Overrides.swift | 6 ++++-- .../View/TempTargets/AdjustmentsRootView+TempTargets.swift | 6 ++++-- .../Modules/CGMSettings/View/CustomCGMOptionsView.swift | 6 ++++-- .../Modules/ContactImage/View/AddContactImageSheet.swift | 5 +++-- .../Modules/ContactImage/View/ContactImageDetailView.swift | 5 +++-- Trio/Sources/Modules/History/View/CarbEntryEditorView.swift | 6 ++++-- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 508831d705..90e751da35 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) diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift index 78d602e467..03878b9f5b 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) diff --git a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift index 4e3f257c16..766cb3a2b3 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift @@ -461,12 +461,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() @@ -475,7 +476,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/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/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) From c6bd339b0e5d51706b31fb6c0a8f9673fe629d95 Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 20:00:04 -0500 Subject: [PATCH 05/69] fix(ui): Size error buttons and widget preview relative to containers Replace screen-based widths with container-relative sizing for the load-error Retry button, the migration-error Proceed button and the Live Activity widget configuration preview. --- .../View/LiveActivityWidgetConfiguration.swift | 2 +- Trio/Sources/Modules/Main/View/MainLoadingView.swift | 3 ++- Trio/Sources/Modules/Main/View/MainMigrationErrorView.swift | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) 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 dfd19c9ec2..636607bd5c 100644 --- a/Trio/Sources/Modules/Main/View/MainLoadingView.swift +++ b/Trio/Sources/Modules/Main/View/MainLoadingView.swift @@ -66,7 +66,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 d13007ae7a..04a3aebc2b 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) From 5897074581dd3d39f972621f348cce5024ac3af7 Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Tue, 27 Oct 2026 21:12:39 -0500 Subject: [PATCH 06/69] ci: Re-run unit tests From 8c3404afa659a7988ac2f735326344da52f0a571 Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Thu, 29 Oct 2026 13:00:46 +0100 Subject: [PATCH 07/69] Remove unused code and dead configuration Removes unused AddCarbs, Autotune, and Libre configuration screens, unused helper extensions (Array, ConcurrentMap, MD5), progress views, and their Xcode project references. No behavior change. --- Trio.xcodeproj/project.pbxproj | 20 -- .../Extensions/ComputedBGTargets+Getter.swift | 11 - .../Sources/Extensions/Color+Extensions.swift | 0 Trio/Sources/Helpers/Array+Extension.swift | 11 - Trio/Sources/Helpers/ConcurrentMap.swift | 18 - Trio/Sources/Helpers/MD5.swift | 17 - Trio/Sources/Helpers/ProgressBar.swift | 25 -- Trio/Sources/Helpers/SettingsRowView.swift | 57 ---- .../Modules/AddCarbs/AddCarbsStateModel.swift | 180 ---------- .../AddCarbs/View/AddCarbsRootView.swift | 307 ------------------ .../AutotuneConfigDataFlow.swift | 10 - .../AutotuneConfigProvider.swift | 15 - .../AutotuneConfigStateModel.swift | 115 ------- .../View/AutotuneConfigRootView.swift | 198 ----------- .../View/LibreConfigRootView.swift | 55 ---- .../Settings/View/SettingsRootViewModel.swift | 23 -- .../Views/BolusProgressViewStyle.swift | 23 -- 17 files changed, 1085 deletions(-) delete mode 100644 Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift delete mode 100644 Trio/Sources/Extensions/Color+Extensions.swift delete mode 100644 Trio/Sources/Helpers/Array+Extension.swift delete mode 100644 Trio/Sources/Helpers/ConcurrentMap.swift delete mode 100644 Trio/Sources/Helpers/MD5.swift delete mode 100644 Trio/Sources/Helpers/ProgressBar.swift delete mode 100644 Trio/Sources/Helpers/SettingsRowView.swift delete mode 100644 Trio/Sources/Modules/AddCarbs/AddCarbsStateModel.swift delete mode 100644 Trio/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift delete mode 100644 Trio/Sources/Modules/AutotuneConfig/AutotuneConfigDataFlow.swift delete mode 100644 Trio/Sources/Modules/AutotuneConfig/AutotuneConfigProvider.swift delete mode 100644 Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift delete mode 100644 Trio/Sources/Modules/AutotuneConfig/View/AutotuneConfigRootView.swift delete mode 100644 Trio/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift delete mode 100644 Trio/Sources/Modules/Settings/View/SettingsRootViewModel.swift delete mode 100644 Trio/Sources/Views/BolusProgressViewStyle.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index cac7c4de35..ea320af7be 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -84,7 +84,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 */; }; @@ -135,7 +134,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 */; }; @@ -211,10 +209,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 */; }; @@ -1087,7 +1083,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 */; }; @@ -1316,7 +1311,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 = ""; }; @@ -1381,7 +1375,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 = ""; }; @@ -1435,11 +1428,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 = ""; }; @@ -2300,7 +2291,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 = ""; }; @@ -3184,7 +3174,6 @@ 3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */, 383420D825FFEB3F002D46C1 /* Popup.swift */, 389ECDFD2601061500D86C4F /* View+Snapshot.swift */, - 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */, DDB0BBA02026050100000002 /* BolusProgressBar.swift */, 38DF1785276A73D400B3528F /* TagCloudView.swift */, DD88C8E12C50420800F2D558 /* DefinitionRow.swift */, @@ -3332,7 +3321,6 @@ 388E5A5A25B6F05F0019842D /* Helpers */ = { isa = PBXGroup; children = ( - E06B9119275B5EEA003C04B6 /* Array+Extension.swift */, DD73FA0E2D74F57300D19D1E /* BackgroundTask+Helper.swift */, 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */, DD1DB7CB2BECCA1F0048B367 /* BuildDetails.swift */, @@ -3340,7 +3328,6 @@ 7A11AC0DE000000000000130 /* UIHostingController+KeyboardSafeArea.swift */, BD249DA62D42FE3800412DEB /* Calendar+GlucoseStatsChart.swift */, 38F37827261260DC009DB701 /* Color+Extensions.swift */, - 389ECE042601144100D86C4F /* ConcurrentMap.swift */, C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */, 38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */, BD1661302B82ADAB00256551 /* CustomProgressView.swift */, @@ -3356,10 +3343,8 @@ 388E5A5B25B6F0770019842D /* JSON.swift */, 38A00B2225FC2B55006BC0B0 /* LRUCache.swift */, 582DF9782C8CE1E5001F516D /* MainChartHelper.swift */, - 38FCF3D525E8FDF40078B0D1 /* MD5.swift */, 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */, 38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */, - 3811DE5725C9D4D500A708ED /* ProgressBar.swift */, DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */, 3811DEE325CA063400A708ED /* PropertyWrappers */, 3811DE5525C9D4D500A708ED /* Publisher.swift */, @@ -5979,12 +5964,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 */, @@ -6152,8 +6135,6 @@ 38E44536274E411700EC9A94 /* Disk.swift in Sources */, 19A910362A24D6D700C8951B /* DateFilter.swift in Sources */, 582DF9792C8CE1E5001F516D /* MainChartHelper.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 */, @@ -6255,7 +6236,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 */, 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/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/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/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/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 5616d18281..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 { - storage.save(basals, as: OpenAPS.Settings.basalProfile) - 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.storage.save(basals, as: OpenAPS.Settings.basalProfile) - 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/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/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/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) - } -} From fcaa8f6f68d94bbcddcae696807c3e9bab3b6d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 30 Oct 2026 19:00:52 +0100 Subject: [PATCH 08/69] Store the pump status in Core Data --- .../PumpStatusStored+CoreDataClass.swift | 4 + .../PumpStatusStored+CoreDataProperties.swift | 14 ++ Model/Helper/PumpStatusStored+helper.swift | 34 ++++ .../contents | 8 + Trio.xcodeproj/project.pbxproj | 20 +++ Trio/Sources/APS/APSManager.swift | 2 - Trio/Sources/APS/DeviceDataManager.swift | 5 +- Trio/Sources/APS/OpenAPS/Constants.swift | 1 - .../APS/Storage/PumpStatusStorage.swift | 99 ++++++++++ Trio/Sources/Application/TrioApp.swift | 2 + Trio/Sources/Assemblies/StorageAssembly.swift | 1 + .../Nightscout/NightscoutManager.swift | 5 +- .../PumpStatusStorageTests.swift | 170 ++++++++++++++++++ TrioTests/CoreDataTests/TestAssembly.swift | 4 + 14 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 Model/Classes+Properties/PumpStatusStored+CoreDataClass.swift create mode 100644 Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift create mode 100644 Model/Helper/PumpStatusStored+helper.swift create mode 100644 Trio/Sources/APS/Storage/PumpStatusStorage.swift create mode 100644 TrioTests/CoreDataTests/PumpStatusStorageTests.swift 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..e329712be5 --- /dev/null +++ b/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift @@ -0,0 +1,14 @@ +import CoreData +import Foundation + +public extension PumpStatusStored { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "PumpStatusStored") + } + + @NSManaged var basalDeliveryState: String? + @NSManaged var date: Date? + @NSManaged var deliveryIsUncertain: Bool +} + +extension PumpStatusStored: Identifiable {} diff --git a/Model/Helper/PumpStatusStored+helper.swift b/Model/Helper/PumpStatusStored+helper.swift new file mode 100644 index 0000000000..ffbadba5e6 --- /dev/null +++ b/Model/Helper/PumpStatusStored+helper.swift @@ -0,0 +1,34 @@ +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 + } + + /// Suspended for any state without delivery. Without a pump manager nothing is bolusing, and the + /// timestamp is the upload time, as for a status read from the pump manager. + var pumpStatus: PumpStatus { + let suspended = [.suspended, .inactive, .unknown].contains(deliveryState) + return PumpStatus(status: suspended ? .suspended : .normal, bolusing: false, suspended: suspended, timestamp: Date()) + } +} diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index 724222d335..b8b52ab6ae 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -232,6 +232,14 @@ + + + + + + + + diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index b16689df41..03eea915f8 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, ); }; }; @@ -1250,6 +1255,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; @@ -3411,6 +3421,7 @@ 38A0362725ECF05300FCBB52 /* Storage */ = { isa = PBXGroup; children = ( + A0E3EA2038BADFBCBC8801A4 /* PumpStatusStorage.swift */, 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */, 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */, DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */, @@ -3813,6 +3824,7 @@ 5825D1622BD405AE00F36E9B /* Helper */ = { isa = PBXGroup; children = ( + 1EE4D8D4BA1BD69A89CA1C13 /* PumpStatusStored+helper.swift */, 49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */, 581516A82BCEEDF800BF67D7 /* NSPredicates.swift */, 583684052BD178DB00070A60 /* GlucoseStored+helper.swift */, @@ -4332,6 +4344,7 @@ BD8FC0552D66187700B95AED /* CoreDataTests */ = { isa = PBXGroup; children = ( + 5C7EA1FA38A1728182C949F4 /* PumpStatusStorageTests.swift */, BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */, 7A11AC0DE00000000000000C /* ScheduledBasalInferenceTests.swift */, 7A11AC0DE000000000000009 /* PumpEventFinalizationTests.swift */, @@ -5060,6 +5073,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 */, @@ -5736,6 +5751,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 */, @@ -6510,6 +6529,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 477F2885616B2839D22FF0A4 /* PumpStatusStorageTests.swift in Sources */, 3B5CD2C92D4AECD500CE213C /* ProfileCarbsTests.swift in Sources */, 3BEDC9932EEB27B600AC6492 /* IobConsecutiveEventsTests.swift in Sources */, 3B1DB90F2E63C14C00BD814B /* DetermineBasalLowEventualGlucoseTests.swift in Sources */, diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 3775a4d0f3..e32e7cd941 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -1557,8 +1557,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) } } diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 95356ef6e3..9f432bfd2b 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -40,6 +40,7 @@ private let accessLock = NSRecursiveLock(label: "BaseDeviceDataManager.accessLoc final class BaseDeviceDataManager: DeviceDataManager, Injectable { private let processQueue = DispatchQueue.markedQueue(label: "BaseDeviceDataManager.processQueue", qos: .userInitiated) @Injected() private var pumpHistoryStorage: PumpHistoryStorage! + @Injected() private var pumpStatusStorage: PumpStatusStorage! @Injected() var alertHistoryStorage: AlertHistoryStorage! @Injected() private var storage: FileStorage! @Injected() private var broadcaster: Broadcaster! @@ -445,8 +446,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: @@ -538,6 +538,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { func pumpManagerWillDeactivate(_: PumpManager) { dispatchPrecondition(condition: .onQueue(processQueue)) pumpManager = nil + pumpStatusStorage.storePumpRemoved() broadcaster.notify(PumpDeactivatedObserver.self, on: processQueue) { $0.pumpDeactivatedDidChange() } diff --git a/Trio/Sources/APS/OpenAPS/Constants.swift b/Trio/Sources/APS/OpenAPS/Constants.swift index 238a184a1e..3d7757fe22 100644 --- a/Trio/Sources/APS/OpenAPS/Constants.swift +++ b/Trio/Sources/APS/OpenAPS/Constants.swift @@ -18,7 +18,6 @@ extension OpenAPS { 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/Storage/PumpStatusStorage.swift b/Trio/Sources/APS/Storage/PumpStatusStorage.swift new file mode 100644 index 0000000000..fb2a8c57b3 --- /dev/null +++ b/Trio/Sources/APS/Storage/PumpStatusStorage.swift @@ -0,0 +1,99 @@ +import CoreData +import Foundation +import LoopKit +import Swinject + +protocol PumpStatusStorage { + /// Stores the status when its delivery state or uncertainty 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? +} + +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) + removeLegacyStatusFile() + } + + func storePumpStatus(_ status: PumpManagerStatus) { + store(Self.deliveryState(of: status), deliveryIsUncertain: status.deliveryIsUncertain) + } + + func storePumpRemoved() { + store(.unknown, deliveryIsUncertain: false) + } + + 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 + } + } + } + + private func store(_ deliveryState: PumpStatusStored.DeliveryState, deliveryIsUncertain: Bool) { + let date = Date() + + context.perform { [context] in + do { + if let last = try context.fetch(PumpStatusStored.fetchLast()).first, + last.deliveryState == deliveryState, + last.deliveryIsUncertain == deliveryIsUncertain + { + return + } + + let stored = PumpStatusStored(context: context) + stored.date = date + stored.basalDeliveryState = deliveryState.rawValue + stored.deliveryIsUncertain = deliveryIsUncertain + try context.save() + debug(.storage, "Pump status stored: \(deliveryState.rawValue), delivery uncertain \(deliveryIsUncertain)") + } catch { + context.rollback() + warning(.storage, "Saving pump status failed", error: error) + } + } + } + + private func removeLegacyStatusFile() { // FIXME: this can be removed in later releases + guard let url = fileStorage.urlFor(file: Self.legacyStatusFile), + FileManager.default.fileExists(atPath: url.path) + else { return } + fileStorage.remove(Self.legacyStatusFile) + } + + private static func deliveryState(of status: PumpManagerStatus) -> PumpStatusStored.DeliveryState { + switch status.basalDeliveryState { + case .none: + return .unknown + case let .active(since) where since == .distantPast: + return .inactive + case .suspending: + return .suspending + case .suspended: + return .suspended + case .resuming: + return .resuming + case .active, + .cancelingTempBasal, + .initiatingTempBasal, + .tempBasal: + return .active + } + } +} diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 5058b745eb..9c12a7a05d 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -498,6 +498,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: () = coreDataStack.batchDeleteOlderThan(PumpStatusStored.self, dateKey: "date", days: 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( @@ -520,6 +521,7 @@ extension Notification.Name { try await tempBasalDeletion try await determinationDeletion try await batteryDeletion + try 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 9431a0bd77..0ec8b8f8e6 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/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index a206f005f6..720e0a8624 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,10 +500,10 @@ 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 diff --git a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift new file mode 100644 index 0000000000..f3f33ba294 --- /dev/null +++ b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift @@ -0,0 +1,170 @@ +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?, + deliveryIsUncertain: Bool = false + ) -> 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: deliveryIsUncertain + ) + } + + private func tempBasal() -> PumpManagerStatus.BasalDeliveryState { + .tempBasal(DoseEntry( + type: .tempBasal, + startDate: Date(), + endDate: Date().addingTimeInterval(30 * 60), + value: 1.2, + unit: .unitsPerHour + )) + } + + /// Stored rows, oldest first. Runs on the storage's context, after the stores it has queued. + private func storedRows() async -> [(state: String?, uncertain: Bool, 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.deliveryIsUncertain, $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") func testPurge() async throws { + await testContext.perform { + let old = PumpStatusStored(context: testContext) + old.date = Date().addingTimeInterval(-91 * 24 * 60 * 60) + old.basalDeliveryState = PumpStatusStored.DeliveryState.suspended.rawValue + try? testContext.save() + } + storage.storePumpStatus(status(.active(Date()))) + #expect(await storedRows().count == 2) + + try await coreDataStack.batchDeleteOlderThan(PumpStatusStored.self, dateKey: "date", days: 90) + + #expect(await storedRows().map(\.state) == ["active"]) + } + + @Test("A change in delivery uncertainty is stored") func testDeliveryUncertainty() async { + storage.storePumpStatus(status(tempBasal())) + storage.storePumpStatus(status(tempBasal(), deliveryIsUncertain: true)) + storage.storePumpStatus(status(tempBasal(), deliveryIsUncertain: true)) + storage.storePumpStatus(status(.active(Date()))) + + let rows = await storedRows() + #expect(rows.map(\.state) == ["active", "active", "active"]) + #expect(rows.map(\.uncertain) == [false, true, false]) + } + + @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) + #expect(suspended.timestamp != nil) + + 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"]) + } +} diff --git a/TrioTests/CoreDataTests/TestAssembly.swift b/TrioTests/CoreDataTests/TestAssembly.swift index 10bd3ef687..d581e6eebc 100644 --- a/TrioTests/CoreDataTests/TestAssembly.swift +++ b/TrioTests/CoreDataTests/TestAssembly.swift @@ -16,6 +16,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 }) From 92b86a78722ed53ae1bb3582d1c48951d3f56295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 31 Oct 2026 09:36:35 +0100 Subject: [PATCH 09/69] Keep the stored pump status across removals, migration and purges, with one upload mapping for both paths --- .../PumpStatusStored+CoreDataProperties.swift | 1 - Model/Helper/PumpStatusStored+helper.swift | 6 +- .../contents | 1 - Trio/Sources/APS/DeviceDataManager.swift | 5 +- .../APS/Storage/PumpStatusStorage.swift | 79 +++++++++++++------ Trio/Sources/Application/TrioApp.swift | 4 +- Trio/Sources/Models/PumpStatus.swift | 14 +++- .../Nightscout/NightscoutManager.swift | 9 ++- .../PumpStatusStorageTests.swift | 52 ++++++------ TrioTests/PumpStatusSuspendTests.swift | 14 ++++ 10 files changed, 125 insertions(+), 60 deletions(-) diff --git a/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift b/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift index e329712be5..22146ac994 100644 --- a/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift +++ b/Model/Classes+Properties/PumpStatusStored+CoreDataProperties.swift @@ -8,7 +8,6 @@ public extension PumpStatusStored { @NSManaged var basalDeliveryState: String? @NSManaged var date: Date? - @NSManaged var deliveryIsUncertain: Bool } extension PumpStatusStored: Identifiable {} diff --git a/Model/Helper/PumpStatusStored+helper.swift b/Model/Helper/PumpStatusStored+helper.swift index ffbadba5e6..1519f7ccee 100644 --- a/Model/Helper/PumpStatusStored+helper.swift +++ b/Model/Helper/PumpStatusStored+helper.swift @@ -25,10 +25,8 @@ extension PumpStatusStored { basalDeliveryState.flatMap(DeliveryState.init(rawValue:)) ?? .unknown } - /// Suspended for any state without delivery. Without a pump manager nothing is bolusing, and the - /// timestamp is the upload time, as for a status read from the pump manager. + /// Without a pump manager nothing is bolusing, and the timestamp is when the stored state began. var pumpStatus: PumpStatus { - let suspended = [.suspended, .inactive, .unknown].contains(deliveryState) - return PumpStatus(status: suspended ? .suspended : .normal, bolusing: false, suspended: suspended, timestamp: Date()) + PumpStatus(deliveryState: deliveryState, bolusing: false, timestamp: date) } } diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index b8b52ab6ae..314fcbd07f 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -235,7 +235,6 @@ - diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 9f432bfd2b..bbcd25bea5 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -168,6 +168,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) @@ -538,7 +542,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate { func pumpManagerWillDeactivate(_: PumpManager) { dispatchPrecondition(condition: .onQueue(processQueue)) pumpManager = nil - pumpStatusStorage.storePumpRemoved() broadcaster.notify(PumpDeactivatedObserver.self, on: processQueue) { $0.pumpDeactivatedDidChange() } diff --git a/Trio/Sources/APS/Storage/PumpStatusStorage.swift b/Trio/Sources/APS/Storage/PumpStatusStorage.swift index fb2a8c57b3..776a5557a0 100644 --- a/Trio/Sources/APS/Storage/PumpStatusStorage.swift +++ b/Trio/Sources/APS/Storage/PumpStatusStorage.swift @@ -4,11 +4,13 @@ import LoopKit import Swinject protocol PumpStatusStorage { - /// Stores the status when its delivery state or uncertainty differs from the last stored status. + /// 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 { @@ -23,15 +25,15 @@ final class BasePumpStatusStorage: PumpStatusStorage, Injectable { context = contextProvider?() ?? CoreDataStack.shared.newTaskContext() context.name = "pumpStatusStorage" injectServices(resolver) - removeLegacyStatusFile() + migrateLegacyStatusFile() } func storePumpStatus(_ status: PumpManagerStatus) { - store(Self.deliveryState(of: status), deliveryIsUncertain: status.deliveryIsUncertain) + store(PumpStatusStored.DeliveryState(status.basalDeliveryState)) } func storePumpRemoved() { - store(.unknown, deliveryIsUncertain: false) + store(.unknown) } func lastPumpStatus() async -> PumpStatus? { @@ -45,24 +47,40 @@ final class BasePumpStatusStorage: PumpStatusStorage, Injectable { } } - private func store(_ deliveryState: PumpStatusStored.DeliveryState, deliveryIsUncertain: Bool) { + 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, - last.deliveryIsUncertain == deliveryIsUncertain - { + 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 - stored.deliveryIsUncertain = deliveryIsUncertain try context.save() - debug(.storage, "Pump status stored: \(deliveryState.rawValue), delivery uncertain \(deliveryIsUncertain)") + debug(.storage, "Pump status stored: \(deliveryState.rawValue)") } catch { context.rollback() warning(.storage, "Saving pump status failed", error: error) @@ -70,30 +88,43 @@ final class BasePumpStatusStorage: PumpStatusStorage, Injectable { } } - private func removeLegacyStatusFile() { // FIXME: this can be removed in later releases - guard let url = fileStorage.urlFor(file: Self.legacyStatusFile), - FileManager.default.fileExists(atPath: url.path) - else { return } - fileStorage.remove(Self.legacyStatusFile) + 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) + } + } } +} - private static func deliveryState(of status: PumpManagerStatus) -> PumpStatusStored.DeliveryState { - switch status.basalDeliveryState { +extension PumpStatusStored.DeliveryState { + init(_ basalDeliveryState: PumpManagerStatus.BasalDeliveryState?) { + switch basalDeliveryState { case .none: - return .unknown + self = .unknown case let .active(since) where since == .distantPast: - return .inactive + self = .inactive case .suspending: - return .suspending + self = .suspending case .suspended: - return .suspended + self = .suspended case .resuming: - return .resuming + self = .resuming case .active, .cancelingTempBasal, .initiatingTempBasal, .tempBasal: - return .active + self = .active } } } diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 9c12a7a05d..d07eec2551 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -498,7 +498,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: () = coreDataStack.batchDeleteOlderThan(PumpStatusStored.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( @@ -521,7 +521,7 @@ extension Notification.Name { try await tempBasalDeletion try await determinationDeletion try await batteryDeletion - try await pumpStatusDeletion + await pumpStatusDeletion try await carbEntryDeletion try await forecastDeletion try await forecastValueDeletion 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/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index 720e0a8624..62223f7429 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -505,8 +505,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { guard let liveStatus = apsManager.pumpManager?.status else { 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() @@ -529,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. diff --git a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift index f3f33ba294..dce5c1aeb2 100644 --- a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift +++ b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift @@ -31,10 +31,7 @@ import Testing injectServices(resolver) } - private func status( - _ basalDeliveryState: PumpManagerStatus.BasalDeliveryState?, - deliveryIsUncertain: Bool = false - ) -> PumpManagerStatus { + private func status(_ basalDeliveryState: PumpManagerStatus.BasalDeliveryState?) -> PumpManagerStatus { PumpManagerStatus( timeZone: .current, device: HKDevice( @@ -45,7 +42,7 @@ import Testing basalDeliveryState: basalDeliveryState, bolusState: .noBolus, insulinType: nil, - deliveryIsUncertain: deliveryIsUncertain + deliveryIsUncertain: false ) } @@ -60,12 +57,12 @@ import Testing } /// Stored rows, oldest first. Runs on the storage's context, after the stores it has queued. - private func storedRows() async -> [(state: String?, uncertain: Bool, date: Date?)] { + 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.deliveryIsUncertain, $0.date) } + return rows.map { ($0.basalDeliveryState, $0.date) } } } @@ -123,30 +120,40 @@ import Testing #expect(removed.suspended) } - @Test("Statuses older than 90 days are purged") func testPurge() async throws { + @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 { - let old = PumpStatusStored(context: testContext) - old.date = Date().addingTimeInterval(-91 * 24 * 60 * 60) - old.basalDeliveryState = PumpStatusStored.DeliveryState.suspended.rawValue + 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() } - storage.storePumpStatus(status(.active(Date()))) - #expect(await storedRows().count == 2) - try await coreDataStack.batchDeleteOlderThan(PumpStatusStored.self, dateKey: "date", days: 90) + 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("A change in delivery uncertainty is stored") func testDeliveryUncertainty() async { - storage.storePumpStatus(status(tempBasal())) - storage.storePumpStatus(status(tempBasal(), deliveryIsUncertain: true)) - storage.storePumpStatus(status(tempBasal(), deliveryIsUncertain: true)) - storage.storePumpStatus(status(.active(Date()))) + @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) == ["active", "active", "active"]) - #expect(rows.map(\.uncertain) == [false, true, false]) + #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 { @@ -155,7 +162,8 @@ import Testing #expect(suspended.suspended) #expect(suspended.status == .suspended) #expect(!suspended.bolusing) - #expect(suspended.timestamp != nil) + let suspendedDate = await storedRows().last?.date + #expect(suspended.timestamp == suspendedDate) storage.storePumpStatus(status(.resuming)) let resuming = try #require(await storage.lastPumpStatus()) 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) + } } From 69bedb5d19f774c8370c5ba879fe664c3adf8793 Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sat, 31 Oct 2026 11:09:54 +0100 Subject: [PATCH 10/69] Fix glucose delta rounding and use one delta formatter --- Trio/Sources/Models/BloodGlucose.swift | 12 ++++++ .../Home/View/Header/CurrentGlucoseView.swift | 26 ++---------- ...iveActivitySimpleWidgetConfiguration.swift | 4 +- .../Modules/Treatments/View/PopupView.swift | 2 +- .../Services/Calendar/CalendarManager.swift | 11 +---- .../ContactImage/ContactImageManager.swift | 20 +++------ .../LiveActivityAttributes+Helper.swift | 42 +++---------------- .../WatchManager/AppleWatchManager.swift | 14 ++----- .../Shortcuts/State/StateIntentRequest.swift | 17 +------- 9 files changed, 34 insertions(+), 114 deletions(-) diff --git a/Trio/Sources/Models/BloodGlucose.swift b/Trio/Sources/Models/BloodGlucose.swift index c808332d47..48e3c89176 100644 --- a/Trio/Sources/Models/BloodGlucose.swift +++ b/Trio/Sources/Models/BloodGlucose.swift @@ -233,9 +233,21 @@ extension Decimal { units == .mgdL ? description : formattedAsMmolL } + func formattedDelta(for units: GlucoseUnits) -> String { + let value = units == .mgdL ? self : self * GlucoseUnits.exchangeRate + return value.formatted(Self.glucoseStyle(for: units).sign(strategy: .always(includingZero: true))) + } + func formatted(withUnits units: GlucoseUnits) -> String { formatted(for: units) + " \(units.rawValue)" } + + private static func glucoseStyle(for units: GlucoseUnits) -> Decimal.FormatStyle { + .number + .precision(.fractionLength(units == .mgdL ? 0 : 1)) + .grouping(.never) + .rounded(rule: .toNearestOrAwayFromZero) + } } extension Double { diff --git a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index fc38064d97..f6d76368c8 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -34,21 +34,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) @@ -165,14 +150,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). 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/Treatments/View/PopupView.swift b/Trio/Sources/Modules/Treatments/View/PopupView.swift index 3ca2de3b62..6af0047921 100644 --- a/Trio/Sources/Modules/Treatments/View/PopupView.swift +++ b/Trio/Sources/Modules/Treatments/View/PopupView.swift @@ -462,7 +462,7 @@ 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() diff --git a/Trio/Sources/Services/Calendar/CalendarManager.swift b/Trio/Sources/Services/Calendar/CalendarManager.swift index c616e04346..a52f97e31f 100644 --- a/Trio/Sources/Services/Calendar/CalendarManager.swift +++ b/Trio/Sources/Services/Calendar/CalendarManager.swift @@ -38,14 +38,6 @@ final class BaseCalendarManager: CalendarManager, Injectable { 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 @@ -270,8 +262,7 @@ final class BaseCalendarManager: CalendarManager, Injectable { 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..a1aa847639 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -39,15 +39,6 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { 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) { @@ -216,11 +207,12 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { 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: settingsManager.settings.units) + } else { + state.delta = nil + } } else { // fetchGlucose() only looks back 20 minutes, so an empty result means dropout. state.glucose = nil 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/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index cc2743603a..6cd513fec0 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -324,17 +324,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/StateIntentRequest.swift b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift index 2070129106..f19ee2ac56 100644 --- a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift +++ b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift @@ -83,14 +83,7 @@ final class StateIntentRequest: BaseIntentsRequest { 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) @@ -125,12 +118,4 @@ final class StateIntentRequest: BaseIntentsRequest { formatter.roundingMode = .halfUp return formatter } - - private var deltaFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 1 - formatter.positivePrefix = "+" - return formatter - } } From 6998a6cfbfdfdb51e8891bdabb31c94eb2a36e10 Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sat, 31 Oct 2026 11:57:13 +0100 Subject: [PATCH 11/69] Remove unused formatters --- LiveActivity/LiveActivity+Helper.swift | 19 ----- Trio/Sources/APS/Storage/GlucoseStorage.swift | 11 --- Trio/Sources/Helpers/Formatters.swift | 28 -------- .../View/BolusCalculatorConfigRootView.swift | 14 ---- .../View/CarbRatioEditorRootView.swift | 7 -- .../View/DynamicSettingsRootView.swift | 24 ------- .../History/HistoryDataFlow+Models.swift | 72 ------------------- .../Modules/Home/View/Header/PumpView.swift | 6 -- .../ISFEditor/View/ISFEditorRootView.swift | 7 -- .../View/MealSettingsRootView.swift | 20 ------ .../GlucoseTargetStepView.swift | 8 --- .../View/TargetsEditorRootView.swift | 7 -- .../Treatments/View/TreatmentsRootView.swift | 19 ----- .../View/UserInterfaceSettingsRootView.swift | 18 ----- .../Shortcuts/State/ListStateView.swift | 21 ------ 15 files changed, 281 deletions(-) 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/Trio/Sources/APS/Storage/GlucoseStorage.swift b/Trio/Sources/APS/Storage/GlucoseStorage.swift index 625107ea47..60534635b1 100644 --- a/Trio/Sources/APS/Storage/GlucoseStorage.swift +++ b/Trio/Sources/APS/Storage/GlucoseStorage.swift @@ -53,17 +53,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/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index bb7fd307b7..be380545d1 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() @@ -50,19 +35,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 diff --git a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift index 8364eb69d7..1d342f48b6 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( diff --git a/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift index 720c2274bc..f4fcfc7172 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/View/CarbRatioEditorRootView.swift @@ -13,13 +13,6 @@ 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 diff --git a/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift b/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift index 0a02cca109..035bb4310b 100644 --- a/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift +++ b/Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift @@ -42,33 +42,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/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/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/ISFEditor/View/ISFEditorRootView.swift b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift index 634e3e9673..13f4b569ba 100644 --- a/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift +++ b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift @@ -13,13 +13,6 @@ extension ISFEditor { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = state.units == .mmolL ? 1 : 0 - return formatter - } - var saveButton: some View { ZStack { let shouldDisableButton = state.items.isEmpty || !state.hasChanges diff --git a/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift b/Trio/Sources/Modules/MealSettings/View/MealSettingsRootView.swift index ce440db2ff..9dc6505162 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/Onboarding/View/OnboardingSteps/TherapySettings/GlucoseTargetStepView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/GlucoseTargetStepView.swift index 02f361bdbd..12ef4b357e 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/GlucoseTargetStepView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/GlucoseTargetStepView.swift @@ -17,14 +17,6 @@ struct GlucoseTargetStepView: View { @State private var now = Date() @Namespace private var bottomID - // Formatter for glucose values - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = state.units == .mmolL ? 1 : 0 - return formatter - } - private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.timeZone = TimeZone(secondsFromGMT: 0) diff --git a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift index 5882a20c2c..a55f7f4902 100644 --- a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift +++ b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift @@ -13,13 +13,6 @@ extension TargetsEditor { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = state.units == .mmolL ? 1 : 0 - return formatter - } - var saveButton: some View { ZStack { let shouldDisableButton = state.shouldDisplaySaving || state.items.isEmpty || !state.hasChanges diff --git a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift index 1c9730174d..2219e0a04f 100644 --- a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift +++ b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift @@ -73,25 +73,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() diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift index 6dab04373e..07b10e2e5d 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( 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 From 7cf674721ef597d2eea57c629590792a0c66157b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 31 Oct 2026 13:12:27 +0100 Subject: [PATCH 12/69] Clear the stored reservoir when the pump is removed or the Medtrum patch session has ended The reservoir file kept the previous pump's or patch's value, so Home and the Nightscout device status showed it until the new pump reported its own. A missing reservoir is now uploaded as no value rather than 0. --- Trio/Sources/APS/DeviceDataManager.swift | 14 +++++++++++++- .../Network/Nightscout/NightscoutManager.swift | 4 ++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 95356ef6e3..0a9dc04b31 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -129,6 +129,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } if let medtrumPump = pumpManager as? MedtrumPumpManager { + storeReservoir(of: medtrumPump) + // Medtrum's state.patchExpiresAt is actually lifespan + grace // keeping this in line with omnipod, we will use just the lifetime // i.e., state.patchGracePeriodFrom @@ -167,6 +169,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } } else { info(.pump, oldValue == nil ? "No pump configured" : "Pump removed") + storage.remove(OpenAPS.Monitor.reservoir) pumpDisplayState.value = nil pumpExpiresAtDate.send(nil) pumpActivatedAtDate.send(nil) @@ -485,7 +488,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { manualTempBasal.send(manualTempBasalActive) if let medtrumPump = pumpManager as? MedtrumPumpManager { - storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir) + storeReservoir(of: medtrumPump) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir)) } @@ -535,6 +538,15 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } + /// MedtrumKit keeps the last patch's reservoir until a new patch is activated + private func storeReservoir(of medtrumPump: MedtrumPumpManager) { + if medtrumPump.state.patchSessionEnded { + storage.remove(OpenAPS.Monitor.reservoir) + } else { + storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir) + } + } + func pumpManagerWillDeactivate(_: PumpManager) { dispatchPrecondition(condition: .onQueue(processQueue)) pumpManager = nil diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index a206f005f6..0c23f079f3 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -577,7 +577,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // OpenAPS Status async let fetchedBattery = fetchBattery() - async let fetchedReservoir = Decimal(from: storage.retrieveRawAsync(OpenAPS.Monitor.reservoir) ?? "0") + async let fetchedReservoir = storage.retrieveAsync(OpenAPS.Monitor.reservoir, as: Decimal.self) async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self) async let fetchedPumpStatus = pumpStatusForUpload() @@ -686,7 +686,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { 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 ) From 32876aa8966ce6e59837f31a4d7e8cb2262863d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:32:09 +0000 Subject: [PATCH 13/69] CI: Bump APP_DEV_VERSION to 1.0.2.43 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4c1cd7566f..503a0a6b27 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.43 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 8c9cd5f3a53a4f9de9da695dac1075e8df1df4c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:33:32 +0000 Subject: [PATCH 14/69] CI: Bump APP_DEV_VERSION to 1.0.2.44 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 503a0a6b27..1ea93c4a65 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.43 +APP_DEV_VERSION = 1.0.2.44 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From a4e16093576a8c9316c64987baa3f1a740e7d1f9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:34:12 +0000 Subject: [PATCH 15/69] CI: Bump APP_DEV_VERSION to 1.0.2.45 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1ea93c4a65..3d92fcd46c 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.44 +APP_DEV_VERSION = 1.0.2.45 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From a9e1b10c774ff8343d343894b2409e106f2b5cb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:35:19 +0000 Subject: [PATCH 16/69] CI: Bump APP_DEV_VERSION to 1.0.2.46 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 3d92fcd46c..0fc625d7f9 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.45 +APP_DEV_VERSION = 1.0.2.46 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From f5cedb5f38d89bf9958f4cc135306ee1a35756c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:37:00 +0000 Subject: [PATCH 17/69] CI: Bump APP_DEV_VERSION to 1.0.2.47 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0fc625d7f9..4f88100723 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.46 +APP_DEV_VERSION = 1.0.2.47 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 87126ef271771235c9c89beb45d4c4ecde833a46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:39:44 +0000 Subject: [PATCH 18/69] CI: Bump APP_DEV_VERSION to 1.0.2.48 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4f88100723..0a2362ac1c 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.47 +APP_DEV_VERSION = 1.0.2.48 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 6bc16eb29af072d2007cee950c3fd4875ff0ac3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:48:46 +0000 Subject: [PATCH 19/69] CI: Bump APP_DEV_VERSION to 1.0.2.49 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0a2362ac1c..1156758bc0 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.48 +APP_DEV_VERSION = 1.0.2.49 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From e92ee42f11be0edd20d912c1f162a4d90fa67584 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 13:49:39 +0000 Subject: [PATCH 20/69] CI: Bump APP_DEV_VERSION to 1.0.2.50 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1156758bc0..b8eedabc1b 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.49 +APP_DEV_VERSION = 1.0.2.50 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From ccb00bb7bd277c4abcc7ba2f364725510bbe2592 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 14:01:03 +0000 Subject: [PATCH 21/69] CI: Bump APP_DEV_VERSION to 1.0.2.51 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index b8eedabc1b..96ea08b0e6 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.50 +APP_DEV_VERSION = 1.0.2.51 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 0028dd35ddfbedc4ef29189223019a6bf701231e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 31 Oct 2026 20:03:31 +0100 Subject: [PATCH 22/69] Read the reservoir from the pump manager instead of storing it --- Trio/Sources/APS/APSManager.swift | 5 -- Trio/Sources/APS/DeviceDataManager.swift | 59 +++++++++++-------- Trio/Sources/APS/OpenAPS/Constants.swift | 1 - Trio/Sources/APS/OpenAPS/OpenAPS.swift | 7 --- .../DetermineBasalGenerator.swift | 3 - Trio/Sources/Modules/Home/HomeProvider.swift | 2 +- .../Sources/Modules/Home/HomeStateModel.swift | 2 +- .../Nightscout/NightscoutManager.swift | 3 +- .../DetermineBasalEarlyExitTests.swift | 28 +-------- .../DetermineBasalSmbMicroBolusTests.swift | 4 -- .../Parity/ParityScenarios.swift | 3 - 11 files changed, 41 insertions(+), 76 deletions(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index e32e7cd941..f17920c7e2 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -452,11 +452,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 } diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 01da64f83d..b9b0e67ac2 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,10 +1,12 @@ 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,6 +41,7 @@ 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! @@ -130,8 +133,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } if let medtrumPump = pumpManager as? MedtrumPumpManager { - storeReservoir(of: medtrumPump) - // Medtrum's state.patchExpiresAt is actually lifespan + grace // keeping this in line with omnipod, we will use just the lifetime // i.e., state.patchGracePeriodFrom @@ -158,7 +159,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)) @@ -170,7 +170,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } } else { info(.pump, oldValue == nil ? "No pump configured" : "Pump removed") - storage.remove(OpenAPS.Monitor.reservoir) // A stored pump manager that fails to decode at launch counts as removed. if oldValue != nil || rawPumpManager != nil { pumpStatusStorage.storePumpRemoved() @@ -229,6 +228,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { init(resolver: Resolver) { self.resolver = resolver injectServices(resolver) + removeLegacyReservoirFile() setupPumpManager() UIDevice.current.isBatteryMonitoringEnabled = true @@ -244,6 +244,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) @@ -492,9 +496,8 @@ extension BaseDeviceDataManager: PumpManagerDelegate { manualTempBasal.send(manualTempBasalActive) if let medtrumPump = pumpManager as? MedtrumPumpManager { - storeReservoir(of: medtrumPump) broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { - $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir)) + $0.pumpReservoirDidChange(medtrumPump.currentReservoir) } // Medtrum's state.patchExpiresAt is actually lifespan + grace @@ -515,13 +518,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 { @@ -542,15 +540,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } - /// MedtrumKit keeps the last patch's reservoir until a new patch is activated - private func storeReservoir(of medtrumPump: MedtrumPumpManager) { - if medtrumPump.state.patchSessionEnded { - storage.remove(OpenAPS.Monitor.reservoir) - } else { - storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir) - } - } - func pumpManagerWillDeactivate(_: PumpManager) { dispatchPrecondition(condition: .onQueue(processQueue)) pumpManager = nil @@ -619,7 +608,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)) } @@ -700,8 +688,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/OpenAPS/Constants.swift b/Trio/Sources/APS/OpenAPS/Constants.swift index 3d7757fe22..b792dc563f 100644 --- a/Trio/Sources/APS/OpenAPS/Constants.swift +++ b/Trio/Sources/APS/OpenAPS/Constants.swift @@ -15,7 +15,6 @@ 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 tempBasal = "monitor/temp_basal.json" diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index f79ebd40c9..e887515e07 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -265,7 +265,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 @@ -277,7 +276,6 @@ final class OpenAPS { rawProfile, rawBasalProfile, rawAutosens, - rawReservoir, hasSufficientTdd ) = await ( try parsePumpHistory(on: context, await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount), @@ -287,7 +285,6 @@ final class OpenAPS { profileAsync, basalAsync, autosenseAsync, - reservoirAsync, try hasSufficientTddForDynamic ) @@ -299,7 +296,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 @@ -340,7 +336,6 @@ final class OpenAPS { autosens: autosens, meal: meal, microBolusAllowed: true, - reservoir: reservoir, preferences: preferences, trioCustomOrefVariables: trioCustomOrefVariables ) @@ -683,7 +678,6 @@ final class OpenAPS { autosens: Autosens?, meal: ComputedCarbs?, microBolusAllowed: Bool, - reservoir: Decimal, preferences: Preferences, trioCustomOrefVariables: TrioCustomOrefVariables ) throws -> Determination? { @@ -700,7 +694,6 @@ final class OpenAPS { iobData: iob, mealData: meal, autosensData: autosens, - reservoirData: reservoir, glucose: glucose, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift index 2d853c35b9..fb9fe9e97a 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift @@ -19,7 +19,6 @@ enum DeterminationGenerator { iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData: Decimal, glucose: [BloodGlucose], microBolusAllowed: Bool, trioCustomOrefVariables: TrioCustomOrefVariables, @@ -34,7 +33,6 @@ enum DeterminationGenerator { iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -52,7 +50,6 @@ enum DeterminationGenerator { iobData: [IobResult], mealData: ComputedCarbs, autosensData: Autosens, - reservoirData _: Decimal, glucoseStatus: GlucoseStatus, microBolusAllowed: Bool, trioCustomOrefVariables: TrioCustomOrefVariables, 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 316b50adb2..08c074467f 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -1082,7 +1082,7 @@ extension Home.StateModel: } } - func pumpReservoirDidChange(_: Decimal) { + func pumpReservoirDidChange(_: Decimal?) { setupReservoir() displayPumpStatusHighlightMessage() displayPumpStatusBadge() diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift index 5bf8fa5afa..89dcbbc2d2 100644 --- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift +++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift @@ -581,7 +581,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { // OpenAPS Status async let fetchedBattery = fetchBattery() - async let fetchedReservoir = storage.retrieveAsync(OpenAPS.Monitor.reservoir, as: Decimal.self) async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self) async let fetchedPumpStatus = pumpStatusForUpload() @@ -685,7 +684,7 @@ 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(), diff --git a/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift b/TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift index 9462f8db66..cd84785812 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, @@ -158,7 +155,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -176,7 +172,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -202,7 +197,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -223,7 +217,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -250,7 +243,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -271,7 +263,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -298,7 +289,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -319,7 +309,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -335,7 +324,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -353,7 +341,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -380,7 +367,6 @@ import Testing iobData: mutableIobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -406,7 +392,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -433,7 +418,6 @@ import Testing iobData: mutableIobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -458,7 +442,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -474,7 +457,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -492,7 +474,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, _, microBolusAllowed, trioCustomOrefVariables, @@ -517,7 +498,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -543,7 +523,6 @@ import Testing iobData, mealData, autosensData, - reservoirData, glucoseStatus, microBolusAllowed, trioCustomOrefVariables, @@ -559,7 +538,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -590,7 +568,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) @@ -605,7 +583,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, @@ -627,7 +604,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 @@ -651,7 +628,6 @@ import Testing iobData: iobData, mealData: mealData, autosensData: autosensData, - reservoirData: reservoirData, glucoseStatus: glucoseStatus, microBolusAllowed: microBolusAllowed, trioCustomOrefVariables: trioCustomOrefVariables, diff --git a/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift b/TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift index cbd3deccef..d301cd70cf 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, @@ -145,7 +143,6 @@ import Testing iobData: inputs.iobData, mealData: inputs.mealData, autosensData: inputs.autosensData, - reservoirData: inputs.reservoirData, glucoseStatus: inputs.glucoseStatus, microBolusAllowed: inputs.microBolusAllowed, trioCustomOrefVariables: inputs.trioCustomOrefVariables, @@ -166,7 +163,6 @@ import Testing iobData: inputs.iobData, mealData: inputs.mealData, autosensData: inputs.autosensData, - reservoirData: inputs.reservoirData, glucoseStatus: inputs.glucoseStatus, microBolusAllowed: inputs.microBolusAllowed, trioCustomOrefVariables: inputs.trioCustomOrefVariables, diff --git a/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift b/TrioTests/OpenAPSSwiftTests/Parity/ParityScenarios.swift index e3868935f6..2c2c90a201 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] @@ -179,7 +178,6 @@ enum ParityScenarios { preferences: preferences, orefVariables: orefVariables, currentTemp: currentTemp, - reservoir: 150, pumpSettings: ParityInputs.pumpSettings(), bgTargets: ParityInputs.bgTargets(), basalProfile: ParityInputs.basalProfile(), @@ -255,7 +253,6 @@ enum ParityScenarios { iobData: iob, mealData: mealData, autosensData: autosens, - reservoirData: scenario.reservoir, glucose: scenario.glucose, microBolusAllowed: true, trioCustomOrefVariables: scenario.orefVariables, From 5a69d67fb71b1559549a9fcf5675c4329e574b09 Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Sat, 31 Oct 2026 15:36:33 -0500 Subject: [PATCH 23/69] fix(ui): Fill the glucose simulator's Reset to Defaults with system blue The button used Color.accentColor, which in the CGM sheet resolves to the tab bar's lavender tint. Use Color(.systemBlue), like Trio's other filled buttons. --- .../Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift index 766cb3a2b3..43ab54ee4c 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift @@ -453,7 +453,7 @@ extension CGMSettings { }) .frame(maxWidth: .infinity, alignment: .center) .tint(.white) - }.listRowBackground(Color.accentColor) + }.listRowBackground(Color(.systemBlue)) }.listSectionSpacing(sectionSpacing) } From cba6b42982370b1b7c028f95939629ee80604c5c Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:09:12 +0100 Subject: [PATCH 24/69] Stop critical audio only for the alert that owns it --- .../Services/Alerts/TrioAlertManager.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Trio/Sources/Services/Alerts/TrioAlertManager.swift b/Trio/Sources/Services/Alerts/TrioAlertManager.swift index 7ce4ecc7d6..32dcb35b92 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) { @@ -312,7 +322,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) @@ -353,7 +363,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. From e9850f4d1b52c0e94e13e6ed7129eeafd995aa98 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:09:12 +0100 Subject: [PATCH 25/69] Ask for AlarmKit permission on launch --- Trio/Sources/Application/TrioApp.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index d07eec2551..2410c85975 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -387,6 +387,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 @@ -414,6 +415,7 @@ extension Notification.Name { AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC) } presentDevelopmentBranchWarningIfNeeded() + requestAlarmAuthorizationIfNeeded() if initState.complete { performCleanupIfNecessary() } @@ -433,6 +435,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() From 11ecb634785a95b5ddc7244677b66c5e44268e2a Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:09:12 +0100 Subject: [PATCH 26/69] Derive AlarmKit alarm IDs from the alert identifier --- .../Alerts/CriticalAlertAlarmScheduler.swift | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift index 11cbcc3146..f136bce89d 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,10 +98,9 @@ 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 + Task { do { _ = try await AlarmManager.shared.schedule(id: id, configuration: configuration) info(.alerts, "AlarmKit alarm scheduled for \(identifier.value)") @@ -102,7 +110,6 @@ final class CriticalAlertAlarmScheduler { "Scheduling AlarmKit alarm for \(identifier.value) failed, falling back to audio", error: scheduleError ) - self?.alarmsByAlert.removeValue(forKey: identifier) onScheduleFailure() } } @@ -116,11 +123,19 @@ final class CriticalAlertAlarmScheduler { 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 } } From 7b3d0283b01768bcca30a711dab1e1f6966237ed Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:09:12 +0100 Subject: [PATCH 27/69] Ring critical alarms for one bounded burst --- .../Alerts/CriticalAlertAlarmScheduler.swift | 17 +++++ .../Alerts/CriticalAlertAudioPlayer.swift | 69 +++++++++++++++---- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift index f136bce89d..f7ab3b2a04 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift @@ -100,10 +100,17 @@ final class CriticalAlertAlarmScheduler { let id = Self.alarmID(for: alert.identifier) let identifier = alert.identifier + // 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, @@ -119,6 +126,16 @@ 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 } 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() From 5e4ccc5c76791de267370d695a5d207157b3c211 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:26:43 +0100 Subject: [PATCH 28/69] Pass nil decisionId to pump enact calls --- Trio/Sources/APS/APSManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index e32e7cd941..fb2f5d336b 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -1427,7 +1427,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) @@ -1442,7 +1442,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) From d53acb6f0d031b3759937434f96931021d8bc0b3 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:26:53 +0100 Subject: [PATCH 29/69] Treat pumpInoperable as pump unavailable --- Trio/Sources/APS/APSManager.swift | 3 ++- Trio/Sources/APS/DeviceDataManager.swift | 2 ++ Trio/Sources/APS/Storage/PumpStatusStorage.swift | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index fb2f5d336b..b1d309335c 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -758,7 +758,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 = Decimal(dose.unitsPerHour) diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index bbcd25bea5..85add4175c 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -472,6 +472,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) diff --git a/Trio/Sources/APS/Storage/PumpStatusStorage.swift b/Trio/Sources/APS/Storage/PumpStatusStorage.swift index 776a5557a0..7d095a1ce6 100644 --- a/Trio/Sources/APS/Storage/PumpStatusStorage.swift +++ b/Trio/Sources/APS/Storage/PumpStatusStorage.swift @@ -114,6 +114,8 @@ extension PumpStatusStored.DeliveryState { self = .unknown case let .active(since) where since == .distantPast: self = .inactive + case .pumpInoperable: + self = .inactive case .suspending: self = .suspending case .suspended: From 46f5dfc61854aadb6f570828c29f5a6c82fb74dd Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:00 +0100 Subject: [PATCH 30/69] Use LoopQuantity and LoopUnit at LoopKit boundary --- Trio/Sources/APS/DeviceDataManager.swift | 6 +++--- Trio/Sources/APS/FetchGlucoseManager.swift | 6 +++--- Trio/Sources/Models/BloodGlucose+LoopKit.swift | 3 ++- .../AlgorithmAdvancedSettingsProvider.swift | 8 ++++---- .../GeneralSettings/UnitsLimitsSettingsProvider.swift | 8 ++++---- .../NightscoutConfig/NightscoutConfigProvider.swift | 8 ++++---- TrioTests/DeliveryLimitsSyncTests.swift | 10 +++++----- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 85add4175c..e838b407bd 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,7 +1,7 @@ import Combine import CoreData import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import MedtrumKit @@ -117,8 +117,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 { diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index f75deb0870..a7fd9b35ce 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -1,7 +1,7 @@ import Combine import CoreData import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import SwiftDate @@ -231,8 +231,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/Models/BloodGlucose+LoopKit.swift b/Trio/Sources/Models/BloodGlucose+LoopKit.swift index 6416d0e52c..e9a7435d06 100644 --- a/Trio/Sources/Models/BloodGlucose+LoopKit.swift +++ b/Trio/Sources/Models/BloodGlucose+LoopKit.swift @@ -1,5 +1,6 @@ import Foundation import HealthKit +import LoopAlgorithm import LoopKit // LoopKit/HealthKit adapter for `BloodGlucose`. @@ -12,7 +13,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/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift index dce2fd880c..21fbe0ee49 100644 --- a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift +++ b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift @@ -1,6 +1,6 @@ import Combine import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -39,8 +39,8 @@ extension AlgorithmAdvancedSettings { 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 { @@ -54,7 +54,7 @@ extension AlgorithmAdvancedSettings { 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/UnitsLimitsSettingsProvider.swift b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift index 4f0fcfbcd3..6c315deddc 100644 --- a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift +++ b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift @@ -1,6 +1,6 @@ import Combine import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -34,8 +34,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 +49,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/NightscoutConfig/NightscoutConfigProvider.swift b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift index d4a130165e..131a2efec8 100644 --- a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift +++ b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift @@ -1,6 +1,6 @@ import Combine import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -34,8 +34,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 +49,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/TrioTests/DeliveryLimitsSyncTests.swift b/TrioTests/DeliveryLimitsSyncTests.swift index be186c13a6..083c8657ac 100644 --- a/TrioTests/DeliveryLimitsSyncTests.swift +++ b/TrioTests/DeliveryLimitsSyncTests.swift @@ -1,5 +1,5 @@ import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import Testing @@ -11,8 +11,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 +20,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)) ) } From 901391b2153f4a9af833bff5af84ed5b6fd25af9 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:04 +0100 Subject: [PATCH 31/69] Adopt async device delegate and alert APIs --- Trio/Sources/APS/CGM/PluginSource.swift | 34 ++++++------------- Trio/Sources/APS/DeviceDataManager.swift | 14 ++++---- .../Services/Alerts/TrioAlertManager.swift | 6 ++-- TrioTests/Logging/LogExportTests.swift | 10 +++--- .../Logging/LoggingLongRunStressTests.swift | 10 +++--- .../LoggingRobustnessStressTests.swift | 10 +++--- .../Logging/LoggingVolumeStressTests.swift | 10 +++--- 7 files changed, 41 insertions(+), 53 deletions(-) diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift index 5a7f79dc4e..35c1d22428 100644 --- a/Trio/Sources/APS/CGM/PluginSource.swift +++ b/Trio/Sources/APS/CGM/PluginSource.swift @@ -128,42 +128,28 @@ extension PluginSource: CGMManagerDelegate { /// 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) { + func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) async throws { 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 e838b407bd..acbd5018af 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -353,6 +353,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, @@ -649,16 +652,13 @@ extension BaseDeviceDataManager: DeviceManagerDelegate { 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 { diff --git a/Trio/Sources/Services/Alerts/TrioAlertManager.swift b/Trio/Sources/Services/Alerts/TrioAlertManager.swift index 7ce4ecc7d6..160eeea4f3 100644 --- a/Trio/Sources/Services/Alerts/TrioAlertManager.swift +++ b/Trio/Sources/Services/Alerts/TrioAlertManager.swift @@ -375,8 +375,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/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 6bc777d596..0d146785ca 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)" } } From 0b3d1a3aa39669e6528d75a25f83b90da0e25551 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:10 +0100 Subject: [PATCH 32/69] State plugin identifiers in device catalog --- Trio/Sources/APS/Devices/DeviceCatalog.swift | 33 +++++++++++++------- TrioTests/DeviceCatalogTests.swift | 30 ++++++++++++++---- 2 files changed, 45 insertions(+), 18 deletions(-) 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/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") } From fa6362959a056620f9fec962049e58a5a51935b1 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:10 +0100 Subject: [PATCH 33/69] Move Tidepool to next-dev service API --- .../Settings/View/TidepoolConfigView.swift | 5 +- .../Services/Network/TidepoolManager.swift | 50 ++++++++++--------- .../Network/TidepoolUploadSerializer.swift | 18 +++++++ TrioTests/TidepoolTherapySettingsTests.swift | 30 ++++------- 4 files changed, 58 insertions(+), 45 deletions(-) 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/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 8b033d54c6..19c10c3f54 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -3,6 +3,7 @@ import CoreData import CryptoKit import Foundation import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import Swinject @@ -257,7 +258,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 +288,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 +348,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 +427,7 @@ extension BaseTidepoolManager { endDate: event.timestamp, value: Double(programmed), unit: .units, + decisionId: nil, deliveredUnits: Double(amount), syncIdentifier: event.id, scheduledBasalRate: nil, @@ -471,8 +473,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 +489,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 +536,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 +619,7 @@ extension BaseTidepoolManager { endDate: adjustedEndDate, value: adjustedDeliveredUnits, unit: .units, + decisionId: nil, deliveredUnits: adjustedDeliveredUnits, syncIdentifier: predecessorEntrySyncIdentifier, insulinType: insulinType(for: predecessorEntry.insulinType), @@ -637,9 +641,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 +736,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 +820,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 +891,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 +906,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 +945,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 +960,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..09466fb947 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 () 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/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index c07a935764..541d911b00 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -1,5 +1,6 @@ import CryptoKit import HealthKit +import LoopAlgorithm import LoopKit import Testing import TidepoolKit @@ -8,9 +9,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 @@ -240,7 +241,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 +269,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 +280,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 +497,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 +515,7 @@ private extension StoredSettings { timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, @@ -549,10 +544,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 +560,7 @@ private extension StoredSettings { timeZone: tz )!, carbRatioSchedule: CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], timeZone: tz )!, From b09796a213af801e93526953618d8aed98166241 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:10 +0100 Subject: [PATCH 34/69] Read device log through async fetch --- Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift | 4 ++-- TrioTests/Logging/DeviceLogStoreTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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, From 1bb4c46c058df9fa969fb3a1e27d5917465be5df Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:11 +0100 Subject: [PATCH 35/69] Adopt next-dev LoopKitUI signatures --- Trio/Sources/APS/Extensions/LoopUIColorPalette+Default.swift | 1 + Trio/Sources/APS/Extensions/PumpManagerExtensions.swift | 2 +- Trio/Sources/APS/PluginManager.swift | 2 +- Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) 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 db6a3c9e3a..6adb1cf951 100644 --- a/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift +++ b/Trio/Sources/APS/Extensions/PumpManagerExtensions.swift @@ -13,7 +13,7 @@ extension PumpManager { } extension PumpManagerUI { - func settingsViewController( + @MainActor func settingsViewController( bluetoothProvider: BluetoothProvider, pumpManagerOnboardingDelegate: PumpManagerOnboardingDelegate? ) -> UIViewController & CompletionNotifying { 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/Services/Bluetooth/BluetoothStateManager.swift b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift index 2e30f215b4..b48e1cae5b 100644 --- a/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift +++ b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift @@ -10,7 +10,7 @@ public class BaseBluetoothStateManager: NSObject, BluetoothStateManager, Injecta 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) } From e9d2f3deff877b128376f5487b7c0b3f403fc619 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:27:11 +0100 Subject: [PATCH 36/69] Pass decisionId to test DoseEntry fixtures --- TrioTests/CoreDataTests/PumpEventFinalizationTests.swift | 4 ++++ TrioTests/CoreDataTests/PumpEventPredicateTests.swift | 2 ++ TrioTests/CoreDataTests/PumpHistoryStorageTests.swift | 4 ++++ TrioTests/CoreDataTests/PumpStatusStorageTests.swift | 3 ++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift index bd2d4e4c94..32d86c2e9e 100644 --- a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift +++ b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift @@ -1,5 +1,6 @@ import CoreData import Foundation +import LoopAlgorithm 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, @@ -594,6 +597,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 index dce5c1aeb2..6395ac2586 100644 --- a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift +++ b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift @@ -52,7 +52,8 @@ import Testing startDate: Date(), endDate: Date().addingTimeInterval(30 * 60), value: 1.2, - unit: .unitsPerHour + unit: .unitsPerHour, + decisionId: nil )) } From fee2a70cc451cec7a2257e5cedfad95dd571d7ea Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:21:38 +0100 Subject: [PATCH 37/69] Bump TidepoolKit to LoopWorkspace next-dev pin --- Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index dbc1405d66..accbe9aa1e 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" } } ], From 4cab8724f7a5236fa5bf1260b4741768ccae3d3c Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:29:01 +0100 Subject: [PATCH 38/69] Pin LoopKit and kits to next-dev ports --- AccuChekKit | 2 +- CGMBLEKit | 2 +- DanaKit | 2 +- EversenseKit | 2 +- G7SensorKit | 2 +- LibreLoop | 2 +- LibreTransmitter | 2 +- LoopKit | 2 +- MedtrumKit | 2 +- MinimedKit | 2 +- OmnipodKit | 2 +- RileyLinkKit | 2 +- SyaiKit | 2 +- TidepoolService | 2 +- dexcom-share-client-swift | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/AccuChekKit b/AccuChekKit index 4f8c3a8cb5..df7a26958c 160000 --- a/AccuChekKit +++ b/AccuChekKit @@ -1 +1 @@ -Subproject commit 4f8c3a8cb50e68a979bdbed2e207aca00e4b35e0 +Subproject commit df7a26958c3b30599abae99fe2c56cb757b66f13 diff --git a/CGMBLEKit b/CGMBLEKit index 776543fca9..4eee5121a1 160000 --- a/CGMBLEKit +++ b/CGMBLEKit @@ -1 +1 @@ -Subproject commit 776543fca902ad74e3a068a3ec402d62d716a322 +Subproject commit 4eee5121a12c01032b306063b9b673ffea1bf126 diff --git a/DanaKit b/DanaKit index eadb9fefc0..9beaecc90f 160000 --- a/DanaKit +++ b/DanaKit @@ -1 +1 @@ -Subproject commit eadb9fefc0e48df6639e1a9de5147ecf6a110caf +Subproject commit 9beaecc90f58011e9623fa60cb18907dd3cd1363 diff --git a/EversenseKit b/EversenseKit index 94dc675725..82b1f108af 160000 --- a/EversenseKit +++ b/EversenseKit @@ -1 +1 @@ -Subproject commit 94dc6757254733fb914b119c3b3c7eb45181bb42 +Subproject commit 82b1f108af77865a4c7c72ba9b3c733fca519fcd diff --git a/G7SensorKit b/G7SensorKit index f455451853..e02347769c 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit f45545185370bc9ebc57d06e8eea5773b1ef868c +Subproject commit e02347769c1c53772bfccd18cb9f4fc007e80a15 diff --git a/LibreLoop b/LibreLoop index a76748161d..0cf47529a7 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit a76748161d31089ba102a05204afbaca448f5f09 +Subproject commit 0cf47529a7e456aaf20855078525f076002ab009 diff --git a/LibreTransmitter b/LibreTransmitter index 41105123c3..30e3739cf5 160000 --- a/LibreTransmitter +++ b/LibreTransmitter @@ -1 +1 @@ -Subproject commit 41105123c3c4fb5c55ce89a38630cea2f8f7a17a +Subproject commit 30e3739cf5bed2e259bfe376ded356f1b1dbbafb diff --git a/LoopKit b/LoopKit index ff8c30e281..bd89cc561d 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit ff8c30e281d04edc2237a18834e5e783ae8c63d3 +Subproject commit bd89cc561d875f30a4366deff96bd7055f3a4e6e diff --git a/MedtrumKit b/MedtrumKit index 41a8202bd3..a84fc64876 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit 41a8202bd31fef12c9232c1a862afae00a387bc5 +Subproject commit a84fc6487657a2285050d29f783bf86ed0700914 diff --git a/MinimedKit b/MinimedKit index a7b26858e3..81112d8ece 160000 --- a/MinimedKit +++ b/MinimedKit @@ -1 +1 @@ -Subproject commit a7b26858e3073ac8881e7303b675c6b0f8c51ee9 +Subproject commit 81112d8ecee065e8ecf2ecb4f6707dcb4ae7f8f9 diff --git a/OmnipodKit b/OmnipodKit index 0c6d68b00d..1ed2013224 160000 --- a/OmnipodKit +++ b/OmnipodKit @@ -1 +1 @@ -Subproject commit 0c6d68b00d45f4fecd9b0fc0477fc00e2d7e4c02 +Subproject commit 1ed2013224c1247b5f68e2b8e14217ada02aa436 diff --git a/RileyLinkKit b/RileyLinkKit index 049f0d51b2..1a40a3373a 160000 --- a/RileyLinkKit +++ b/RileyLinkKit @@ -1 +1 @@ -Subproject commit 049f0d51b2fb90fa5180f3f62555d8c661b55968 +Subproject commit 1a40a3373a7999fc4922b8e6ce146a2457a7031e diff --git a/SyaiKit b/SyaiKit index 8161089806..00c08388bd 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit 816108980650863b637934367dc5e35212001956 +Subproject commit 00c08388bd96856958223f0af262f25ff44b89ec diff --git a/TidepoolService b/TidepoolService index 99ac3544f8..54097855c7 160000 --- a/TidepoolService +++ b/TidepoolService @@ -1 +1 @@ -Subproject commit 99ac3544f8462973f6ea886d1a46c2954ad0cc0f +Subproject commit 54097855c7ff9a868d406664ef17f1a7c929ebb7 diff --git a/dexcom-share-client-swift b/dexcom-share-client-swift index ff0d714334..c91e893dad 160000 --- a/dexcom-share-client-swift +++ b/dexcom-share-client-swift @@ -1 +1 @@ -Subproject commit ff0d71433447af3c93dac450d95c14b63f58f417 +Subproject commit c91e893dadc2ccf21f8423675264c511c8433a8a From cb341ea1fcd12cda7bc026f5107e5b39c90f0067 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:33:55 +0100 Subject: [PATCH 39/69] Link LoopAlgorithm only through LoopKit --- Trio.xcodeproj/project.pbxproj | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index a0d8c777a5..840d2d1cb3 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -415,7 +415,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 */; }; @@ -2376,7 +2375,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 */, @@ -5303,7 +5301,6 @@ 3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */, DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */, 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */, - 5E12FE112EBC0000000000A1 /* LoopAlgorithm */, 5E12FE112EBC0000000000B1 /* RoundWhiteDiscKit */, ); productName = Trio; @@ -7452,11 +7449,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" */; From 479f35caa0a9aa713af5ffdce392d8a111cf0281 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:35:49 +0100 Subject: [PATCH 40/69] Drop test for removed Tidepool automatedDelivery --- TrioTests/TidepoolTherapySettingsTests.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index 541d911b00..cf76b3ef3c 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -39,7 +39,6 @@ private let mmolPerL = LoopUnit.millimolesPerLiter "\"bgTargets\"", "\"carbRatios\"", "\"insulinSensitivities\"", - "\"automatedDelivery\"", "\"name\" : \"Trio\"", "\"version\" : \"0.6.0\"" ] @@ -148,19 +147,6 @@ private let mmolPerL = LoopUnit.millimolesPerLiter #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() { From 469033590e78aef6a4619244d641d4d1af6cb4af Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:02:20 +0100 Subject: [PATCH 41/69] Don't loop against an inoperable pump --- Trio/Sources/APS/APSManager.swift | 3 ++- TrioTests/CoreDataTests/PumpStatusStorageTests.swift | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index b1d309335c..1e9c90d117 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -1573,7 +1573,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/TrioTests/CoreDataTests/PumpStatusStorageTests.swift b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift index 6395ac2586..2e02037c44 100644 --- a/TrioTests/CoreDataTests/PumpStatusStorageTests.swift +++ b/TrioTests/CoreDataTests/PumpStatusStorageTests.swift @@ -176,4 +176,11 @@ import Testing #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) + } } From 138b0037b474ac6517feaaf71aa6147ad3257a8c Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:02:20 +0100 Subject: [PATCH 42/69] Treat Libre 2 as a direct CGM for glucose alerts --- .../Sources/Services/Alerts/CGMManagerAlertOwnership.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 } From daf19d1c5da31223e541776d9989858661fa2421 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:57:41 +0100 Subject: [PATCH 43/69] Play alert sounds while Trio is in the foreground --- .../Services/UserNotifications/UserNotificationsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index 783e15e4c9..8584391ed7 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -217,7 +217,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]) From a8d776f4d8be8f25ae41889dd7c9286ab9e8bbba Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:53:23 +0100 Subject: [PATCH 44/69] Track trio-next-dev for G7SensorKit and LibreLoop --- .gitmodules | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 024f06be07..1e672f1fef 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,7 +13,7 @@ [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 @@ -45,7 +45,7 @@ [submodule "LibreLoop"] path = LibreLoop url = https://github.com/loopandlearn/LibreLoop.git - branch = legacy-loopkit-compat + branch = trio-next-dev [submodule "LoopAlgorithm"] path = LoopAlgorithm url = https://github.com/loopandlearn/LoopAlgorithm.git From cda314ce5229eb6f36e9423bbf3f20fcb11a8e9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 00:46:03 +0000 Subject: [PATCH 45/69] CI: Bump APP_DEV_VERSION to 1.0.2.52 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 96ea08b0e6..07232cb717 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.51 +APP_DEV_VERSION = 1.0.2.52 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 68e13b6e16e7f7acc084d58c7d170f78d72ce1e3 Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sat, 31 Oct 2026 12:29:16 +0100 Subject: [PATCH 46/69] Use one formatter for glucose display --- Trio/Sources/Helpers/Formatters.swift | 17 ---------- Trio/Sources/Models/BloodGlucose.swift | 30 +++--------------- .../View/AdjustmentsRootView.swift | 11 ------- .../AdjustmentsRootView+Overrides.swift | 2 +- .../View/TempTargets/AddTempTargetForm.swift | 10 ------ .../AdjustmentsRootView+TempTargets.swift | 4 +-- .../View/TempTargets/EditTempTargetForm.swift | 22 ------------- .../View/ViewElements/TargetPicker.swift | 21 +++++-------- .../AlgorithmAdvancedSettingsRootView.swift | 2 +- .../View/AutosensSettingsRootView.swift | 15 +++------ .../View/BolusCalculatorConfigRootView.swift | 4 +-- .../View/CustomCGMOptionsView.swift | 8 ++--- .../View/UnitsLimitsSettingsRootView.swift | 6 ++-- .../History/HistoryDeletionTarget.swift | 4 +-- .../View/HistoryRootView+Adjustments.swift | 2 +- .../View/HistoryRootView+Glucose.swift | 18 +++-------- .../ChartElements/ChartSelectionRow.swift | 28 +++-------------- .../Home/View/Chart/MainChartView.swift | 2 +- .../Home/View/Header/CurrentGlucoseView.swift | 6 ++-- .../View/HomeRootView+BottomControls.swift | 31 +++---------------- .../Home/View/HomeRootView+Header.swift | 4 +-- .../QuickPickTreatmentsView.swift | 2 +- .../AlgorithmSettingsSubstepView.swift | 6 ++-- .../DeliveryLimitsStepView.swift | 6 ++-- .../InsulinSensitivityStepView.swift | 13 ++++---- .../View/OnboardingView+AlgorithmUtil.swift | 24 +++++++------- .../Onboarding/View/OnboardingView+Util.swift | 6 ++-- .../View/TherapySettingEditorView.swift | 11 ++++--- .../View/SMBSettingsRootView.swift | 8 ++--- .../SettingsExportStateModel.swift | 18 ++++------- .../Glucose/GlucoseDistributionChart.swift | 14 ++++----- .../Glucose/GlucosePercentileChart.swift | 6 ++-- .../Glucose/GlucoseSectorChart.swift | 15 ++------- .../View/TargetBehavoirRootView.swift | 16 +++++----- .../Treatments/View/ForecastChart.swift | 14 ++++----- .../Modules/Treatments/View/PopupView.swift | 8 ++--- .../View/UserInterfaceSettingsRootView.swift | 12 +++---- .../Services/Calendar/CalendarManager.swift | 19 +----------- .../ContactImage/ContactImageManager.swift | 10 ++---- .../WatchManager/AppleWatchManager.swift | 8 +---- .../Shortcuts/State/StateIntentRequest.swift | 17 +--------- Trio/Sources/Views/SettingInputSection.swift | 6 ++-- 42 files changed, 136 insertions(+), 350 deletions(-) diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index be380545d1..57ea4ce84e 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -50,23 +50,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 - } - static let bolusFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/Trio/Sources/Models/BloodGlucose.swift b/Trio/Sources/Models/BloodGlucose.swift index 48e3c89176..b7482d139b 100644 --- a/Trio/Sources/Models/BloodGlucose.swift +++ b/Trio/Sources/Models/BloodGlucose.swift @@ -199,12 +199,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 { @@ -225,12 +221,9 @@ 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) -> String { - units == .mgdL ? description : formattedAsMmolL + let value = units == .mgdL ? self : self * GlucoseUnits.exchangeRate + return value.formatted(Self.glucoseStyle(for: units)) } func formattedDelta(for units: GlucoseUnits) -> String { @@ -263,12 +256,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 { @@ -276,17 +265,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/Modules/Adjustments/View/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index 219a9c2558..fae3000129 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/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 90e751da35..7646aad72f 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -150,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/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index 62cbee078b..35e21f22f3 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -331,16 +331,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 03878b9f5b..cc0d228fcc 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -176,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") @@ -216,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 c6dd6743c4..cb1f69e71e 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -420,30 +420,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/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/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift b/Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift index 6eb1a7d431..298f971ea4 100644 --- a/Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift +++ b/Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift @@ -292,7 +292,7 @@ extension AlgorithmAdvancedSettings { "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 \(state.units == .mgdL ? "8" : 8.formattedAsMmolL) \(state.units.rawValue)/5min. This affects how fast COB is decayed in situations when carb absorption is not visible in BG deviations. The default of \(state.units == .mgdL ? "8" : 8.formattedAsMmolL) \(state.units.rawValue)/5min corresponds to a minimum carb absorption rate of 24 g/hr at a CSF of \(state.units == .mgdL ? "4" : 4.formattedAsMmolL) \(state.units.rawValue)/g." + "The default is an expected \(8.formatted(for: state.units)) \(state.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: state.units)) \(state.units.rawValue)/5min corresponds to a minimum carb absorption rate of 24 g/hr at a CSF of \(4.formatted(for: state.units)) \(state.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." diff --git a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift index 4e28a348c4..8aa47d4977 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.settingsManager.preferences - .useNewFormula ? newISF!.description : (dynamicISF ?? 0).description - ) - } else { - Text(( - !state.settingsManager.preferences - .useNewFormula ? newISF!.formattedAsMmolL : dynamicISF?.decimalValue.formattedAsMmolL - ) ?? "0") - } + Text( + (!state.settingsManager.preferences.useNewFormula ? newISF! : dynamicISF?.decimalValue ?? 0) + .formatted(for: state.units) + ) Text(state.units.rawValue + "/U").foregroundColor(.secondary) } diff --git a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift index 1d342f48b6..d635bd9d5e 100644 --- a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift +++ b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift @@ -161,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 43ab54ee4c..61ef18c628 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift @@ -326,7 +326,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) @@ -354,7 +354,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) @@ -367,7 +367,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) @@ -418,7 +418,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) diff --git a/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift b/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift index 2a78fe80c8..bacb6ce65b 100644 --- a/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift +++ b/Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift @@ -191,14 +191,14 @@ extension UnitsLimitsSettings { } VStack(alignment: .leading, spacing: 5) { Text( - "If your glucose target is \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue), Trio will use a safety threshold of \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue), unless you set Minimum Safety Threshold to something > \(state.units == .mgdL ? "75" : 75.formattedAsMmolL) \(state.units.rawValue)." + "If your glucose target is \(110.formatted(for: state.units)) \(state.units.rawValue), Trio will use a safety threshold of \(75.formatted(for: state.units)) \(state.units.rawValue), unless you set Minimum Safety Threshold to something > \(75.formatted(for: state.units)) \(state.units.rawValue)." ) 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/HistoryDeletionTarget.swift b/Trio/Sources/Modules/History/HistoryDeletionTarget.swift index 4941f3dea1..3828a7d103 100644 --- a/Trio/Sources/Modules/History/HistoryDeletionTarget.swift +++ b/Trio/Sources/Modules/History/HistoryDeletionTarget.swift @@ -34,9 +34,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/HistoryRootView+Adjustments.swift b/Trio/Sources/Modules/History/View/HistoryRootView+Adjustments.swift index ba3179906a..975804071e 100644 --- a/Trio/Sources/Modules/History/View/HistoryRootView+Adjustments.swift +++ b/Trio/Sources/Modules/History/View/HistoryRootView+Adjustments.swift @@ -94,7 +94,7 @@ extension History.RootView { guard let target = item.target, target != 0 else { return "" } - return "\(state.units == .mgdL ? target : target.asMmolL) \(state.units.rawValue)" + return target.formatted(withUnits: state.units) }() let labels: [String] = [ diff --git a/Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift b/Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift index 48a997acb2..e1a29c75cd 100644 --- a/Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift +++ b/Trio/Sources/Modules/History/View/HistoryRootView+Glucose.swift @@ -16,7 +16,7 @@ extension History.RootView { ? String(localized: "manual entry", comment: "Accessibility: manually entered glucose") : (glucose.directionEnum?.symbol ?? "--") HStack { - Text(formatGlucose(Decimal(glucose.glucose), isManual: glucose.isManual)) + Text(Decimal(glucose.glucose).formatted(for: state.units)) /// check for manual glucose if glucose.isManual { @@ -28,15 +28,13 @@ extension History.RootView { 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("(") + Text(Image(systemName: "sparkles")) + Text(" ") + - Text("\(smoothedGlucoseForDisplay)") + + Text(smoothedGlucoseForDisplay) + Text(")") ).foregroundStyle(.secondary) .padding(.leading, 10) @@ -48,7 +46,7 @@ extension History.RootView { } .accessibilityElement(children: .ignore) .accessibilityLabel(Text( - formatGlucose(Decimal(glucose.glucose), isManual: glucose.isManual) + Decimal(glucose.glucose).formatted(for: state.units) + ", " + manualOrTrend + ", " + Formatter.dateFormatter.string(from: glucose.date ?? Date()) )) @@ -77,12 +75,4 @@ extension History.RootView { } }.listRowBackground(Color.chart) } - - func formatGlucose(_ value: Decimal, isManual: Bool) -> String { - let formatter = isManual ? manualGlucoseFormatter : Formatter.glucoseFormatter(for: state.units) - let glucoseValue = state.units == .mmolL ? value.asMmolL : value - let formattedValue = formatter.string(from: glucoseValue as NSNumber) ?? "--" - - return formattedValue - } } diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift index 9c59c18d06..e589d55456 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/ChartSelectionRow.swift @@ -74,26 +74,6 @@ struct ChartSelectionRow: View { let glucoseColorScheme: GlucoseColorScheme let isSmoothingEnabled: Bool - 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, @@ -171,13 +151,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) } @@ -189,10 +169,10 @@ struct ChartSelectionRow: View { ) } - /// 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. diff --git a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift index 010f1d9700..85e25aef29 100644 --- a/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift +++ b/Trio/Sources/Modules/Home/View/Chart/MainChartView.swift @@ -218,7 +218,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 f6d76368c8..f9f8b5850e 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -199,7 +199,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) } @@ -218,9 +218,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/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 58d8a5403d..ad45c3c145 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -22,15 +22,6 @@ extension Home.RootView { return formatter } - private var fetchedTargetFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - if state.units == .mmolL { - formatter.maximumFractionDigits = 1 - } else { formatter.maximumFractionDigits = 0 } - return formatter - } - func remainingFraction(start: Date?, durationMinutes: Decimal?, indefinite: Bool) -> Double? { guard !indefinite, let start = start, let durationMinutes = durationMinutes, durationMinutes > 0 else { return nil @@ -79,11 +70,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 = "" } @@ -158,7 +147,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, @@ -173,9 +162,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 = @@ -540,14 +527,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 @@ -564,7 +543,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 f9b3eeeeb9..24c1b81754 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+Header.swift @@ -173,7 +173,7 @@ extension Home.RootView { .font(.callout) .fontWeight(.bold) - Text(state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL) + Text(eventualGlucose.formatted(for: state.units)) .font(.callout) .fontWeight(.bold) .fontDesign(.rounded) @@ -181,7 +181,7 @@ extension Home.RootView { .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/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift b/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift index 28d06a0615..aa0b157655 100644 --- a/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift +++ b/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift @@ -375,7 +375,7 @@ struct QuickPickTreatmentsView: View { .buttonStyle(.plain) .animation(.easeInOut(duration: 0.15), value: isSelected) .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("\(formatted) \(unitSpoken)")) + .accessibilityLabel(Text(formatted + " " + unitSpoken)) .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } 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 28bbc98ae3..9c81ef97b2 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 ffed864083..4b4a260a8c 100644 --- a/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift +++ b/Trio/Sources/Modules/Onboarding/View/TherapySettingEditorView.swift @@ -216,7 +216,7 @@ struct TherapySettingEditorView: View { } )) { ForEach(valueOptions, id: \.self) { value in - Text("\(displayText(for: unit, decimalValue: value)) \(unit.displayName)").tag(Double(value)) + Text(displayText(for: unit, decimalValue: value) + " " + unit.displayName).tag(Double(value)) } } .frame(maxWidth: .infinity) @@ -311,12 +311,13 @@ struct TherapySettingEditorView: View { switch unit { case .mmolL, .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, - .mgdLPerUnit: + case .gramPerUnit: return decimalValue.description } } diff --git a/Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift b/Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift index a1802b8d3d..35a67ce774 100644 --- a/Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift +++ b/Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift @@ -88,13 +88,13 @@ extension SMBSettings { type: .boolean, label: String(localized: "Enable SMB With Temptarget", comment: "Enable SMB With Temptarget"), miniHint: String( - localized: "Allow SMB when a manual Temporary Target is set under \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue)." + localized: "Allow SMB when a manual Temporary Target is set under \(100.formatted(for: state.units)) \(state.units.rawValue)." ), verboseHint: 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 \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.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: state.units)) \(state.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." @@ -184,13 +184,13 @@ extension SMBSettings { comment: "Allow SMB With High Temptarget" ), miniHint: String( - localized: "Allow SMB when a manual Temporary Target is set greater than \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue)." + localized: "Allow SMB when a manual Temporary Target is set greater than \(100.formatted(for: state.units)) \(state.units.rawValue)." ), verboseHint: 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 \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.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: state.units)) \(state.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." diff --git a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift index 3562eb0451..365240bc57 100644 --- a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift +++ b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift @@ -984,15 +984,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 )) } @@ -1008,10 +1004,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, @@ -1079,9 +1075,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/TargetBehavoir/View/TargetBehavoirRootView.swift b/Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift index f0a4b1e1ea..5d51a732a6 100644 --- a/Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift +++ b/Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift @@ -42,16 +42,16 @@ extension TargetBehavoir { comment: "High Temp Target Raises Sensitivity" ), miniHint: String( - localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set." + localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(100.formatted(for: state.units)) \(state.units.rawValue) is set." ), verboseHint: VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "When this feature is enabled, manually setting a temporary target above \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.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: state.units)) \(state.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 \(state.units == .mgdL ? "160" : 160.formattedAsMmolL) \(state.units.rawValue), a temp target of \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(state.units == .mgdL ? "140" : 140.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 0.6." + "If Half Basal Exercise Target is set to \(160.formatted(for: state.units)) \(state.units.rawValue), a temp target of \(120.formatted(for: state.units)) \(state.units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(140.formatted(for: state.units)) \(state.units.rawValue) uses an Autosens Ratio of 0.6." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") }, @@ -83,16 +83,16 @@ extension TargetBehavoir { miniHint: state.dosingMode == .basalTesting ? String(localized: "Basal Testing is on. Low temp targets will not lower sensitivity.") : String( - localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set." + localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(100.formatted(for: state.units)) \(state.units.rawValue) is set." ), verboseHint: VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "When this feature is enabled, setting a temporary target below \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.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: state.units)) \(state.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 \(state.units == .mgdL ? "160" : 160.formattedAsMmolL) \(state.units.rawValue), a Temp Target of \(state.units == .mgdL ? "95" : 95.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(state.units == .mgdL ? "85" : 85.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 1.33." + "If Half Basal Exercise Target is \(160.formatted(for: state.units)) \(state.units.rawValue), a Temp Target of \(95.formatted(for: state.units)) \(state.units.rawValue) uses an Autosens Ratio of 1.09. A Temp Target of \(85.formatted(for: state.units)) \(state.units.rawValue) uses an Autosens Ratio of 1.33." ) Text("Note: The effect of this can be adjusted with the Half Basal Exercise Target") } @@ -162,14 +162,14 @@ extension TargetBehavoir { verboseHint: VStack(alignment: .leading, spacing: 10) { Text( - "Default: \(state.units == .mgdL ? "160" : 160.formattedAsMmolL) \(state.units.rawValue)" + "Default: \(160.formatted(for: state.units)) \(state.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 \(state.units == .mgdL ? "160" : 160.formattedAsMmolL) \(state.units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue), 60% at \(state.units == .mgdL ? "140" : 140.formattedAsMmolL) \(state.units.rawValue))." + "For example, at a temp target of \(160.formatted(for: state.units)) \(state.units.rawValue), your basal is reduced to 50%, but this scales depending on the target (e.g., 75% at \(120.formatted(for: state.units)) \(state.units.rawValue), 60% at \(140.formatted(for: state.units)) \(state.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/Treatments/View/ForecastChart.swift b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift index 88e3ed4d45..b120d9e3e4 100644 --- a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift +++ b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift @@ -102,11 +102,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) @@ -206,7 +204,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 { @@ -242,9 +240,9 @@ 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) diff --git a/Trio/Sources/Modules/Treatments/View/PopupView.swift b/Trio/Sources/Modules/Treatments/View/PopupView.swift index a353428351..50f37db95d 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.formatted(for: state.units)) .valueStyle() Text("=") .operatorStyle() @@ -466,7 +466,7 @@ struct PopupView: View { .valueStyle() Text("/") .operatorStyle() - Text(state.units == .mmolL ? state.isf.formattedAsMmolL : state.isf.description) + Text(state.isf.formatted(for: state.units)) .valueStyle() Text("=") .operatorStyle() diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift index 07b10e2e5d..030c815117 100644 --- a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift +++ b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift @@ -217,7 +217,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) @@ -230,8 +230,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 @@ -269,7 +268,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) @@ -282,8 +281,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 @@ -331,7 +329,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." diff --git a/Trio/Sources/Services/Calendar/CalendarManager.swift b/Trio/Sources/Services/Calendar/CalendarManager.swift index a52f97e31f..47977572a6 100644 --- a/Trio/Sources/Services/Calendar/CalendarManager.swift +++ b/Trio/Sources/Services/Calendar/CalendarManager.swift @@ -26,18 +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 iobFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal @@ -254,12 +242,7 @@ 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 deltaText = delta.formattedDelta(for: settingsManager.settings.units) diff --git a/Trio/Sources/Services/ContactImage/ContactImageManager.swift b/Trio/Sources/Services/ContactImage/ContactImageManager.swift index a1aa847639..cbc2f70e75 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -201,7 +201,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { ? 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: settingsManager.settings.units) state.glucoseValue = value state.trend = firstGlucoseValue.directionEnum?.symbol state.direction = firstGlucoseValue.directionEnum @@ -239,12 +239,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: settingsManager.settings.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/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index 773b327038..0452707fc9 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) diff --git a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift index f19ee2ac56..1bf8e42d10 100644 --- a/Trio/Sources/Shortcuts/State/StateIntentRequest.swift +++ b/Trio/Sources/Shortcuts/State/StateIntentRequest.swift @@ -76,10 +76,7 @@ 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") @@ -106,16 +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 - } } diff --git a/Trio/Sources/Views/SettingInputSection.swift b/Trio/Sources/Views/SettingInputSection.swift index 314e8bd935..bb915be21a 100644 --- a/Trio/Sources/Views/SettingInputSection.swift +++ b/Trio/Sources/Views/SettingInputSection.swift @@ -230,8 +230,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 .insulinUnit: @@ -251,8 +250,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 .insulinUnit: From c61c7aeade32e971a27ff83b134ce7ec64ca60bf Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sun, 1 Nov 2026 12:52:12 +0100 Subject: [PATCH 47/69] Keep ISF decimals and finish glucose formatting cleanup --- Trio.xcodeproj/project.pbxproj | 4 ++ Trio/Sources/Models/BloodGlucose.swift | 25 ++++++++---- .../View/AutosensSettingsRootView.swift | 2 +- .../QuickPickTreatmentsView.swift | 2 +- .../InsulinSensitivityStepView.swift | 4 +- .../Treatments/View/ForecastChart.swift | 5 +-- .../Modules/Treatments/View/PopupView.swift | 4 +- .../View/UserInterfaceSettingsRootView.swift | 13 ++----- .../ContactImage/ContactImageManager.swift | 12 +++--- TrioTests/GlucoseFormattingTests.swift | 38 +++++++++++++++++++ 10 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 TrioTests/GlucoseFormattingTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index a0d8c777a5..ba8cf08541 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -686,6 +686,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 */; }; @@ -1912,6 +1913,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 = ""; }; @@ -3533,6 +3535,7 @@ 38FCF3EE25E9028E0078B0D1 /* TrioTests */ = { isa = PBXGroup; children = ( + A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */, DDDD0FFD2F4E231600F9C645 /* Mocks */, DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */, CA0924000000000000000005 /* GRICalculatorTests.swift */, @@ -6516,6 +6519,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 */, diff --git a/Trio/Sources/Models/BloodGlucose.swift b/Trio/Sources/Models/BloodGlucose.swift index b7482d139b..0b1e0e6863 100644 --- a/Trio/Sources/Models/BloodGlucose.swift +++ b/Trio/Sources/Models/BloodGlucose.swift @@ -221,23 +221,32 @@ extension Decimal { Trio.rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain) } - func formatted(for units: GlucoseUnits) -> String { - let value = units == .mgdL ? self : self * GlucoseUnits.exchangeRate - return value.formatted(Self.glucoseStyle(for: units)) + func formatted(for units: GlucoseUnits, locale: Locale = .autoupdatingCurrent) -> String { + converted(to: units).formatted(Self.glucoseStyle(for: units).locale(locale)) + } + + 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 formattedDelta(for units: GlucoseUnits) -> String { - let value = units == .mgdL ? self : self * GlucoseUnits.exchangeRate - return value.formatted(Self.glucoseStyle(for: units).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 static func glucoseStyle(for units: GlucoseUnits) -> Decimal.FormatStyle { + 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 ? 0 : 1)) + .precision(.fractionLength(units == .mgdL ? mgdLFractionLength : 1 ... 1)) .grouping(.never) .rounded(rule: .toNearestOrAwayFromZero) } diff --git a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift index 8aa47d4977..839c5f8c9d 100644 --- a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift +++ b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift @@ -88,7 +88,7 @@ extension AutosensSettings { Spacer() Text( (!state.settingsManager.preferences.useNewFormula ? newISF! : dynamicISF?.decimalValue ?? 0) - .formatted(for: state.units) + .formattedISF(for: state.units) ) Text(state.units.rawValue + "/U").foregroundColor(.secondary) } diff --git a/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift b/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift index aa0b157655..28d06a0615 100644 --- a/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift +++ b/Trio/Sources/Modules/Home/View/QuickPickTreatments/QuickPickTreatmentsView.swift @@ -375,7 +375,7 @@ struct QuickPickTreatmentsView: View { .buttonStyle(.plain) .animation(.easeInOut(duration: 0.15), value: isSelected) .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(formatted + " " + unitSpoken)) + .accessibilityLabel(Text("\(formatted) \(unitSpoken)")) .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/InsulinSensitivityStepView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/InsulinSensitivityStepView.swift index 6cc767651d..e21071206e 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/InsulinSensitivityStepView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/TherapySettings/InsulinSensitivityStepView.swift @@ -85,7 +85,7 @@ struct InsulinSensitivityStepView: View { let aboveTargetText = aboveTarget.formatted(for: state.units) let firstIsfRate: Decimal = state.isfRateValues[state.isfItems.first?.rateIndex ?? 0] let isfValueText = firstIsfRate.formatted(for: state.units) - let insulinNeeded = aboveTarget / firstIsfRate + let insulinNeeded = aboveTarget.asUnit(state.units) / firstIsfRate.asUnit(state.units) Text( "If you are \(aboveTargetText) \(state.units.rawValue) above target:" @@ -94,7 +94,7 @@ struct InsulinSensitivityStepView: View { .padding(.horizontal) Text( - "\(aboveTargetText) \(state.units.rawValue) / \(isfValueText) \(state.units.rawValue)/\(String(localized: "U", comment: "Insulin unit abbreviation")) = \(String(format: "%.1f", Double(insulinNeeded))) \(String(localized: "U", comment: "Insulin unit abbreviation"))" + "\(aboveTargetText) \(state.units.rawValue) / \(isfValueText) \(state.units.rawValue)/\(String(localized: "U", comment: "Insulin unit abbreviation")) = \(insulinNeeded.formatted(.number.precision(.fractionLength(1)))) \(String(localized: "U", comment: "Insulin unit abbreviation"))" ) .font(.system(.body, design: .monospaced)) .foregroundColor(.cyan) diff --git a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift index b120d9e3e4..bdd4a0e5f6 100644 --- a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift +++ b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift @@ -248,12 +248,9 @@ struct ForecastChart: View { ).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 50f37db95d..8439966244 100644 --- a/Trio/Sources/Modules/Treatments/View/PopupView.swift +++ b/Trio/Sources/Modules/Treatments/View/PopupView.swift @@ -243,7 +243,7 @@ struct PopupView: View { .operatorStyle() Text("/") .operatorStyle() - Text(state.isf.formatted(for: state.units)) + Text(state.isf.formattedISF(for: state.units)) .valueStyle() Text("=") .operatorStyle() @@ -466,7 +466,7 @@ struct PopupView: View { .valueStyle() Text("/") .operatorStyle() - Text(state.isf.formatted(for: state.units)) + Text(state.isf.formattedISF(for: state.units)) .valueStyle() Text("=") .operatorStyle() diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift index 030c815117..701b81b6f4 100644 --- a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift +++ b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift @@ -253,8 +253,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()) @@ -303,8 +302,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()) @@ -764,11 +762,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) @@ -798,7 +791,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/ContactImage/ContactImageManager.swift b/Trio/Sources/Services/ContactImage/ContactImageManager.swift index cbc2f70e75..b362d175d0 100644 --- a/Trio/Sources/Services/ContactImage/ContactImageManager.swift +++ b/Trio/Sources/Services/ContactImage/ContactImageManager.swift @@ -37,14 +37,11 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { private var coreDataPublisher: AnyPublisher, Never>? private var subscriptions = Set() - private var units: GlucoseUnits = .mgdL - weak var delegate: ContactImageManagerDelegate? init(resolver: Resolver) { super.init() injectServices(resolver) - units = settingsManager.settings.units coreDataPublisher = CoreDataStack.shared.entityChangePublisher .receive(on: queue) @@ -185,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() @@ -197,11 +195,11 @@ 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 = Decimal(firstGlucoseValue.glucose).formatted(for: settingsManager.settings.units) + state.glucose = Decimal(firstGlucoseValue.glucose).formatted(for: units) state.glucoseValue = value state.trend = firstGlucoseValue.directionEnum?.symbol state.direction = firstGlucoseValue.directionEnum @@ -209,7 +207,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { if glucoseObjects.count >= 2 { let delta = Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects[1].glucose) - state.delta = delta.formattedDelta(for: settingsManager.settings.units) + state.delta = delta.formattedDelta(for: units) } else { state.delta = nil } @@ -240,7 +238,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable { } if let eventualBG = lastDetermination?.eventualBG { - state.eventualBG = "⇢ " + eventualBG.decimalValue.formatted(for: settingsManager.settings.units) + 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/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: ",")) + } +} From 28c86f29e8eed57e96daf05a3ce14d915ac36b8c Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:08:26 +0100 Subject: [PATCH 48/69] Ignore timeZoneSync pump events explicitly --- Trio/Sources/APS/Storage/PumpHistoryStorage.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift index effceb32d4..ee355b2fca 100644 --- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift +++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift @@ -703,6 +703,7 @@ extension PumpEventType { case .replaceComponent(componentType: .infusionSet), .replaceComponent(componentType: .pump): return .siteChange case .replaceComponent: return nil + case .timeZoneSync: return nil @unknown default: return nil } } From 927ad50afff0d74cb7bb8764ed59158b4b710177 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:03:50 +0100 Subject: [PATCH 49/69] Describe alert catalog as Trio-owned in comments --- .../Sources/Services/Alerts/AlertCatalogRegistry.swift | 10 ++++------ Trio/Sources/Services/Alerts/AlertCatalogVendor.swift | 9 ++++----- 2 files changed, 8 insertions(+), 11 deletions(-) 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 } } From 2b74f6d6e1383d9ee801036967f6f03a87a19e52 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:12:39 +0100 Subject: [PATCH 50/69] Mark Tidepool async upload operation Sendable --- Trio/Sources/Services/Network/TidepoolUploadSerializer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift index 09466fb947..44e115e38c 100644 --- a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift +++ b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift @@ -180,7 +180,7 @@ extension TidepoolUploadSerializer { static func awaitUpload( _ label: String, timeout: TimeInterval = 120, - _ operation: @escaping () async throws -> Void + _ operation: @escaping @Sendable () async throws -> Void ) async -> Result { await awaitUpload(label, timeout: timeout) { completion in Task { From 680711fd6376edc9548e06ba8b8873a911013272 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:41:16 +0100 Subject: [PATCH 51/69] Bump DanaKit, AccuChekKit and EversenseKit pins --- AccuChekKit | 2 +- DanaKit | 2 +- EversenseKit | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AccuChekKit b/AccuChekKit index df7a26958c..afef2472a3 160000 --- a/AccuChekKit +++ b/AccuChekKit @@ -1 +1 @@ -Subproject commit df7a26958c3b30599abae99fe2c56cb757b66f13 +Subproject commit afef2472a38d5349b9b0fec702b7a7b07ccda039 diff --git a/DanaKit b/DanaKit index 9beaecc90f..a87fd73d31 160000 --- a/DanaKit +++ b/DanaKit @@ -1 +1 @@ -Subproject commit 9beaecc90f58011e9623fa60cb18907dd3cd1363 +Subproject commit a87fd73d31e23e8552f65a04a9a172694765e2f6 diff --git a/EversenseKit b/EversenseKit index 82b1f108af..0c2ce3cc2b 160000 --- a/EversenseKit +++ b/EversenseKit @@ -1 +1 @@ -Subproject commit 82b1f108af77865a4c7c72ba9b3c733fca519fcd +Subproject commit 0c2ce3cc2b626c80fd162faef40e67b96dba77ae From 9b74620012825874f67e83666befbd82e121bca5 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:17:20 +0100 Subject: [PATCH 52/69] Handle kit alerts off the main actor --- Trio/Sources/APS/CGM/PluginSource.swift | 12 ++++++------ Trio/Sources/APS/DeviceDataManager.swift | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift index 35c1d22428..ba09469187 100644 --- a/Trio/Sources/APS/CGM/PluginSource.swift +++ b/Trio/Sources/APS/CGM/PluginSource.swift @@ -116,12 +116,12 @@ 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 @@ -143,8 +143,8 @@ extension PluginSource: CGMManagerDelegate { /// 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) async throws { - 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) async { diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index acbd5018af..6919d9a2a7 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -642,14 +642,14 @@ 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) async throws -> Bool { false } From 1b181d1be7ed31bc0dc479d20d30ee1000dbc925 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:17:20 +0100 Subject: [PATCH 53/69] Mark Bluetooth state manager MainActor --- Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift index b48e1cae5b..6872767be6 100644 --- a/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift +++ b/Trio/Sources/Services/Bluetooth/BluetoothStateManager.swift @@ -5,7 +5,7 @@ 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() From f6af098a225e6fed4741590fc13d84382e5b122a Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:17:20 +0100 Subject: [PATCH 54/69] Format Tidepool upload closure --- Trio/Sources/Services/Network/TidepoolUploadSerializer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift index 44e115e38c..5b21c1729a 100644 --- a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift +++ b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift @@ -180,7 +180,7 @@ extension TidepoolUploadSerializer { static func awaitUpload( _ label: String, timeout: TimeInterval = 120, - _ operation: @escaping @Sendable () async throws -> Void + _ operation: @escaping @Sendable() async throws -> Void ) async -> Result { await awaitUpload(label, timeout: timeout) { completion in Task { From ccb38a34ea5902fc264a93d7e6c8c25d8c115f59 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 16:51:02 +0000 Subject: [PATCH 55/69] CI: Bump APP_DEV_VERSION to 1.0.2.53 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 07232cb717..3828b4d7d4 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.52 +APP_DEV_VERSION = 1.0.2.53 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 8a790531902d73658ab8f124176d4d2c1122c852 Mon Sep 17 00:00:00 2001 From: Haaris Edhi Date: Sun, 1 Nov 2026 13:10:06 -0600 Subject: [PATCH 56/69] Skip Apple Health insulin samples that end before they start A temp basal dated across a clock change is stored with its end before its start, so the upload built an HKQuantitySample whose endDate precedes its startDate. HealthKit validates the sample when saving it and raises _HKObjectValidationFailureException, an Objective-C exception that the upload's do/catch cannot handle, so the app terminated on every attempt to upload insulin until that row left the 24 hour query. Both places that derive a sample's end from a pump event now skip the sample and log it instead of building one whose end precedes its start. Co-authored-by: CommandCodeBot --- .../Services/HealthKit/HealthKitManager.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 From 7b2b623db544896f3135d40fe387b191d218c9a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 20:58:46 +0000 Subject: [PATCH 57/69] CI: Bump APP_DEV_VERSION to 1.0.2.54 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 3828b4d7d4..d56103e83f 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.53 +APP_DEV_VERSION = 1.0.2.54 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 96bbe5950b361a6fcd7d4acdd0dbf0896b328e8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 22:13:57 +0000 Subject: [PATCH 58/69] CI: Bump APP_DEV_VERSION to 1.0.2.55 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index d56103e83f..a4ed7cdf23 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.54 +APP_DEV_VERSION = 1.0.2.55 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 3e5d3f4e3634987fb8eb475a2f297f4591823fbe Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:05:13 +0100 Subject: [PATCH 59/69] Bump submodules to LoopKit-owned value types --- AccuChekKit | 2 +- CGMBLEKit | 2 +- DanaKit | 2 +- EversenseKit | 2 +- G7SensorKit | 2 +- LibreLoop | 2 +- LibreTransmitter | 2 +- LoopKit | 2 +- MedtrumKit | 2 +- MinimedKit | 2 +- OmnipodKit | 2 +- SyaiKit | 2 +- TidepoolService | 2 +- dexcom-share-client-swift | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AccuChekKit b/AccuChekKit index afef2472a3..5b03b01fe5 160000 --- a/AccuChekKit +++ b/AccuChekKit @@ -1 +1 @@ -Subproject commit afef2472a38d5349b9b0fec702b7a7b07ccda039 +Subproject commit 5b03b01fe5f7236218b34fc86e64d6a6201e4dac diff --git a/CGMBLEKit b/CGMBLEKit index 4eee5121a1..aaeb9d34a7 160000 --- a/CGMBLEKit +++ b/CGMBLEKit @@ -1 +1 @@ -Subproject commit 4eee5121a12c01032b306063b9b673ffea1bf126 +Subproject commit aaeb9d34a7440e224cd3245cbcb3109362d59ce7 diff --git a/DanaKit b/DanaKit index a87fd73d31..0ef44f9fab 160000 --- a/DanaKit +++ b/DanaKit @@ -1 +1 @@ -Subproject commit a87fd73d31e23e8552f65a04a9a172694765e2f6 +Subproject commit 0ef44f9fab9b653009c0a6128646ef75d1757e93 diff --git a/EversenseKit b/EversenseKit index 0c2ce3cc2b..0f75ccbe79 160000 --- a/EversenseKit +++ b/EversenseKit @@ -1 +1 @@ -Subproject commit 0c2ce3cc2b626c80fd162faef40e67b96dba77ae +Subproject commit 0f75ccbe79346a9942ad56a0a6ff46d889059676 diff --git a/G7SensorKit b/G7SensorKit index e02347769c..32dafea98c 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit e02347769c1c53772bfccd18cb9f4fc007e80a15 +Subproject commit 32dafea98c7ccc56c7c1f66cc1e1f82bb4302802 diff --git a/LibreLoop b/LibreLoop index 0cf47529a7..01b9bf0e36 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit 0cf47529a7e456aaf20855078525f076002ab009 +Subproject commit 01b9bf0e3695fcceff657fcd7dde59f500ca52fd diff --git a/LibreTransmitter b/LibreTransmitter index 30e3739cf5..5f5881dadc 160000 --- a/LibreTransmitter +++ b/LibreTransmitter @@ -1 +1 @@ -Subproject commit 30e3739cf5bed2e259bfe376ded356f1b1dbbafb +Subproject commit 5f5881dadcf2e4c81ae4edc051b2de844d4980d6 diff --git a/LoopKit b/LoopKit index bd89cc561d..e11ca0d90a 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit bd89cc561d875f30a4366deff96bd7055f3a4e6e +Subproject commit e11ca0d90aa794b7c2d94bba2156c6290a1b760f diff --git a/MedtrumKit b/MedtrumKit index a84fc64876..882102ed4c 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit a84fc6487657a2285050d29f783bf86ed0700914 +Subproject commit 882102ed4c8efe3fd0ac463b0da99f47269b8299 diff --git a/MinimedKit b/MinimedKit index 81112d8ece..016dddfe2a 160000 --- a/MinimedKit +++ b/MinimedKit @@ -1 +1 @@ -Subproject commit 81112d8ecee065e8ecf2ecb4f6707dcb4ae7f8f9 +Subproject commit 016dddfe2a2c75e3e8c47e5830201350fc051a78 diff --git a/OmnipodKit b/OmnipodKit index 1ed2013224..7c96720852 160000 --- a/OmnipodKit +++ b/OmnipodKit @@ -1 +1 @@ -Subproject commit 1ed2013224c1247b5f68e2b8e14217ada02aa436 +Subproject commit 7c96720852239379d6afceff35bd935fba4ad0ba diff --git a/SyaiKit b/SyaiKit index 00c08388bd..8372b7e4bc 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit 00c08388bd96856958223f0af262f25ff44b89ec +Subproject commit 8372b7e4bc3737f181c568ed59b4c9035cfa6c20 diff --git a/TidepoolService b/TidepoolService index 54097855c7..9cee9e1389 160000 --- a/TidepoolService +++ b/TidepoolService @@ -1 +1 @@ -Subproject commit 54097855c7ff9a868d406664ef17f1a7c929ebb7 +Subproject commit 9cee9e1389980df667e1bfab46e391ca74961fec diff --git a/dexcom-share-client-swift b/dexcom-share-client-swift index c91e893dad..f39801d4b3 160000 --- a/dexcom-share-client-swift +++ b/dexcom-share-client-swift @@ -1 +1 @@ -Subproject commit c91e893dadc2ccf21f8423675264c511c8433a8a +Subproject commit f39801d4b3e67850ea7b3a0944fedf2101a0cdd2 From a9ffc88405d4988212fb25cf6fa6c91699350968 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:05:13 +0100 Subject: [PATCH 60/69] Update submodule branches in update script --- scripts/define_common_trio.sh | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/scripts/define_common_trio.sh b/scripts/define_common_trio.sh index 287937cb89..bb7fda0b04 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 +# tracks 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:trio-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 \ ) From 800ba231d9cefffa50f24be9b59f000e3e7547a2 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:05:18 +0100 Subject: [PATCH 61/69] Remove LoopAlgorithm submodule --- .gitmodules | 3 --- LoopAlgorithm | 1 - Trio.xcodeproj/project.pbxproj | 5 ----- Trio.xcworkspace/contents.xcworkspacedata | 3 --- Trio/Sources/APS/DeviceDataManager.swift | 1 - Trio/Sources/APS/FetchGlucoseManager.swift | 1 - Trio/Sources/Models/BloodGlucose+LoopKit.swift | 1 - .../AlgorithmAdvancedSettingsProvider.swift | 1 - .../GeneralSettings/UnitsLimitsSettingsProvider.swift | 1 - .../Modules/NightscoutConfig/NightscoutConfigProvider.swift | 1 - Trio/Sources/Services/Network/TidepoolManager.swift | 1 - TrioTests/CoreDataTests/PumpEventFinalizationTests.swift | 2 +- TrioTests/DeliveryLimitsSyncTests.swift | 1 - TrioTests/TidepoolTherapySettingsTests.swift | 1 - scripts/swiftformat.sh | 1 - 15 files changed, 1 insertion(+), 23 deletions(-) delete mode 160000 LoopAlgorithm diff --git a/.gitmodules b/.gitmodules index 1e672f1fef..d2362f62b4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -46,9 +46,6 @@ path = LibreLoop url = https://github.com/loopandlearn/LibreLoop.git branch = trio-next-dev -[submodule "LoopAlgorithm"] - path = LoopAlgorithm - url = https://github.com/loopandlearn/LoopAlgorithm.git [submodule "SyaiKit"] path = SyaiKit url = https://github.com/nightscout/SyaiKit 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/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index b38690be32..4868f0800f 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -5482,7 +5482,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 */; @@ -7324,10 +7323,6 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 5E12FE112EBC0000000000A0 /* XCLocalSwiftPackageReference "LoopAlgorithm" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = LoopAlgorithm; - }; 5E12FE112EBC0000000000B0 /* XCLocalSwiftPackageReference "RoundWhiteDiscKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = 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/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 6919d9a2a7..8437d9c06a 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,7 +1,6 @@ import Combine import CoreData import Foundation -import LoopAlgorithm import LoopKit import LoopKitUI import MedtrumKit diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index a7fd9b35ce..d47df1a31a 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -1,7 +1,6 @@ import Combine import CoreData import Foundation -import LoopAlgorithm import LoopKit import LoopKitUI import SwiftDate diff --git a/Trio/Sources/Models/BloodGlucose+LoopKit.swift b/Trio/Sources/Models/BloodGlucose+LoopKit.swift index e9a7435d06..3c8ba80bd4 100644 --- a/Trio/Sources/Models/BloodGlucose+LoopKit.swift +++ b/Trio/Sources/Models/BloodGlucose+LoopKit.swift @@ -1,6 +1,5 @@ import Foundation import HealthKit -import LoopAlgorithm import LoopKit // LoopKit/HealthKit adapter for `BloodGlucose`. diff --git a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift index 21fbe0ee49..d6441d61c5 100644 --- a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift +++ b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsProvider.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import LoopAlgorithm import LoopKit import LoopKitUI diff --git a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsProvider.swift index 6c315deddc..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 LoopAlgorithm import LoopKit import LoopKitUI diff --git a/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigProvider.swift index 131a2efec8..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 LoopAlgorithm import LoopKit import LoopKitUI diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 19c10c3f54..5b00c9fc3f 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -3,7 +3,6 @@ import CoreData import CryptoKit import Foundation import HealthKit -import LoopAlgorithm import LoopKit import LoopKitUI import Swinject diff --git a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift index 32d86c2e9e..a87967a819 100644 --- a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift +++ b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift @@ -1,6 +1,6 @@ import CoreData import Foundation -import LoopAlgorithm +import LoopKit import Swinject import Testing diff --git a/TrioTests/DeliveryLimitsSyncTests.swift b/TrioTests/DeliveryLimitsSyncTests.swift index 083c8657ac..72fca0b36e 100644 --- a/TrioTests/DeliveryLimitsSyncTests.swift +++ b/TrioTests/DeliveryLimitsSyncTests.swift @@ -1,5 +1,4 @@ import Foundation -import LoopAlgorithm import LoopKit import Testing diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index cf76b3ef3c..4c8696733b 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -1,6 +1,5 @@ import CryptoKit import HealthKit -import LoopAlgorithm import LoopKit import Testing import TidepoolKit diff --git a/scripts/swiftformat.sh b/scripts/swiftformat.sh index a9811027e4..c85ee36fc1 100755 --- a/scripts/swiftformat.sh +++ b/scripts/swiftformat.sh @@ -120,7 +120,6 @@ trailingClosures \ --exclude OmnipodKit \ --exclude LibreLoop \ --exclude RoundWhiteDiscKit \ ---exclude LoopAlgorithm \ --exclude AccuChekKit \ --exclude EversenseKit \ --exclude SyaiKit From 78491be2b7a80c3c7593afa89f96e8a9faad07fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Nov 2026 22:15:44 +0000 Subject: [PATCH 62/69] CI: Bump APP_DEV_VERSION to 1.0.2.56 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index a4ed7cdf23..1b42ad46f2 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.55 +APP_DEV_VERSION = 1.0.2.56 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 1c6e49b5c5058c8e9790e65d0c9e01a53410771d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Nov 2026 19:29:41 +0000 Subject: [PATCH 63/69] CI: Bump APP_DEV_VERSION to 1.0.2.57 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1b42ad46f2..0e20bebee4 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.56 +APP_DEV_VERSION = 1.0.2.57 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 685a0d82949cf70f8531e4625aaecca8ee3307c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Nov 2026 20:18:38 +0000 Subject: [PATCH 64/69] CI: Bump APP_DEV_VERSION to 1.0.2.58 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0e20bebee4..d68c900652 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.57 +APP_DEV_VERSION = 1.0.2.58 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From e11ddab1e864dc9a21e5102f89df37f22de1e3ad Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:14:54 +0100 Subject: [PATCH 65/69] Adopt async device deletion and track next-dev --- .gitmodules | 13 +++++++++++++ AccuChekKit | 2 +- CGMBLEKit | 2 +- DanaKit | 2 +- EversenseKit | 2 +- G7SensorKit | 2 +- LibreLoop | 2 +- LibreTransmitter | 2 +- LoopKit | 2 +- MedtrumKit | 2 +- MinimedKit | 2 +- OmnipodKit | 2 +- RileyLinkKit | 2 +- SyaiKit | 2 +- Trio/Sources/APS/DeviceDataManager.swift | 14 +++++++++----- dexcom-share-client-swift | 2 +- scripts/define_common_trio.sh | 6 +++--- 17 files changed, 39 insertions(+), 22 deletions(-) diff --git a/.gitmodules b/.gitmodules index d2362f62b4..79da6ecc30 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,19 @@ [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 @@ -17,27 +21,35 @@ [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 @@ -49,3 +61,4 @@ [submodule "SyaiKit"] path = SyaiKit url = https://github.com/nightscout/SyaiKit + branch = trio-next-dev diff --git a/AccuChekKit b/AccuChekKit index 5b03b01fe5..3a784a8e2b 160000 --- a/AccuChekKit +++ b/AccuChekKit @@ -1 +1 @@ -Subproject commit 5b03b01fe5f7236218b34fc86e64d6a6201e4dac +Subproject commit 3a784a8e2b79e6f12edcc0ff325869cf8f3948e0 diff --git a/CGMBLEKit b/CGMBLEKit index aaeb9d34a7..5bde830de8 160000 --- a/CGMBLEKit +++ b/CGMBLEKit @@ -1 +1 @@ -Subproject commit aaeb9d34a7440e224cd3245cbcb3109362d59ce7 +Subproject commit 5bde830de82f62b71dc60e06765d5c1704396c56 diff --git a/DanaKit b/DanaKit index 0ef44f9fab..787a639245 160000 --- a/DanaKit +++ b/DanaKit @@ -1 +1 @@ -Subproject commit 0ef44f9fab9b653009c0a6128646ef75d1757e93 +Subproject commit 787a6392451fd1c29597afa83a68587b71908434 diff --git a/EversenseKit b/EversenseKit index 0f75ccbe79..ef496f1688 160000 --- a/EversenseKit +++ b/EversenseKit @@ -1 +1 @@ -Subproject commit 0f75ccbe79346a9942ad56a0a6ff46d889059676 +Subproject commit ef496f1688a7d8a17941a95ec9e336f81cd267d0 diff --git a/G7SensorKit b/G7SensorKit index 32dafea98c..08d488633c 160000 --- a/G7SensorKit +++ b/G7SensorKit @@ -1 +1 @@ -Subproject commit 32dafea98c7ccc56c7c1f66cc1e1f82bb4302802 +Subproject commit 08d488633cc9e248a4b4095626c958ed3a75d0d7 diff --git a/LibreLoop b/LibreLoop index 01b9bf0e36..0c90cd9a05 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit 01b9bf0e3695fcceff657fcd7dde59f500ca52fd +Subproject commit 0c90cd9a05353ed60642100e0bf1f7c1ec05f76a diff --git a/LibreTransmitter b/LibreTransmitter index 5f5881dadc..fb8e4ad28d 160000 --- a/LibreTransmitter +++ b/LibreTransmitter @@ -1 +1 @@ -Subproject commit 5f5881dadcf2e4c81ae4edc051b2de844d4980d6 +Subproject commit fb8e4ad28dc75b8a0d51db1d9b968d150b28747b diff --git a/LoopKit b/LoopKit index e11ca0d90a..babddf0453 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit e11ca0d90aa794b7c2d94bba2156c6290a1b760f +Subproject commit babddf04536c04c6852ee7c76a1905164ee9893d diff --git a/MedtrumKit b/MedtrumKit index 882102ed4c..1e761ef6ac 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit 882102ed4c8efe3fd0ac463b0da99f47269b8299 +Subproject commit 1e761ef6acee446262e6cedd5e9cff5979260d10 diff --git a/MinimedKit b/MinimedKit index 016dddfe2a..008aab09c1 160000 --- a/MinimedKit +++ b/MinimedKit @@ -1 +1 @@ -Subproject commit 016dddfe2a2c75e3e8c47e5830201350fc051a78 +Subproject commit 008aab09c108b0c29d999134c9a1103f5dab15d7 diff --git a/OmnipodKit b/OmnipodKit index 7c96720852..6fdf0e0e34 160000 --- a/OmnipodKit +++ b/OmnipodKit @@ -1 +1 @@ -Subproject commit 7c96720852239379d6afceff35bd935fba4ad0ba +Subproject commit 6fdf0e0e342b734e1a844f388d4cedc68c34196c diff --git a/RileyLinkKit b/RileyLinkKit index 1a40a3373a..52a652c932 160000 --- a/RileyLinkKit +++ b/RileyLinkKit @@ -1 +1 @@ -Subproject commit 1a40a3373a7999fc4922b8e6ce146a2457a7031e +Subproject commit 52a652c932cdd5b677b11f149654ab773c433188 diff --git a/SyaiKit b/SyaiKit index 8372b7e4bc..d46674c540 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit 8372b7e4bc3737f181c568ed59b4c9035cfa6c20 +Subproject commit d46674c5405d16418b55709d2e7464cd95d92412 diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 9fee71847c..ac1f2db4e7 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -544,11 +544,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() + } } } diff --git a/dexcom-share-client-swift b/dexcom-share-client-swift index f39801d4b3..e7eda40295 160000 --- a/dexcom-share-client-swift +++ b/dexcom-share-client-swift @@ -1 +1 @@ -Subproject commit f39801d4b3e67850ea7b3a0944fedf2101a0cdd2 +Subproject commit e7eda402954511016c53443270778d688736040e diff --git a/scripts/define_common_trio.sh b/scripts/define_common_trio.sh index bb7fda0b04..837c71fb86 100755 --- a/scripts/define_common_trio.sh +++ b/scripts/define_common_trio.sh @@ -12,8 +12,8 @@ # define the TRIO_PROJECTS used by Trio where for Trio the .gitmodules points # to the downstream fork of loopandlearn (SyaiKit: nightscout) # -# Kits track trio-next-dev (upstream next-dev plus Trio carries); OmnipodKit -# tracks next-dev and RoundWhiteDiscKit main +# 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 @@ -21,7 +21,7 @@ TRIO_PROJECTS=( \ loopandlearn:AccuChekKit:trio-next-dev \ loopandlearn:CGMBLEKit:trio-next-dev \ loopandlearn:DanaKit:trio-next-dev \ - loopandlearn:dexcom-share-client-swift: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 \ From 23bb3be3634e3d3f3fe2e9bcf525c3d4425b24ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 5 Nov 2026 06:03:53 +0000 Subject: [PATCH 66/69] CI: Bump APP_DEV_VERSION to 1.0.2.59 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index d68c900652..58cb6a481b 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.58 +APP_DEV_VERSION = 1.0.2.59 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 5c077e40e9020b5719d1666527f93508bdfe3c5b Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 5 Nov 2026 18:04:28 +0100 Subject: [PATCH 67/69] fix(sync): drop four dangling pbxproj refs and the dead fetchedTargetFormatter --- Trio.xcodeproj/project.pbxproj | 4 ---- .../Modules/Home/View/HomeRootView+BottomControls.swift | 9 --------- 2 files changed, 13 deletions(-) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 5fb55a6310..9625ec270e 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -3557,7 +3557,6 @@ 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 */, @@ -3578,7 +3577,6 @@ 388E5A5B25B6F0770019842D /* JSON.swift */, 38A00B2225FC2B55006BC0B0 /* LRUCache.swift */, 49B21BBC2D7F102100D0B6C3 /* MainChartHelper.swift */, - 38FCF3D525E8FDF40078B0D1 /* MD5.swift */, 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */, 38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */, DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */, @@ -6700,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 */, diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 80176c95df..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 From 15c45e8c2084a44e1500efd865d1604e67eb7e64 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 5 Nov 2026 18:47:42 +0100 Subject: [PATCH 68/69] fix(sync): port PumpConfig delivery limits to LoopQuantity --- Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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? From b0458cc1f2665ab14ecbd2e5bcec58f4c1a71c89 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 5 Nov 2026 19:12:54 +0100 Subject: [PATCH 69/69] fix(sync): restore numberFormatter for the autoISF chart axis labels --- .../Sources/Modules/ISFEditor/View/ISFEditorRootView.swift | 7 +++++++ .../Modules/TargetsEditor/View/TargetsEditorRootView.swift | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift index 7760efad22..e248928886 100644 --- a/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift +++ b/Trio/Sources/Modules/ISFEditor/View/ISFEditorRootView.swift @@ -13,6 +13,13 @@ extension ISFEditor { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = state.units == .mmolL ? 1 : 0 + return formatter + } + var saveButton: some View { ZStack { let shouldDisableButton = state.items.isEmpty || !state.hasChanges diff --git a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift index 53030e6b1b..c091d334f4 100644 --- a/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift +++ b/Trio/Sources/Modules/TargetsEditor/View/TargetsEditorRootView.swift @@ -13,6 +13,13 @@ extension TargetsEditor { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = state.units == .mmolL ? 1 : 0 + return formatter + } + var saveButton: some View { ZStack { let shouldDisableButton = state.shouldDisplaySaving || state.items.isEmpty || !state.hasChanges