From d951c37e053fdeb5501a445e5aff92dbd778e846 Mon Sep 17 00:00:00 2001 From: Lamercho <33007090+Lamercho@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:07:13 +0200 Subject: [PATCH 01/20] Fix basal rate editor saving the lowest rate on 0.01 U/h pumps --- .../AutotuneConfig/AutotuneConfigStateModel.swift | 5 ++++- .../BasalProfileEditorProvider.swift | 7 +++++-- .../BasalProfileEditorStateModel.swift | 10 ++++++---- Trio/Sources/Modules/Home/HomeStateModel.swift | 6 +++++- .../Modules/PumpConfig/PumpConfigStateModel.swift | 5 ++++- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift index dd40287114..96a150b124 100644 --- a/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift +++ b/Trio/Sources/Modules/AutotuneConfig/AutotuneConfigStateModel.swift @@ -90,7 +90,10 @@ extension AutotuneConfig { return } let syncValues = basals.map { - RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) + RepeatingScheduleValue( + startTime: TimeInterval($0.minutes * 60), + value: Double($0.rate).roundedDouble(toPlaces: 3) + ) } pump.syncBasalRateSchedule(items: syncValues) { result in switch result { diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index 834c6891ef..fa87b70712 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift @@ -18,7 +18,7 @@ extension BasalProfileEditor { // from this list. deviceManager.pumpManager?.supportedBasalRates .filter { $0 > 0 } - .map { Decimal($0) } + .map { Decimal($0).rounded(scale: 3) } } func saveProfile(_ profile: [BasalProfileEntry]) -> AnyPublisher { @@ -28,7 +28,10 @@ extension BasalProfileEditor { } let syncValues = profile.map { - RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) + RepeatingScheduleValue( + startTime: TimeInterval($0.minutes * 60), + value: Double($0.rate).roundedDouble(toPlaces: 3) + ) } return Future { promise in diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift index 8e805278ba..e7e5fe5e03 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift @@ -42,7 +42,7 @@ extension BasalProfileEditor { func updateFromTherapyItems(_ therapyItems: [TherapySettingItem]) { items = therapyItems.map { therapyItem in let timeIndex = timeValues.firstIndex(where: { abs($0 - therapyItem.time) < 1 }) ?? 0 - let rateIndex = rateValues.firstIndex(of: therapyItem.value) ?? 0 + let rateIndex = rateValues.lastIndex(where: { $0 <= therapyItem.value.rounded(scale: 3) }) ?? 0 return Item(rateIndex: rateIndex, timeIndex: timeIndex) } } @@ -50,13 +50,15 @@ extension BasalProfileEditor { override func subscribe() { rateValues = provider.supportedBasalRates ?? stride(from: 5.0, to: 1001.0, by: 5.0) .map { ($0.decimal ?? .zero) / 100 } - items = provider.profile.map { value in + let profile = provider.profile + items = profile.map { value in let timeIndex = timeValues.firstIndex(of: Double(value.minutes * 60)) ?? 0 - let rateIndex = rateValues.firstIndex(of: value.rate) ?? 0 + let rateIndex = rateValues.lastIndex(where: { $0 <= value.rate.rounded(scale: 3) }) ?? 0 return Item(rateIndex: rateIndex, timeIndex: timeIndex) } - initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } + let ratesMatch = zip(profile, items).allSatisfy { $0.rate == rateValues[$1.rateIndex] } + initialItems = ratesMatch ? items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } : [] calcTotal() } diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 4698868442..951d493334 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -861,7 +861,11 @@ extension Home { if let schedule = BasalRateSchedule( dailyItems: basalProfile - .map { RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) } + .map { + RepeatingScheduleValue( + startTime: TimeInterval($0.minutes * 60), + value: Double($0.rate).roundedDouble(toPlaces: 3) + ) } ) { self.pumpInitialSettings.basalSchedule = schedule } diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift index 2a04f422e9..6b50ea2fb7 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift @@ -20,7 +20,10 @@ extension PumpConfig { Task { let basalSchedule = BasalRateSchedule( dailyItems: await provider.getBasalProfile().map { - RepeatingScheduleValue(startTime: $0.minutes.minutes.timeInterval, value: Double($0.rate)) + RepeatingScheduleValue( + startTime: $0.minutes.minutes.timeInterval, + value: Double($0.rate).roundedDouble(toPlaces: 3) + ) } ) From 5352a14b5ba3134bf56f1c12d451faa45ffed4ae Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sat, 31 Oct 2026 15:07:52 +0100 Subject: [PATCH 02/20] Format percentages with the locale's percent style Fix temp target percentages being truncated instead of rounded. --- Trio.xcodeproj/project.pbxproj | 4 ++ Trio/Sources/APS/OpenAPS/OpenAPS.swift | 2 +- Trio/Sources/Helpers/Formatters.swift | 27 +++++++++ .../Localizations/Main/Localizable.xcstrings | 27 +++++++-- Trio/Sources/Models/BloodGlucose.swift | 3 +- .../AdjustmentsStateModel+Helpers.swift | 22 ++++--- .../AdjustmentsStateModel+TempTargets.swift | 6 +- .../View/Overrides/AddOverrideForm.swift | 6 +- .../AdjustmentsRootView+Overrides.swift | 7 +-- .../View/Overrides/EditOverrideForm.swift | 6 +- .../View/TempTargets/AddTempTargetForm.swift | 11 +--- .../AdjustmentsRootView+TempTargets.swift | 16 +++-- .../View/TempTargets/EditTempTargetForm.swift | 20 ++----- .../View/AutosensSettingsRootView.swift | 11 +--- .../Modules/Home/View/Header/PumpView.swift | 5 +- .../View/HomeRootView+BottomControls.swift | 16 +++-- .../AlgorithmSettingsSubstepView.swift | 4 +- .../SettingsExportStateModel.swift | 4 +- .../View/ViewElements/Glucose/GRIView.swift | 6 +- .../GlucoseDailyDistributionChart.swift | 2 +- .../Glucose/GlucoseDistributionChart.swift | 2 +- .../Glucose/GlucoseMetricsView.swift | 7 +-- .../Glucose/GlucoseSectorChart.swift | 58 +++++++------------ .../Looping/LoopBarChartView.swift | 2 +- .../ViewElements/Looping/LoopStatsView.swift | 3 +- .../Modules/Treatments/View/PopupView.swift | 10 ++-- Trio/Sources/Views/SettingInputSection.swift | 4 +- TrioTests/PercentFormattingTests.swift | 37 ++++++++++++ 28 files changed, 183 insertions(+), 145 deletions(-) create mode 100644 TrioTests/PercentFormattingTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index b38690be32..c9bc1c2c7b 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -686,6 +686,7 @@ 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 */; }; + A1A0EDF53097CE470028A032 /* PercentFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A0EDF43097CE470028A032 /* PercentFormattingTests.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 */; }; @@ -1913,6 +1914,7 @@ 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 = ""; }; + A1A0EDF43097CE470028A032 /* PercentFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentFormattingTests.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 = ""; }; @@ -3534,6 +3536,7 @@ isa = PBXGroup; children = ( A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */, + A1A0EDF43097CE470028A032 /* PercentFormattingTests.swift */, DDDD0FFD2F4E231600F9C645 /* Mocks */, DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */, CA0924000000000000000005 /* GRICalculatorTests.swift */, @@ -6517,6 +6520,7 @@ 3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */, 3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */, A1A0EDA830974FFF0028A032 /* GlucoseFormattingTests.swift in Sources */, + A1A0EDF53097CE470028A032 /* PercentFormattingTests.swift in Sources */, 3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */, BD11A011000000000000A011 /* RoundBasalTests.swift in Sources */, BD11A015000000000000A011 /* PumpRateTables.swift in Sources */, diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index f79ebd40c9..d2ee65c427 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -533,7 +533,7 @@ final class OpenAPS { halfBasalTarget: effectiveHBT, target: targetValue, autosensMax: preferences.autosensMax - )) + ).rounded()) debug( .algorithm, "Temp target \(targetValue) mg/dL: half basal target \(defaultHalfBasalTarget) -> \(effectiveHBT) mg/dL, \(percentage)%" diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index 57ea4ce84e..adb34902b3 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -85,3 +85,30 @@ extension JSONEncoder.DateEncodingStrategy { try container.encode(Formatter.iso8601withFractionalSeconds.string(from: $0)) } } + +extension FormatStyle where Self == FloatingPointFormatStyle.Percent { + /// No decimals: 0.8 → "80%". + static var wholePercent: Self { roundedPercent(0 ... 0) } + + /// Exactly one decimal: 0.07 → "7.0%". + static var oneDecimalPercent: Self { roundedPercent(1 ... 1) } + + /// Up to one decimal: 0.725 → "72.5%", 0.72 → "72%". + static var upToOneDecimalPercent: Self { roundedPercent(0 ... 1) } + + /// Rounds halves away from zero, like glucose. + private static func roundedPercent(_ fractionLength: ClosedRange) -> Self { + .percent.precision(.fractionLength(fractionLength)).rounded(rule: .toNearestOrAwayFromZero) + } +} + +/// The same styles for Decimal values. +extension FormatStyle where Self == Decimal.FormatStyle.Percent { + static var wholePercent: Self { roundedPercent(0 ... 0) } + static var oneDecimalPercent: Self { roundedPercent(1 ... 1) } + static var upToOneDecimalPercent: Self { roundedPercent(0 ... 1) } + + private static func roundedPercent(_ fractionLength: ClosedRange) -> Self { + .percent.precision(.fractionLength(fractionLength)).rounded(rule: .toNearestOrAwayFromZero) + } +} diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4292db903b..61f5f402b1 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -5503,6 +5503,7 @@ } }, ", %lld%%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -8034,6 +8035,7 @@ }, "%" : { "comment" : "Percentage symbol", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -8164,6 +8166,7 @@ } }, "%.0f%%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -9371,6 +9374,7 @@ } }, "%@ %%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -10566,6 +10570,9 @@ } } } + }, + "%@ Insulin" : { + }, "%@ is on. %@" : { "localizations" : { @@ -11764,6 +11771,7 @@ } }, "%@%% Insulin" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -13341,6 +13349,7 @@ } }, "%lld %%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -14251,6 +14260,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -164571,14 +164581,12 @@ "comment" : "The vertical axis label in the GRI risk grid.", "isCommentAutoGenerated" : true }, - "Hypoglycemia %@ percent, hyperglycemia %@ percent" : { - "comment" : "A textual representation of the GRI risk grid, including the values for hypoglycemia and hyperglycemia.", - "isCommentAutoGenerated" : true, + "Hypoglycemia %@, hyperglycemia %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Hypoglycemia %1$@ percent, hyperglycemia %2$@ percent" + "value" : "Hypoglycemia %1$@, hyperglycemia %2$@" } } } @@ -172366,6 +172374,12 @@ } } }, + "Insulin doses will be decreased by %@." : { + "comment" : "Adjustment footer; the value is a percentage, e.g. 20%" + }, + "Insulin doses will be increased by %@." : { + "comment" : "Adjustment footer; the value is a percentage, e.g. 20%" + }, "Insulin Limit Reached" : { "localizations" : { "bg" : { @@ -233864,6 +233878,7 @@ }, "percent" : { "comment" : "Accessibility: spoken unit", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -241113,8 +241128,12 @@ } } } + }, + "Profile at %@" : { + }, "Profile at 100 %" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { diff --git a/Trio/Sources/Models/BloodGlucose.swift b/Trio/Sources/Models/BloodGlucose.swift index 0b1e0e6863..7cdf3b997d 100644 --- a/Trio/Sources/Models/BloodGlucose.swift +++ b/Trio/Sources/Models/BloodGlucose.swift @@ -286,8 +286,7 @@ enum UnitSpelling { "U": String(localized: "units", comment: "Accessibility: spoken unit"), "g": String(localized: "grams", comment: "Accessibility: spoken unit"), "hr": String(localized: "hour", comment: "Accessibility: spoken unit"), - "min": String(localized: "minutes", comment: "Accessibility: spoken unit"), - "%": String(localized: "percent", comment: "Accessibility: spoken unit") + "min": String(localized: "minutes", comment: "Accessibility: spoken unit") ] /// Returns a spoken form of a unit string, e.g. "mg/dL/U" -> "milligrams per deciliter per units". diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift index dce49fb9f4..42c4c17931 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift @@ -5,18 +5,16 @@ extension Adjustments.StateModel { func percentageDescription(_ percent: Double) -> Text? { if percent.isNaN || percent == 100 { return nil } - var description: String = "Insulin doses will be " - - if percent < 100 { - description += "decreased by " - } else { - description += "increased by " - } - - let deviationFrom100 = abs(percent - 100) - description += String(format: "%.0f% %.", deviationFrom100) - - return Text(description) + let change = (abs(percent - 100) / 100).formatted(.wholePercent) + return percent < 100 + ? Text( + "Insulin doses will be decreased by \(change).", + comment: "Adjustment footer; the value is a percentage, e.g. 20%" + ) + : Text( + "Insulin doses will be increased by \(change).", + comment: "Adjustment footer; the value is a percentage, e.g. 20%" + ) } /// Checks if the device is using a 24-hour time format. diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift index a12a2c3721..c6300141ac 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift @@ -131,7 +131,7 @@ extension Adjustments.StateModel { info( .treatments, - "Temp target scheduled: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" + "Temp target scheduled: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage.rounded()))%" ) let tempTarget = TempTarget( name: tempTargetName, @@ -198,7 +198,7 @@ extension Adjustments.StateModel { await disableAllActiveTempTargets(createTempTargetRunEntry: true) info( .treatments, - "Temp target started: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" + "Temp target started: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage.rounded()))%" ) let tempTarget = TempTarget( name: tempTargetName, @@ -224,7 +224,7 @@ extension Adjustments.StateModel { func saveTempTargetPreset() async throws { debug( .treatments, - "Temp target preset saved: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage))%" + "Temp target preset saved: target \(tempTargetTarget) mg/dL for \(tempTargetDuration) min, half basal target \(halfBasalTarget) mg/dL, insulin \(Int(percentage.rounded()))%" ) let tempTarget = TempTarget( name: tempTargetName, diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift index 420a85abc3..37fd4b0a3f 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift @@ -79,7 +79,7 @@ struct AddOverrideForm: View { HStack { Text("Basal Rate Adjustment") Spacer() - Text("\(state.overridePercentage.formatted(.number)) %") + Text((state.overridePercentage / 100).formatted(.wholePercent)) .foregroundColor(!displayPickerPercentage ? .primary : .accentColor) .onTapGesture { displayPickerPercentage = toggleScrollWheel(displayPickerPercentage) @@ -92,7 +92,7 @@ struct AddOverrideForm: View { VStack(alignment: .leading) { // Radio buttons for step iteration ForEach([1, 5], id: \.self) { step in - RadioButton(isSelected: percentageStep == step, label: "\(step) %") { + RadioButton(isSelected: percentageStep == step, label: step.formatted(.percent)) { percentageStep = step state.overridePercentage = Adjustments.StateModel.roundOverridePercentageToStep( state.overridePercentage, @@ -114,7 +114,7 @@ struct AddOverrideForm: View { ), label: Text("") ) { ForEach(Array(stride(from: 40, through: 150, by: percentageStep)), id: \.self) { percent in - Text("\(percent) %").tag(percent) + Text(percent.formatted(.percent)).tag(percent) } } .pickerStyle(WheelPickerStyle()) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 7646aad72f..b9a0105399 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -200,12 +200,7 @@ extension Adjustments.RootView { } }() - // Same trap risk as duration above. - let percentageString: String = { - guard percentage != 100 else { return "" } - guard percentage.isFinite, percentage.magnitude <= Double(Int.max) else { return "" } - return "\(Int(percentage))%\(isfAndCrString)" - }() + let percentageString = percentage == 100 ? "" : (percentage / 100).formatted(.wholePercent) + isfAndCrString // Combine all labels into a single array, filtering out empty strings let labels: [String] = [ diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index 98267c9ab2..6f6672ad71 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -145,7 +145,7 @@ struct EditOverrideForm: View { HStack { Text("Basal Rate Adjustment") Spacer() - Text("\(percentage.formatted(.number)) %") + Text((percentage / 100).formatted(.wholePercent)) .foregroundColor(!displayPickerPercentage ? .primary : .accentColor) .onTapGesture { displayPickerPercentage = toggleScrollWheel(displayPickerPercentage) @@ -158,7 +158,7 @@ struct EditOverrideForm: View { VStack(alignment: .leading) { // Radio buttons for step iteration ForEach([1, 5], id: \.self) { step in - RadioButton(isSelected: percentageStep == step, label: "\(step) %") { + RadioButton(isSelected: percentageStep == step, label: step.formatted(.percent)) { percentageStep = step percentage = Adjustments.StateModel.roundOverridePercentageToStep(percentage, step) } @@ -178,7 +178,7 @@ struct EditOverrideForm: View { Array(stride(from: 40.0, through: 150.0, by: Double(percentageStep))), id: \.self ) { percent in - Text("\(Int(percent)) %").tag(percent) + Text((percent / 100).formatted(.wholePercent)).tag(percent) } } .pickerStyle(WheelPickerStyle()) diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index 35e21f22f3..ef716b7881 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -144,7 +144,7 @@ struct AddTempTargetForm: View { } } - Text("\(formattedPercentage(state.percentage))% Insulin") + Text("\((state.percentage / 100).formatted(.wholePercent)) Insulin") .foregroundColor(isUsingSlider ? .orange : Color.tabBar) .font(.title3) .fontWeight(.bold) @@ -156,9 +156,9 @@ struct AddTempTargetForm: View { in: state.computeSliderLow() ... state.computeSliderHigh(), step: 5 ) {} minimumValueLabel: { - Text("\(state.computeSliderLow(), specifier: "%.0f")%") + Text((state.computeSliderLow() / 100).formatted(.wholePercent)) } maximumValueLabel: { - Text("\(state.computeSliderHigh(), specifier: "%.0f")%") + Text((state.computeSliderHigh() / 100).formatted(.wholePercent)) } onEditingChanged: { editing in isUsingSlider = editing state.halfBasalTarget = Decimal(TempTargetCalculations.computeHalfBasalTarget( @@ -326,11 +326,6 @@ struct AddTempTargetForm: View { return max(0, durationTotal) } - private func formattedPercentage(_ value: Double) -> String { - let percentageNumber = NSNumber(value: value) - return Formatter.integerFormatter.string(from: percentageNumber) ?? "\(value)" - } - 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 cc0d228fcc..3c8d5c5798 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -155,13 +155,11 @@ extension Adjustments.RootView { tempTarget.halfBasalTarget as? Double .RawValue ?? Double(state.settingHalfBasalTarget) ) - let percentage = Int( - TempTargetCalculations.computeAdjustedPercentage( - halfBasalTarget: tempTargetHalfBasal, - target: tempTargetValue, - autosensMax: state.autosensMax - ) - ) + let percentageText = (TempTargetCalculations.computeAdjustedPercentage( + halfBasalTarget: tempTargetHalfBasal, + target: tempTargetValue, + autosensMax: state.autosensMax + ) / 100).formatted(.wholePercent) let remainingTime = tempTarget.date?.timeIntervalSinceNow ?? 0 let row = ZStack(alignment: .trailing) { @@ -189,7 +187,7 @@ extension Adjustments.RootView { .foregroundColor(.secondary) .font(.caption) if state.isAdjustSensEnabled(usingTarget: tempTargetValue) { - Text(", \(percentage)%") + Text(", " + percentageText) .foregroundColor(.secondary) .font(.caption) } @@ -220,7 +218,7 @@ extension Adjustments.RootView { + String(localized: "for", comment: "duration connector") + " " + (Formatter.integerFormatter.string(from: (tempTarget.duration ?? 0) as NSNumber) ?? "0") + " " + String(localized: "min", comment: "minutes abbreviation") - + (state.isAdjustSensEnabled(usingTarget: tempTargetValue) ? ", \(percentage)%" : "") + + (state.isAdjustSensEnabled(usingTarget: tempTargetValue) ? ", " + percentageText : "") )) // Only tappable rows (presets) are buttons; scheduled rows are read-only, so they get // neither the button trait, an activation, nor a hint. diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift index cb1f69e71e..9d90937e83 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -206,7 +206,7 @@ struct EditTempTargetForm: View { } } - Text("\(formattedPercentage(percentage))% Insulin") + Text("\((percentage / 100).formatted(.wholePercent)) Insulin") .foregroundColor(isUsingSlider ? .orange : Color.tabBar) .font(.title3) .fontWeight(.bold) @@ -232,10 +232,10 @@ struct EditTempTargetForm: View { step: 5 ) {} minimumValueLabel: { - Text("\(state.computeSliderLow(usingTarget: target), specifier: "%.0f")%") + Text((state.computeSliderLow(usingTarget: target) / 100).formatted(.wholePercent)) } maximumValueLabel: { - Text("\(state.computeSliderHigh(usingTarget: target), specifier: "%.0f")%") + Text((state.computeSliderHigh(usingTarget: target) / 100).formatted(.wholePercent)) } .listRowSeparator(.hidden, edges: .top) } @@ -327,7 +327,7 @@ struct EditTempTargetForm: View { saveChanges() info( .treatments, - "Temp target edited: target \(target) mg/dL, half basal target \(halfBasalTarget.map { "\($0) mg/dL" } ?? "default"), insulin \(Int(percentage))%" + "Temp target edited: target \(target) mg/dL, half basal target \(halfBasalTarget.map { "\($0) mg/dL" } ?? "default"), insulin \(Int(percentage.rounded()))%" ) do { guard let moc = tempTarget.managedObjectContext else { return } @@ -412,16 +412,4 @@ struct EditTempTargetForm: View { let durationTotal = (durationHours * 60) + durationMinutes return max(0, durationTotal) } - - private var formatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - return formatter - } - - private func formattedPercentage(_ value: Double) -> String { - let percentageNumber = NSNumber(value: value) - return formatter.string(from: percentageNumber) ?? "\(value)" - } } diff --git a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift index 839c5f8c9d..ee4f8684c8 100644 --- a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift +++ b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift @@ -16,13 +16,6 @@ extension AutosensSettings { @EnvironmentObject var appIcons: Icons @Environment(AppState.self) var appState - private var rateFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 2 - return formatter - } - var autosensVerboseHint: some View { VStack(alignment: .leading, spacing: 15) { Text( @@ -75,13 +68,11 @@ extension AutosensSettings { let newISF = state.autosensISF let decimalValue = !state.settingsManager.preferences.useNewFormula ? state .autosensRatio as NSDecimalNumber : dynamicRatio ?? 1 - let decimalValueText = rateFormatter - .string(from: ((decimalValue as Decimal) * Decimal(100)) as NSNumber) ?? "100" HStack { Text("Sensitivity Ratio") Spacer() - Text("\(decimalValueText) \(String(localized: "%", comment: "Percentage symbol"))") + Text((decimalValue as Decimal).formatted(.upToOneDecimalPercent)) }.padding(.vertical) HStack { Text("Calculated Sensitivity") diff --git a/Trio/Sources/Modules/Home/View/Header/PumpView.swift b/Trio/Sources/Modules/Home/View/Header/PumpView.swift index b2c08abf5a..0a9dd4d7dd 100644 --- a/Trio/Sources/Modules/Home/View/Header/PumpView.swift +++ b/Trio/Sources/Modules/Home/View/Header/PumpView.swift @@ -94,7 +94,7 @@ struct PumpView: View { Image(systemName: "battery.100") .font(.callout) .foregroundStyle(batteryColor) - Text("\(Formatter.integerFormatter.string(for: battery.first?.percent ?? 100) ?? "100") %") + Text(((battery.first?.percent ?? 100) / 100).formatted(.wholePercent)) .font(.callout).fontWeight(.bold).fontDesign(.rounded) } .accessibilityElement(children: .ignore) @@ -210,8 +210,7 @@ struct PumpView: View { /// Battery percentage plus a spoken severity word mirroring `batteryColor`. private var batteryAccessibilityValue: String { - let percent = Formatter.integerFormatter.string(for: battery.first?.percent ?? 100) ?? "100" - let value = "\(percent) %" + let value = ((battery.first?.percent ?? 100) / 100).formatted(.wholePercent) switch battery.first?.percent { case .some(...10): return value + ", " + String(localized: "low", comment: "Accessibility: battery severity") diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index ad45c3c145..301502d7ba 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -67,7 +67,7 @@ extension Home.RootView { } let percent = latestOverride.percentage - let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %" + let percentString = percent == 100 ? "" : (percent / 100).formatted(.wholePercent) let unit = state.units let target = (latestOverride.target ?? 0) as Decimal @@ -159,8 +159,12 @@ extension Home.RootView { if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true } if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true } if showPercentage { - percentageString = - " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%" + let percentage = TempTargetCalculations.computeAdjustedPercentage( + halfBasalTarget: effectiveHBT, + target: target, + autosensMax: state.autosensMax + ) + percentageString = " " + (percentage / 100).formatted(.wholePercent) } let targetString = target == 0 ? "" : target.formatted(withUnits: state.units) + percentageString @@ -290,7 +294,7 @@ extension Home.RootView { .font(.subheadline).fontWeight(.medium) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - Text("Profile at 100 %") + Text("Profile at \(1.0.formatted(.wholePercent))") .font(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) @@ -535,7 +539,7 @@ extension Home.RootView { let gmiMmolMol = (gmiPercentage - 2.152) * 10.929 return "\(Int(gmiMmolMol.rounded())) mmol/mol" } - return gmiPercentage.formatted(.number.precision(.fractionLength(1))) + " %" + return (gmiPercentage / 100).formatted(.oneDecimalPercent) } @ViewBuilder func statsBanner() -> some View { @@ -546,7 +550,7 @@ extension Home.RootView { let averageString = stats.meanGlucose?.formatted(withUnits: state.units) ?? "--" let gmiValueString = gmiString(stats.meanGlucose) let tirString = hasData - ? stats.inRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + ? (stats.inRangePct / 100).formatted(.upToOneDecimalPercent) : "-- %" let segments: [(color: Color, fraction: CGFloat)] = hasData ? [ (.red, CGFloat(stats.veryLowPct / 100)), diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift index f1f8b24206..e76a45dfb9 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift @@ -308,7 +308,7 @@ struct AlgorithmSettingsSubstepView String { - value.formatted(.number.precision(.fractionLength(1))) + "%" + (value / 100).formatted(.oneDecimalPercent) } } @@ -221,7 +221,7 @@ private struct GRIScoreBreakdownView: View { } private func percentage(_ value: Double) -> String { - (value / 100).formatted(.percent.precision(.fractionLength(1))) + (value / 100).formatted(.oneDecimalPercent) } } @@ -334,7 +334,7 @@ private struct GRIRiskGridView: View { .accessibilityElement(children: .ignore) .accessibilityLabel("GRI risk grid, zone \(result.zone)") .accessibilityValue( - "Hypoglycemia \(result.hypoComponent.formatted()) percent, hyperglycemia \(result.hyperComponent.formatted()) percent" + "Hypoglycemia \((result.hypoComponent / 100).formatted(.oneDecimalPercent)), hyperglycemia \((result.hyperComponent / 100).formatted(.oneDecimalPercent))" ) } } diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift index f0b54cca1d..f7edeb9f7f 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDailyDistributionChart.swift @@ -167,7 +167,7 @@ struct GlucoseDailyDistributionChart: View { AxisMarks(position: .trailing, values: [4, 25, 50, 75, 100]) { value in if let percentage = value.as(Double.self) { AxisValueLabel { - Text((percentage / 100).formatted(.percent.precision(.fractionLength(0)))) + Text((percentage / 100).formatted(.wholePercent)) .font(.footnote) } AxisGridLine() diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift index 350a3b3748..8d69d0fba8 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseDistributionChart.swift @@ -68,7 +68,7 @@ struct GlucoseDistributionChart: View { AxisMarks(position: .trailing) { value in if let percentage = value.as(Double.self) { AxisValueLabel { - Text((percentage / 100).formatted(.percent.precision(.fractionLength(0)))) + Text((percentage / 100).formatted(.wholePercent)) .font(.footnote) } AxisGridLine() diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift index a4f8abd068..22871c3e05 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift @@ -24,11 +24,11 @@ struct GlucoseMetricsView: View { // Format glucose statistics based on the selected unit let eA1cString = preferredUnit == .mgdL - ? (glucoseStats.ngsp.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%") : glucoseStats + ? (glucoseStats.ngsp / 100).formatted(.oneDecimalPercent) : glucoseStats .ifcc.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) let gmiString = preferredUnit == .mgdL ? - (glucoseStats.gmiPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%") : + (glucoseStats.gmiPercentage / 100).formatted(.oneDecimalPercent) : glucoseStats.gmiMmolMol.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) // glucoseStats already parsed to units - only format decimals @@ -37,8 +37,7 @@ struct GlucoseMetricsView: View { ) : glucoseStats.sd.formatted( .number.grouping(.never).rounded().precision(.fractionLength(1)) ) - let coefficientOfVariationString = glucoseStats.cv - .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%" + let coefficientOfVariationString = (glucoseStats.cv / 100).formatted(.oneDecimalPercent) let daysTrackedString = totalDays.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) VStack(alignment: .leading) { diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift index 791d302518..233f1627bc 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift @@ -55,14 +55,14 @@ struct GlucoseSectorChart: View { let glucoseAverage = Decimal(sumReadings) / total let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray) - let lowPercentage = Decimal(low) / total * 100 - let tightPercentage = Decimal(tight) / total * 100 - let inRangePercentage = Decimal(normal) / total * 100 - let highPercentage = Decimal(high) / total * 100 - let moderatelyLowPercentage = Decimal(moderatelyLow) / total * 100 - let moderatelyHighPercentage = Decimal(moderatelyHigh) / total * 100 - let veryLowPercentage = Decimal(veryLow) / total * 100 - let veryHighPercentage = Decimal(veryHigh) / total * 100 + let lowFraction = Decimal(low) / total + let tightFraction = Decimal(tight) / total + let inRangeFraction = Decimal(normal) / total + let highFraction = Decimal(high) / total + let moderatelyLowFraction = Decimal(moderatelyLow) / total + let moderatelyHighFraction = Decimal(moderatelyHigh) / total + let veryLowFraction = Decimal(veryLow) / total + let veryHighFraction = Decimal(veryHigh) / total VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 5) { @@ -71,7 +71,7 @@ struct GlucoseSectorChart: View { ) .font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(inRangePercentage, tight: true)) + Text(inRangeFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicTeal) } @@ -81,7 +81,7 @@ struct GlucoseSectorChart: View { ) .font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(tightPercentage, tight: true)) + Text(tightFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicGreen) } }.padding(.leading, 5) @@ -90,7 +90,7 @@ struct GlucoseSectorChart: View { VStack(alignment: .leading, spacing: 5) { Text("> \(highLimit.formatted(for: units))").font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(highPercentage, tight: true)) + Text(highFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(showChart ? Color.dynamicPurple : Color.dynamicBlue) } @@ -100,7 +100,7 @@ struct GlucoseSectorChart: View { ) .font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(lowPercentage, tight: true)) + Text(lowFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(showChart ? Color.dynamicRed : Color.dynamicOrange) } } @@ -110,7 +110,7 @@ struct GlucoseSectorChart: View { VStack(alignment: .leading, spacing: 5) { Text("> \(Decimal(220).formatted(for: units))").font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(moderatelyHighPercentage, tight: true)) + Text(moderatelyHighFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicBlue) } @@ -120,7 +120,7 @@ struct GlucoseSectorChart: View { ) .font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(moderatelyLowPercentage, tight: true)) + Text(moderatelyLowFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicOrange) } } @@ -128,7 +128,7 @@ struct GlucoseSectorChart: View { VStack(alignment: .leading, spacing: 5) { Text("> \(Decimal(250).formatted(for: units))").font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(veryHighPercentage, tight: true)) + Text(veryHighFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicPurple) } @@ -138,7 +138,7 @@ struct GlucoseSectorChart: View { ) .font(.subheadline) .foregroundStyle(Color.secondary) - Text(formatPercentage(veryLowPercentage, tight: true)) + Text(veryLowFraction.formatted(.upToOneDecimalPercent)) .foregroundStyle(Color.dynamicRed) } } @@ -285,11 +285,11 @@ struct GlucoseSectorChart: View { items: [ ( String(localized: "Very High (>\(Decimal(250).formatted(for: units)))"), - formatPercentage(Decimal(veryHigh) / total * 100) + (Decimal(veryHigh) / total).formatted(.upToOneDecimalPercent) ), ( String(localized: "High (\(highLimit.formatted(for: units))-\(Decimal(250).formatted(for: units)))"), - formatPercentage(Decimal(high) / total * 100) + (Decimal(high) / total).formatted(.upToOneDecimalPercent) ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), @@ -312,13 +312,13 @@ struct GlucoseSectorChart: View { String( localized: "TIR (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units)))" ), - formatPercentage(Decimal(glucoseValues.count) / total * 100) + (Decimal(glucoseValues.count) / total).formatted(.upToOneDecimalPercent) ), ( String( localized: "TITR (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units)))" ), - formatPercentage(Decimal(tight) / total * 100) + (Decimal(tight) / total).formatted(.upToOneDecimalPercent) ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), @@ -342,11 +342,11 @@ struct GlucoseSectorChart: View { String( localized: "Low (\(Decimal(54).formatted(for: units))-\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units)))" ), - formatPercentage(Decimal(low) / total * 100) + (Decimal(low) / total).formatted(.upToOneDecimalPercent) ), ( String(localized: "Very Low (<\(Decimal(54).formatted(for: units)))"), - formatPercentage(Decimal(veryLow) / total * 100) + (Decimal(veryLow) / total).formatted(.upToOneDecimalPercent) ), (String(localized: "Average"), average.formatted(for: units)), (String(localized: "Median"), median.formatted(for: units)), @@ -356,20 +356,6 @@ struct GlucoseSectorChart: View { } } - /// Formats a percentage value to a string with one decimal place. - /// - Parameter value: A decimal value representing the percentage. - /// - Returns: A formatted percentage string - private func formatPercentage(_ value: Decimal, tight: Bool = false) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.minimumFractionDigits = value == 100 ? 0 : 1 - formatter.maximumFractionDigits = value == 100 ? 0 : 1 - if tight { - formatter.positiveSuffix = "%" - } - return formatter.string(from: NSDecimalNumber(decimal: value / 100)) ?? "0%" - } - /// Calculates statistical values for a given array of glucose readings. /// - Parameter values: An array of glucose readings as integers. /// - Returns: A tuple containing the average, median, and standard deviation. diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift index a6337172c1..4d0d4cd986 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift @@ -38,7 +38,7 @@ struct LoopBarChartView: View { if let percentage = value.as(Double.self) { if selectedInterval != .today { AxisValueLabel(anchor: .top) { - Text("\(Int(percentage))%") + Text((percentage / 100).formatted(.wholePercent)) .font(.footnote) } } diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift index 2a5c986a5c..73cf0f232b 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift @@ -29,8 +29,7 @@ struct LoopStatsView: View { Spacer() StatChartUtils.statView( title: String(localized: "Success"), - value: (successfulStats.percentage / 100) - .formatted(.percent.grouping(.never).rounded().precision(.fractionLength(1))) + value: (successfulStats.percentage / 100).formatted(.upToOneDecimalPercent) ) Spacer() StatChartUtils.statView( diff --git a/Trio/Sources/Modules/Treatments/View/PopupView.swift b/Trio/Sources/Modules/Treatments/View/PopupView.swift index 8439966244..d61c9502c7 100644 --- a/Trio/Sources/Modules/Treatments/View/PopupView.swift +++ b/Trio/Sources/Modules/Treatments/View/PopupView.swift @@ -587,7 +587,7 @@ struct PopupView: View { .valueStyle() Text("×") .operatorStyle() - Text((100 * state.sweetMealFactor).formatted() + " %") + Text(state.sweetMealFactor.formatted(.wholePercent)) .valueStyle() Text("=").operatorStyle() .frame(idealWidth: 10, maxWidth: .infinity, alignment: .trailing) @@ -633,7 +633,7 @@ struct PopupView: View { .valueStyle() Text("×") .operatorStyle() - Text((100 * state.fraction).formatted() + " %") + Text(state.fraction.formatted(.wholePercent)) .valueStyle() Text("=") .operatorStyle() @@ -672,11 +672,11 @@ struct PopupView: View { Text(insulinFormatter(state.wholeCalc)).valueStyle() Text("×") .operatorStyle() - Text((100 * state.fraction).formatted() + " %") + Text(state.fraction.formatted(.wholePercent)) .valueStyle() Text("×") .operatorStyle() - Text((100 * state.fattyMealFactor).formatted() + " %") + Text(state.fattyMealFactor.formatted(.wholePercent)) .valueStyle() Text("=") .operatorStyle() @@ -717,7 +717,7 @@ struct PopupView: View { Text(insulinFormatter(state.wholeCalc)).valueStyle() Text("×") .operatorStyle() - Text((100 * state.fraction).formatted() + " %") + Text(state.fraction.formatted(.wholePercent)) .valueStyle() Text(")") .operatorStyle() diff --git a/Trio/Sources/Views/SettingInputSection.swift b/Trio/Sources/Views/SettingInputSection.swift index bb915be21a..3b1cb16592 100644 --- a/Trio/Sources/Views/SettingInputSection.swift +++ b/Trio/Sources/Views/SettingInputSection.swift @@ -232,7 +232,7 @@ struct SettingInputSection: View { case .glucose: return decimalValue.formatted(withUnits: units) case .factor: - return "\(decimalValue * 100) \(String(localized: "%", comment: "Percentage symbol"))" + return decimalValue.formatted(.wholePercent) case .insulinUnit: return "\(decimalValue) \(String(localized: "U", comment: "Insulin unit abbreviation"))" case .insulinUnitPerHour: @@ -252,7 +252,7 @@ struct SettingInputSection: View { case .glucose: return decimalValue.formatted(for: units) + " " + units.spokenValue case .factor: - return "\(decimalValue * 100) \(UnitSpelling.spoken("%"))" + return decimalValue.formatted(.wholePercent) case .insulinUnit: return "\(decimalValue) \(UnitSpelling.spoken("U"))" case .insulinUnitPerHour: diff --git a/TrioTests/PercentFormattingTests.swift b/TrioTests/PercentFormattingTests.swift new file mode 100644 index 0000000000..c4ba4dd805 --- /dev/null +++ b/TrioTests/PercentFormattingTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Percent Formatting") struct PercentFormattingTests { + private let english = Locale(identifier: "en_US") + + @Test("Whole percent rounds halves away from zero", arguments: [ + ("0.8", "80%"), ("0.725", "73%") + ]) func whole(input: String, expected: String) { + check(input, expected, double: .wholePercent, decimal: .wholePercent) + } + + @Test("One decimal keeps a trailing zero and rounds halves away from zero", arguments: [ + ("0.07", "7.0%"), ("0.0125", "1.3%") + ]) func oneDecimal(input: String, expected: String) { + check(input, expected, double: .oneDecimalPercent, decimal: .oneDecimalPercent) + } + + @Test("Up to one decimal drops a trailing zero, so exact shares stay short", arguments: [ + ("0", "0%"), ("1", "100%"), ("0.873", "87.3%") + ]) func upToOneDecimal(input: String, expected: String) { + check(input, expected, double: .upToOneDecimalPercent, decimal: .upToOneDecimalPercent) + } + + /// The Double and Decimal styles are separate copies, so both are checked. + private func check( + _ input: String, + _ expected: String, + double: FloatingPointFormatStyle.Percent, + decimal: Decimal.FormatStyle.Percent + ) { + #expect(Double(input)!.formatted(double.locale(english)) == expected) + #expect(Decimal(string: input)!.formatted(decimal.locale(english)) == expected) + } +} From 83e890d9fc668d18e9479e02b3dddd45cd756a6c Mon Sep 17 00:00:00 2001 From: David Cintron Date: Tue, 3 Nov 2026 09:21:24 -0600 Subject: [PATCH 03/20] Fix Tidepool dose uploads failing with "configuration" TidepoolService's uploadDoseData annotates doses through its remoteDataServiceDelegate and throws TidepoolServiceError.configuration when none is set. Trio never set one, so every dose upload failed on the pinned TidepoolService. BaseTidepoolManager now conforms to RemoteDataServiceDelegate and attaches itself whenever the service is created or restored. - getBasalHistory returns [] on purpose: with a non-empty history, TidepoolService hits a preconditionFailure for temp basals with unit .units, which is how Trio builds them. Empty leaves doses as Trio built them, including their scheduledBasalRate. - automationHistory applies the current dosing mode to the whole range (Trio sets `automatic` on every dose, so the overlay skips them). - fetchDeviceLogs returns []. Upload-only; dosing is unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SuGCnzpk8tfJPiA2Y6FRrX --- .../Services/Network/TidepoolManager.swift | 31 ++++++++++ TrioTests/TidepoolTherapySettingsTests.swift | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 5b00c9fc3f..9f4d100d3e 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -59,6 +59,9 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { private var tidepoolService: RemoteDataService? { didSet { if let tidepoolService = tidepoolService { + // `TidepoolService.uploadDoseData` throws `.configuration` without this delegate. It is set + // here, where every created or restored service passes through, so no path can miss it. + tidepoolService.remoteDataServiceDelegate = self rawTidepoolManager = tidepoolService.rawValue } else { rawTidepoolManager = nil @@ -225,6 +228,34 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { } } +/// TidepoolService asks its `RemoteDataServiceDelegate` for context while annotating doses before an upload, +/// and for device logs. Without a delegate every dose upload fails with `TidepoolServiceError.configuration`. +extension BaseTidepoolManager: RemoteDataServiceDelegate { + /// Trio has no device log to offer; an empty result makes the uploader skip the interval. + func fetchDeviceLogs(startDate _: Date, endDate _: Date) async throws -> [StoredDeviceLogEntry] { + [] + } + + /// Deliberately empty. With a non-empty history, TidepoolService's `annotated(with:)` hits + /// `preconditionFailure("temp basal without rate unsupported")` for any temp basal whose unit is `.units`, + /// and Trio builds its temp basal doses that way (see `processTempBasalEvent`). Empty makes + /// TidepoolService leave every dose as Trio built it, including the `scheduledBasalRate` Trio already sets, + /// which is how the TidepoolService pinned on `main` (no annotation step) uploads them. + func getBasalHistory(startDate _: Date, endDate _: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + /// Whether the doses in the range were automated. + /// + /// Approximation: Trio doesn't record when the dosing mode changed, so the current mode is applied to + /// the whole range. It only matters for doses without an explicit `automatic` flag, and Trio sets that + /// flag on every dose it uploads (`overlayAutomationHistory` skips those). + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + let isAutomated = settingsManager.settings.dosingMode == .closed + return [AbsoluteScheduleValue(startDate: start, endDate: end, value: isAutomated)] + } +} + extension BaseTidepoolManager: ServiceDelegate { var hostIdentifier: String { "org.nightscout.Trio" diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index 4c8696733b..349499a03e 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -558,3 +558,65 @@ private extension StoredSettings { ) } } + +// MARK: - Dose annotation (RemoteDataServiceDelegate contract) + +/// `TidepoolService.uploadDoseData` annotates doses through `BaseTidepoolManager`'s `RemoteDataServiceDelegate`. +/// These pin down what the delegate's "no basal history" answer does to doses built the way Trio builds them. +@Suite("Tidepool dose annotation Tests") struct TidepoolDoseAnnotationTests { + private static let start = Date(timeIntervalSince1970: 1_800_000_000) + + /// Same shape as the temp basal `BaseTidepoolManager.processTempBasalEvent` creates. + private static func trioTempBasal(automatic: Bool?) -> DoseEntry { + DoseEntry( + type: .tempBasal, + startDate: start, + endDate: start.addingTimeInterval(30 * 60), + value: 0.5, + unit: .units, + decisionId: nil, + deliveredUnits: 0.5, + syncIdentifier: "temp-1", + scheduledBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.8), + insulinType: nil, + automatic: automatic, + manuallyEntered: false, + isMutable: false + ) + } + + @Test("Empty basal history leaves a Trio temp basal untouched") func emptyBasalHistoryKeepsDose() { + let dose = Self.trioTempBasal(automatic: true) + + let annotated = [dose].annotated(with: []) + + #expect(annotated.count == 1) + #expect(annotated[0].syncIdentifier == "temp-1") + #expect(annotated[0].startDate == dose.startDate) + #expect(annotated[0].endDate == dose.endDate) + #expect(annotated[0].deliveredUnits == 0.5) + #expect(annotated[0].scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) == 0.8) + } + + @Test("Automation history keeps an explicit automatic flag") func automationKeepsExplicitFlag() { + let dose = Self.trioTempBasal(automatic: true) + let history = [AbsoluteScheduleValue(startDate: dose.startDate, endDate: dose.endDate, value: false)] + + let overlaid = [dose].overlayAutomationHistory(history) + + #expect(overlaid.count == 1) + #expect(overlaid[0].automatic == true) + #expect(overlaid[0].syncIdentifier == "temp-1") + } + + @Test("A single period over the whole range does not split a dose") func singlePeriodDoesNotSplit() { + let dose = Self.trioTempBasal(automatic: nil) + let history = [AbsoluteScheduleValue(startDate: dose.startDate, endDate: dose.endDate, value: true)] + + let overlaid = [dose].overlayAutomationHistory(history) + + #expect(overlaid.count == 1) + #expect(overlaid[0].automatic == true) + #expect(overlaid[0].deliveredUnits == 0.5) + } +} From 3bb3c6019fc178a3870ee6423d28a04602329f9b Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Thu, 5 Nov 2026 18:27:34 +0100 Subject: [PATCH 04/20] Use one formatter for durations and time ago --- Trio.xcodeproj/project.pbxproj | 12 ++-- Trio/Sources/Helpers/Formatters.swift | 57 ++++++++++++++--- Trio/Sources/Helpers/TimeAgoFormatter.swift | 58 ----------------- .../Localizations/Main/Localizable.xcstrings | 8 +++ .../AdjustmentsStateModel+Helpers.swift | 16 ----- .../View/AdjustmentsRootView.swift | 15 ----- .../View/Overrides/AddOverrideForm.swift | 2 +- .../AdjustmentsRootView+Overrides.swift | 25 +++----- .../View/Overrides/EditOverrideForm.swift | 2 +- .../View/TempTargets/AddTempTargetForm.swift | 2 +- .../AdjustmentsRootView+TempTargets.swift | 11 ++-- .../View/TempTargets/EditTempTargetForm.swift | 2 +- .../View/HistoryRootView+Treatments.swift | 4 +- .../Home/View/Header/CurrentGlucoseView.swift | 29 ++++----- .../Modules/Home/View/Header/LoopView.swift | 11 +--- .../Modules/Home/View/Header/PumpView.swift | 49 ++------------- .../CGMSensorDisplayState.swift | 22 ------- .../View/HomeRootView+BottomControls.swift | 63 +++++++++---------- .../Modules/Home/View/HomeRootView.swift | 16 ----- .../ContactImage/ContactPicture.swift | 2 +- .../Shortcuts/State/ListStateView.swift | 20 +----- TrioTests/DurationFormattingTests.swift | 50 +++++++++++++++ 22 files changed, 183 insertions(+), 293 deletions(-) delete mode 100644 Trio/Sources/Helpers/TimeAgoFormatter.swift delete mode 100644 Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift create mode 100644 TrioTests/DurationFormattingTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index b38690be32..b10e08b89a 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -628,7 +628,6 @@ BD11A011000000000000A011 /* RoundBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A010000000000000A011 /* RoundBasalTests.swift */; }; BD11A013000000000000A011 /* BasalRoundingDriverParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A012000000000000A011 /* BasalRoundingDriverParityTests.swift */; }; BD11A015000000000000A011 /* PumpRateTables.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A014000000000000A011 /* PumpRateTables.swift */; }; - BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C001 /* CGMSensorDisplayState.swift */; }; BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C003 /* SensorLifecycleArcView.swift */; }; BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C004 /* SensorStatusTagView.swift */; }; BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */; }; @@ -686,6 +685,7 @@ 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 */; }; + A1EF5D9B309CEF01007282CC /* DurationFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1EF5D9A309CEF01007282CC /* DurationFormattingTests.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 */; }; @@ -1033,7 +1033,6 @@ DDD1631A2C4C695E00CD525A /* EditOverrideForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD163192C4C695E00CD525A /* EditOverrideForm.swift */; }; DDD1631C2C4C697400CD525A /* AddOverrideForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD1631B2C4C697400CD525A /* AddOverrideForm.swift */; }; DDD1631F2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DDD1631D2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld */; }; - DDD5889D2DDDC9A900C8848D /* TimeAgoFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5889C2DDDC9A900C8848D /* TimeAgoFormatter.swift */; }; DDD6D4D32CDE90720029439A /* EstimatedA1cDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */; }; DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78A902DC4064800AC63F3 /* carbhistory.json */; }; DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD72DC421B500AC63F3 /* enacted.json */; }; @@ -1857,7 +1856,6 @@ BD11A010000000000000A011 /* RoundBasalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundBasalTests.swift; sourceTree = ""; }; BD11A012000000000000A011 /* BasalRoundingDriverParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalRoundingDriverParityTests.swift; sourceTree = ""; }; BD11A014000000000000A011 /* PumpRateTables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpRateTables.swift; sourceTree = ""; }; - BD11C000000000000000C001 /* CGMSensorDisplayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorDisplayState.swift; sourceTree = ""; }; BD11C000000000000000C003 /* SensorLifecycleArcView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorLifecycleArcView.swift; sourceTree = ""; }; BD11C000000000000000C004 /* SensorStatusTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorStatusTagView.swift; sourceTree = ""; }; BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFetchedResultsControllerDelegate.swift; sourceTree = ""; }; @@ -1913,6 +1911,7 @@ 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 = ""; }; + A1EF5D9A309CEF01007282CC /* DurationFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationFormattingTests.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 = ""; }; @@ -2259,7 +2258,6 @@ DDD163192C4C695E00CD525A /* EditOverrideForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideForm.swift; sourceTree = ""; }; DDD1631B2C4C697400CD525A /* AddOverrideForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOverrideForm.swift; sourceTree = ""; }; DDD1631E2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TrioCoreDataPersistentContainer.xcdatamodel; sourceTree = ""; }; - DDD5889C2DDDC9A900C8848D /* TimeAgoFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeAgoFormatter.swift; sourceTree = ""; }; DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedA1cDisplayUnit.swift; sourceTree = ""; }; DDD78A902DC4064800AC63F3 /* carbhistory.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = carbhistory.json; sourceTree = ""; }; DDD78AD72DC421B500AC63F3 /* enacted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = enacted.json; sourceTree = ""; }; @@ -3394,7 +3392,6 @@ 38E98A3625F5509500C0CED0 /* String+Extensions.swift */, 49239B422EEA27AD00469145 /* TempTargetCalculations.swift */, DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */, - DDD5889C2DDDC9A900C8848D /* TimeAgoFormatter.swift */, D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */, BD2FF19F2AE29D43005D1C5D /* ToggleStyles.swift */, CEB434E428B8FF5D00B70274 /* UIColor.swift */, @@ -3534,6 +3531,7 @@ isa = PBXGroup; children = ( A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */, + A1EF5D9A309CEF01007282CC /* DurationFormattingTests.swift */, DDDD0FFD2F4E231600F9C645 /* Mocks */, DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */, CA0924000000000000000005 /* GRICalculatorTests.swift */, @@ -4184,7 +4182,6 @@ BD11C002000000000000C000 /* SensorLifecycle */ = { isa = PBXGroup; children = ( - BD11C000000000000000C001 /* CGMSensorDisplayState.swift */, BD11C000000000000000C003 /* SensorLifecycleArcView.swift */, BD11C000000000000000C004 /* SensorStatusTagView.swift */, ); @@ -5852,7 +5849,6 @@ CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */, BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */, 3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */, - DDD5889D2DDDC9A900C8848D /* TimeAgoFormatter.swift in Sources */, D7C5E1F4A7B91E2F3C5D6E70 /* TimeInterval+Convenience.swift in Sources */, CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */, 3B5CD2B72D4AEA6600CE213C /* ComputedBGTargets.swift in Sources */, @@ -6329,7 +6325,6 @@ BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */, E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */, 38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */, - BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */, BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */, BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */, 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */, @@ -6517,6 +6512,7 @@ 3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */, 3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */, A1A0EDA830974FFF0028A032 /* GlucoseFormattingTests.swift in Sources */, + A1EF5D9B309CEF01007282CC /* DurationFormattingTests.swift in Sources */, 3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */, BD11A011000000000000A011 /* RoundBasalTests.swift in Sources */, BD11A015000000000000A011 /* PumpRateTables.swift in Sources */, diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index 57ea4ce84e..683a7968a9 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -58,14 +58,57 @@ extension Formatter { formatter.maximumFractionDigits = 2 return formatter }() +} - static let timaAgoFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - formatter.negativePrefix = "" - return formatter - }() +extension Duration { + /// Minutes as overrides and temp targets store them. Clamped because those values come from + /// Core Data and Nightscout unchecked, and NaN or 1e30 would trap; Int32.max minutes is ~4,000 years. + init(minutes: Decimal) { + let minutes = Double(minutes) + self = minutes.isNaN ? .zero : .seconds(min(max(minutes, 0), Double(Int32.max)) * 60) + } + + /// Compact span, rounded down: "45m", "1h 30m", "5d 14h", "< 1m". + func formattedTimeSpan(locale: Locale = .autoupdatingCurrent) -> String { + timeSpan(width: .narrow, locale: locale) + } + + /// VoiceOver form of `formattedTimeSpan`: "1 hour, 30 minutes". + func formattedSpokenTimeSpan(locale: Locale = .autoupdatingCurrent) -> String { + timeSpan(width: .wide, locale: locale) + } + + private func timeSpan(width: UnitsFormatStyle.UnitWidth, locale: Locale) -> String { + // Zero is an exact length, like an unset duration picker, not "less than a minute". + if self < .seconds(60), self != .zero { + return "<\u{00A0}" + Duration.seconds(60).timeSpan(width: width, locale: locale) + } + let units: Set = self < .seconds(86400) ? [.hours, .minutes] : [.days, .hours] + return formatted(.units(allowed: units, width: width, fractionalPart: .hide(rounded: .down)).locale(locale)) + } +} + +extension Date { + /// Time since this date, rounded down: "7m", "1h 30m", "< 1m". + func formattedTimeAgo(locale: Locale = .autoupdatingCurrent) -> String { + Duration.seconds(-timeIntervalSinceNow).formattedTimeSpan(locale: locale) + } + + /// VoiceOver: "7 minutes ago", "1 hour ago", "yesterday", "less than a minute ago". + func formattedSpokenTimeAgo(locale: Locale = .autoupdatingCurrent) -> String { + let elapsed = -timeIntervalSinceNow + guard elapsed >= 60 else { + return String(localized: "less than a minute ago", comment: "Accessibility: elapsed time") + } + // Under a day the style rounds to the nearest minute or hour; floor so VoiceOver matches the visible text. + // From a day on it counts calendar days ("yesterday"); shifting the date could cross midnight. + let remainder: TimeInterval = switch elapsed { + case ..<3600: elapsed.truncatingRemainder(dividingBy: 60) + case ..<86400: elapsed.truncatingRemainder(dividingBy: 3600) + default: 0 + } + return addingTimeInterval(remainder).formatted(.relative(presentation: .named, unitsStyle: .wide).locale(locale)) + } } extension JSONDecoder.DateDecodingStrategy { diff --git a/Trio/Sources/Helpers/TimeAgoFormatter.swift b/Trio/Sources/Helpers/TimeAgoFormatter.swift deleted file mode 100644 index 4cc0987f27..0000000000 --- a/Trio/Sources/Helpers/TimeAgoFormatter.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// TimeAgoFormatter.swift -// Trio -// -// Created by Cengiz Deniz on 21.05.25. -// -import Foundation - -enum TimeAgoFormatter { - /// Returns a user-facing string for how many minutes ago the given date occurred, - /// formatted with non-breaking spaces and localized abbreviation. - /// - /// - Parameter date: The past `Date` to calculate elapsed time from. - /// - Returns: A formatted string like `"< 1 m"` or `"2 m"`. Returns `"--"` if the date is `nil`. - static func minutesAgo(from date: Date?) -> String { - guard let date = date else { - return "--" - } - - let secondsAgo = -date.timeIntervalSinceNow - let minutesAgo = Int(floor(secondsAgo / 60)) - - if minutesAgo >= 1 { - let minuteString = Formatter.timaAgoFormatter.string(for: Double(minutesAgo)) ?? "\(minutesAgo)" - return minuteString + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - } else { - return "<" + "\u{00A0}" + "1" + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - } - } - - /// Spoken form of elapsed minutes for VoiceOver — spells out "minute(s) ago" so screen - /// readers don't voice the compact "m" as "meters". - static func minutesAgoAccessible(from date: Date?) -> String { - guard let date = date else { - return String(localized: "time unknown", comment: "Accessibility: elapsed time unavailable") - } - let minutesAgo = Int(floor(-date.timeIntervalSinceNow / 60)) - if minutesAgo < 1 { - return String(localized: "less than a minute ago", comment: "Accessibility: elapsed time") - } - return String( - format: String(localized: "%d minutes ago", comment: "Accessibility: elapsed time"), - minutesAgo - ) - } - - // Calculates the floored integer value of how many full minutes ago the given date occurred. - /// - /// - Parameter date: The past `Date` to compare against the current time. - /// - Returns: An integer representing the number of full minutes since the given date. - /// Returns `Int.max` if the date is `nil`. - static func minutesAgoValue(from date: Date?) -> Int { - guard let date = date else { - return Int.max - } - return Int(floor(-date.timeIntervalSinceNow / 60)) - } -} diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4292db903b..1a99f0a5b3 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -12032,6 +12032,7 @@ } }, "%1$d days %2$d hours" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12163,6 +12164,7 @@ } }, "%1$d hours %2$d minutes" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12425,6 +12427,7 @@ } }, "%d hours" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12556,6 +12559,7 @@ } }, "%d minutes" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12687,6 +12691,7 @@ } }, "%d minutes ago" : { + "extractionState" : "stale", "comment" : "Accessibility: elapsed time", "localizations" : { "bg" : { @@ -94468,6 +94473,7 @@ } }, "d" : { + "extractionState" : "stale", "comment" : "Abbreviation for Days\nabbreviation for days", "localizations" : { "bg" : { @@ -157537,6 +157543,7 @@ "isCommentAutoGenerated" : true }, "h" : { + "extractionState" : "stale", "comment" : "Abbreviation for Hours\nabbreviation for hours\nh", "localizations" : { "bg" : { @@ -312218,6 +312225,7 @@ } }, "time unknown" : { + "extractionState" : "stale", "comment" : "Accessibility: elapsed time unavailable", "localizations" : { "bg" : { diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift index dce49fb9f4..bc3b78348d 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift @@ -47,22 +47,6 @@ extension Adjustments.StateModel { String(format: "%02d", hour) } - /// Converts a duration in minutes to a formatted string (e.g., "1 h 30 m"). - func formatHoursAndMinutes(_ durationInMinutes: Int) -> String { - let hours = durationInMinutes / 60 - let minutes = durationInMinutes % 60 - - switch (hours, minutes) { - case let (0, m): - return "\(m)\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - case let (h, 0): - return "\(h)\u{00A0}" + String(localized: "h", comment: "h") - default: - return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + minutes - .description + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - } - } - /// Converts hours and minutes to total minutes as a `Decimal`. func convertToMinutes(_ hours: Int, _ minutes: Int) -> Decimal { let totalMinutes = (hours * 60) + minutes diff --git a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index fae3000129..f559a07370 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -307,21 +307,6 @@ extension Adjustments { .tint(.white) } } - - func formattedTimeRemaining(_ timeInterval: TimeInterval) -> String { - let totalSeconds = Int(timeInterval) - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - - if hours > 0 { - return "\(hours)h \(minutes)m \(seconds)s" - } else if minutes > 0 { - return "\(minutes)m \(seconds)s" - } else { - return "<1m" - } - } } } diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift index 420a85abc3..4a8581ffbd 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift @@ -346,7 +346,7 @@ struct AddOverrideForm: View { HStack { Text("Duration") Spacer() - Text(state.formatHoursAndMinutes(Int(state.overrideDuration))) + Text(Duration(minutes: state.overrideDuration).formattedTimeSpan()) .foregroundColor(!displayPickerDuration ? .primary : .accentColor) .onTapGesture { displayPickerDuration = toggleScrollWheel(displayPickerDuration) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 7646aad72f..5076aafb52 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -155,14 +155,7 @@ extension Adjustments.RootView { let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)" - // Int(_: Decimal) traps on NaN/out-of-range values, so a corrupted preset would crash - // this screen on every open. Guard via Double instead. - let durationString: String = { - guard !indefinite else { return "" } - let durationMinutes = NSDecimalNumber(decimal: duration).doubleValue - guard durationMinutes.isFinite, durationMinutes.magnitude <= Double(Int.max) else { return "" } - return state.formatHoursAndMinutes(Int(durationMinutes)) - }() + let durationSpan: Duration? = indefinite ? nil : Duration(minutes: duration) let scheduledSMBString: String = { guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" } @@ -200,7 +193,7 @@ extension Adjustments.RootView { } }() - // Same trap risk as duration above. + // Int(_: Double) traps on NaN and huge values, so a corrupted preset would crash this screen on every open. let percentageString: String = { guard percentage != 100 else { return "" } guard percentage.isFinite, percentage.magnitude <= Double(Int.max) else { return "" } @@ -208,14 +201,10 @@ extension Adjustments.RootView { }() // Combine all labels into a single array, filtering out empty strings - let labels: [String] = [ - durationString, - percentageString, - targetString, - smbString, - maxSmbMinsString, - maxUamMinsString - ].filter { !$0.isEmpty } + let otherLabels = [percentageString, targetString, smbString, maxSmbMinsString, maxUamMinsString] + let labels = ([durationSpan?.formattedTimeSpan() ?? ""] + otherLabels).filter { !$0.isEmpty } + // VoiceOver reads the duration spelled out + let spokenLabels = ([durationSpan?.formattedSpokenTimeSpan() ?? ""] + otherLabels).filter { !$0.isEmpty } if !name.isEmpty { ZStack(alignment: .trailing) { @@ -257,7 +246,7 @@ extension Adjustments.RootView { } .accessibilityElement(children: .ignore) .accessibilityLabel(Text(name)) - .accessibilityValue(Text(labels.joined(separator: ", "))) + .accessibilityValue(Text(spokenLabels.joined(separator: ", "))) .accessibilityHint(Text(String(localized: "Double tap to enable this override", comment: "Accessibility hint"))) .accessibilityAddTraits(showOverrideCheckmark && isSelected ? [.isButton, .isSelected] : .isButton) .accessibilityAction { onTap?() } diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index 98267c9ab2..ead8d27639 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -443,7 +443,7 @@ struct EditOverrideForm: View { HStack { Text("Duration") Spacer() - Text(state.formatHoursAndMinutes(Int(truncating: duration as NSNumber))) + Text(Duration(minutes: duration).formattedTimeSpan()) .foregroundColor(!displayPickerDuration ? .primary : .accentColor) .onTapGesture { displayPickerDuration = toggleScrollWheel(displayPickerDuration) diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift index 35e21f22f3..1a02f1fbb7 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -183,7 +183,7 @@ struct AddTempTargetForm: View { HStack { Text("Duration") Spacer() - Text(state.formatHoursAndMinutes(Int(state.tempTargetDuration))) + Text(Duration(minutes: state.tempTargetDuration).formattedTimeSpan()) .foregroundColor( !displayPickerDuration ? (state.tempTargetDuration > 0 ? .primary : .secondary) : .accentColor diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift index cc0d228fcc..ea5dfc01a9 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -163,6 +163,7 @@ extension Adjustments.RootView { ) ) let remainingTime = tempTarget.date?.timeIntervalSinceNow ?? 0 + let duration = Duration(minutes: (tempTarget.duration ?? 0) as Decimal) let row = ZStack(alignment: .trailing) { HStack { @@ -171,7 +172,7 @@ extension Adjustments.RootView { Text(tempTarget.name ?? "") Spacer() if remainingTime > 0 { - Text("Starts in \(formattedTimeRemaining(remainingTime))") + Text("Starts in \(Duration.seconds(remainingTime).formattedTimeSpan())") .foregroundColor(colorScheme == .dark ? .orange : .accentColor) } } @@ -182,10 +183,7 @@ extension Adjustments.RootView { Text("for") .foregroundColor(.secondary) .font(.caption) - Text("\(Formatter.integerFormatter.string(from: (tempTarget.duration ?? 0) as NSNumber)!)") - .foregroundColor(.secondary) - .font(.caption) - Text("min") + Text(duration.formattedTimeSpan()) .foregroundColor(.secondary) .font(.caption) if state.isAdjustSensEnabled(usingTarget: tempTargetValue) { @@ -218,8 +216,7 @@ extension Adjustments.RootView { .accessibilityValue(Text( (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") + + duration.formattedSpokenTimeSpan() + (state.isAdjustSensEnabled(usingTarget: tempTargetValue) ? ", \(percentage)%" : "") )) // Only tappable rows (presets) are buttons; scheduled rows are read-only, so they get diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift index cb1f69e71e..36b9aaa5d5 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -254,7 +254,7 @@ struct EditTempTargetForm: View { HStack { Text("Duration") Spacer() - Text(state.formatHoursAndMinutes(Int(duration))) + Text(Duration(minutes: duration).formattedTimeSpan()) .foregroundColor(!displayPickerDuration ? (duration > 0 ? .primary : .secondary) : .accentColor) .onTapGesture { displayPickerDuration = toggleScrollWheel(displayPickerDuration) diff --git a/Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift b/Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift index 232f2277df..45b4d288af 100644 --- a/Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift +++ b/Trio/Sources/Modules/History/View/HistoryRootView+Treatments.swift @@ -92,7 +92,9 @@ extension History.RootView { ) .foregroundColor(.secondary) if tempBasal.duration > 0 { - Text("\(tempBasal.duration.string) min").foregroundColor(.secondary) + let duration = Duration.seconds(Int(tempBasal.duration) * 60) + Text(duration.formattedTimeSpan()).foregroundColor(.secondary) + .accessibilityLabel(duration.formattedSpokenTimeSpan()) } } else { Image(systemName: "circle.fill").foregroundColor(Color.loopGray) diff --git a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index f9f8b5850e..1398787124 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -194,7 +194,7 @@ struct CurrentGlucoseView: View { /// One combined VoiceOver description for the bobble, e.g. /// "Glucose 79 mg/dL, in range, falling, delta -2, 1 minute ago". private var bobbleAccessibilityLabel: String { - guard let glucoseValue = glucose.last?.glucose, isReadingFresh else { + guard let glucoseValue = glucose.last?.glucose, let date = glucose.last?.date, isReadingFresh else { return String(localized: "Glucose unavailable", comment: "Accessibility: no fresh glucose") } let valueString = glucoseValue == 400 @@ -206,11 +206,11 @@ struct CurrentGlucoseView: View { if delta != "--" { parts.append(String(localized: "delta", comment: "Accessibility: glucose delta") + " \(delta)") } - parts.append(TimeAgoFormatter.minutesAgoAccessible(from: glucose.last?.date)) + parts.append(date.formattedSpokenTimeAgo()) // Fold in the sensor-lifecycle tag (warmup countdown, "expired", "stabilizing") so // it is spoken; the visual tag is a child that the ignore above would otherwise drop. if let tag = tagLabel, !trendIsDownward, !tag.text.isEmpty { - parts.append(tag.text) + parts.append(tag.spoken) } return parts.joined(separator: ", ") } @@ -228,11 +228,10 @@ struct CurrentGlucoseView: View { .foregroundStyle(.secondary) } } - if isReadingFresh { + if let date = glucose.last?.date, isReadingFresh { HStack { - let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date) Group { - Text(minutesAgoString) + Text(date.formattedTimeAgo()) Text(delta) } .font(.callout).fontWeight(.bold) @@ -309,18 +308,16 @@ struct CurrentGlucoseView: View { /// Warmup → hourglass + countdown; stabilizing → hourglass + "Stabilizing" /// (no countdown — duration is sensor-driven); outside warmup/stabilizing /// the tag is gated to the same 48h window as the arc. - private var tagLabel: (text: String, theme: SensorStatusTagTheme, icon: String?)? { + private var tagLabel: (text: String, spoken: String, theme: SensorStatusTagTheme, icon: String?)? { if isInWarmup { - let text: String if let endsAt = cgmWarmupEndsAt { - text = SensorRemainingTimeFormatter.format(until: endsAt) - } else { - text = "Warming up" + let remaining = Duration.seconds(endsAt.timeIntervalSinceNow) + return (remaining.formattedTimeSpan(), remaining.formattedSpokenTimeSpan(), .orange, "hourglass") } - return (text, .orange, "hourglass") + return ("Warming up", "Warming up", .orange, "hourglass") } if isStabilizing { - return ("Stabilizing", .orange, "hourglass") + return ("Stabilizing", "Stabilizing", .orange, "hourglass") } if let status = cgmStatus { // Only the normal tier is gated to the arc's 48h window; a warning @@ -330,18 +327,18 @@ struct CurrentGlucoseView: View { // labels ("Signal\nLoss", "Sensor\nWarmup"); we render in a // single-line tag, so collapse newlines to spaces here. let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ") - return (oneLine, theme(for: status.status), nil) + return (oneLine, oneLine, theme(for: status.status), nil) } guard shouldShowArc else { return nil } if let expiresAt = cgmSensorExpiresAt { - let text = SensorRemainingTimeFormatter.format(until: expiresAt) + let remaining = Duration.seconds(expiresAt.timeIntervalSinceNow) let theme: SensorStatusTagTheme switch cgmProgress?.progressState { case .critical: theme = .red case .warning: theme = .orange default: theme = .green } - return (text, theme, nil) + return (remaining.formattedTimeSpan(), remaining.formattedSpokenTimeSpan(), theme, nil) } return nil } diff --git a/Trio/Sources/Modules/Home/View/Header/LoopView.swift b/Trio/Sources/Modules/Home/View/Header/LoopView.swift index 6f29ce11c4..3997f8ef53 100644 --- a/Trio/Sources/Modules/Home/View/Header/LoopView.swift +++ b/Trio/Sources/Modules/Home/View/Header/LoopView.swift @@ -136,13 +136,13 @@ struct LoopView: View { age = lastDeviceDate.map { String( format: String(localized: "last device communication %@", comment: "Accessibility: device age"), - TimeAgoFormatter.minutesAgoAccessible(from: $0) + $0.formattedSpokenTimeAgo() ) } ?? "" } else if determination.first?.deliverAt != nil, timeString != "--" { age = String( format: String(localized: "last loop %@", comment: "Accessibility: loop age"), - TimeAgoFormatter.minutesAgoAccessible(from: lastLoopDate) + lastLoopDate.formattedSpokenTimeAgo() ) } else { age = "" @@ -199,12 +199,7 @@ struct LoopView: View { } private var timeString: String { - let minutesAgo = TimeAgoFormatter.minutesAgoValue(from: lastLoopDate) - if minutesAgo > 1440 { - return "--" - } else { - return TimeAgoFormatter.minutesAgo(from: lastLoopDate) - } + lastLoopDate.timeIntervalSinceNow < -24 * 60 * 60 ? "--" : lastLoopDate.formattedTimeAgo() } private var color: Color { diff --git a/Trio/Sources/Modules/Home/View/Header/PumpView.swift b/Trio/Sources/Modules/Home/View/Header/PumpView.swift index b2c08abf5a..9614852225 100644 --- a/Trio/Sources/Modules/Home/View/Header/PumpView.swift +++ b/Trio/Sources/Modules/Home/View/Header/PumpView.swift @@ -125,9 +125,8 @@ struct PumpView: View { .lineLimit(2) .multilineTextAlignment(.leading) .frame( - // If the string is > 6 chars, i.e., exceeds "xd yh", limit width to 80 pts - // This forces the "Replace pod" string to wrap to 2 lines. - maxWidth: remainingTimeString.count > 6 ? 80 : .infinity, + // Narrow frame so "Replace pod" wraps to 2 lines. + maxWidth: date.timeIntervalSince(timerDate) > 0 ? .infinity : 80, alignment: .leading ) } @@ -142,53 +141,15 @@ struct PumpView: View { guard time > 0 else { return String(localized: "Replace pod", comment: "View/Header when pod expired") } - - var time = time - let days = Int(time / 1.days.timeInterval) - time -= days.days.timeInterval - let hours = Int(time / 1.hours.timeInterval) - time -= hours.hours.timeInterval - let minutes = Int(time / 1.minutes.timeInterval) - - if days >= 1 { - return "\(days)" + String(localized: "d", comment: "abbreviation for days") + " \(hours)" + - String(localized: "h", comment: "abbreviation for hours") - } - - if hours >= 1 { - var remainingHoursString = "\(hours)" + String(localized: "h", comment: "abbreviation for hours") - if hours < 12 { - remainingHoursString += " " + "\(minutes)" + - String(localized: "m", comment: "abbreviation for minutes") - } - return remainingHoursString - } - - return "\(minutes)" + String(localized: "m", comment: "abbreviation for minutes") + return Duration.seconds(time).formattedTimeSpan() } - /// Spoken remaining pod time — spells out days/hours/minutes so VoiceOver doesn't - /// read the compact "m" as "meters". + /// Spoken remaining pod time, so VoiceOver doesn't read the compact "m" as "meters". private func remainingTimeStringAccessible(time: TimeInterval) -> String { guard time > 0 else { return String(localized: "Replace pod", comment: "View/Header when pod expired") } - var time = time - let days = Int(time / 1.days.timeInterval) - time -= days.days.timeInterval - let hours = Int(time / 1.hours.timeInterval) - time -= hours.hours.timeInterval - let minutes = Int(time / 1.minutes.timeInterval) - - if days >= 1 { - return String(format: String(localized: "%1$d days %2$d hours", comment: "Accessibility: pod time"), days, hours) - } - if hours >= 1 { - return hours < 12 - ? String(format: String(localized: "%1$d hours %2$d minutes", comment: "Accessibility: pod time"), hours, minutes) - : String(format: String(localized: "%d hours", comment: "Accessibility: pod time"), hours) - } - return String(format: String(localized: "%d minutes", comment: "Accessibility: pod time"), minutes) + return Duration.seconds(time).formattedSpokenTimeSpan() } /// Reservoir amount plus a spoken severity word, so the color-coded low state is not lost. diff --git a/Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift b/Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift deleted file mode 100644 index 3e5ed4eb15..0000000000 --- a/Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -/// Compact `5d 14h` / `22h` / `45m` remaining-time formatter. Used as a -/// fallback tag label when the CGM doesn't surface a `cgmStatusHighlight` -/// but the lifecycle progress lets us derive an expiration date. -enum SensorRemainingTimeFormatter { - static func format(until expiresAt: Date, now: Date = Date()) -> String { - let remaining = max(0, expiresAt.timeIntervalSince(now)) - let totalMinutes = Int(remaining) / 60 - let days = totalMinutes / (60 * 24) - let hours = (totalMinutes / 60) % 24 - let minutes = totalMinutes % 60 - let d = String(localized: "d", comment: "Abbreviation for Days") - let h = String(localized: "h", comment: "Abbreviation for Hours") - let m = String(localized: "m", comment: "Abbreviation for Minutes") - if days > 0, hours > 0 { return "\(days)\(d) \(hours)\(h)" } - if days > 0 { return "\(days)\(d)" } - if hours > 0 { return "\(hours)\(h)" } - if minutes > 0 { return "\(minutes)\(m)" } - return "<" + "\u{00A0}" + "1" + "\u{00A0}" + m - } -} diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index ad45c3c145..5c6d2b12e1 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -1,5 +1,4 @@ import CoreData -import SwiftDate import SwiftUI // MARK: - Zone E: bottom controls (adjustment panel / bolus progress) @@ -57,7 +56,7 @@ extension Home.RootView { return nil } - var overrideString: String? { + var overrideString: (text: String, spoken: String)? { guard let latestOverride = latestOverride.first else { return nil } @@ -77,22 +76,16 @@ extension Home.RootView { targetString = "" } - let duration = latestOverride.duration ?? 0 - let addedMinutes = Int(truncating: duration) let date = latestOverride.date ?? Date() - let newDuration = max( - Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes), - 0 - ) + let remaining = Duration(minutes: (latestOverride.duration ?? 0) as Decimal) + .seconds(date.timeIntervalSinceNow) let indefinite = latestOverride.indefinite var durationString = "" + var spokenDurationString = "" if !indefinite { - if newDuration >= 1 { - durationString = formatHrMin(Int(newDuration)) - } else if newDuration > 0 { - durationString = "\(Int(newDuration * 60)) s" - + if remaining > .zero { + durationString = remaining.formattedTimeSpan() + spokenDurationString = remaining.formattedSpokenTimeSpan() } else { /// Do not show the Override anymore Task { @@ -129,23 +122,22 @@ extension Home.RootView { } } - let components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString] - .filter { !$0.isEmpty } - return components.isEmpty ? nil : components.joined(separator: ", ") + let otherComponents = [percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString] + let components = ([durationString] + otherComponents).filter { !$0.isEmpty } + let spokenComponents = ([spokenDurationString] + otherComponents).filter { !$0.isEmpty } + + guard !components.isEmpty else { return nil } + return (components.joined(separator: ", "), spokenComponents.joined(separator: ", ")) } - var tempTargetString: String? { + var tempTargetString: (text: String, spoken: String)? { guard let latestTempTarget = latestTempTarget.first else { return nil } - let duration = latestTempTarget.duration - let addedMinutes = Int(truncating: duration ?? 0) let date = latestTempTarget.date ?? Date() - let newDuration = max( - Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes), - 0 - ) + let remaining = Duration(minutes: (latestTempTarget.duration ?? 0) as Decimal) + .seconds(date.timeIntervalSinceNow) var durationString = "" + var spokenDurationString = "" var percentageString = "" let target = (latestTempTarget.target ?? 100) as Decimal // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT) @@ -164,12 +156,9 @@ extension Home.RootView { } let targetString = target == 0 ? "" : target.formatted(withUnits: state.units) + percentageString - if newDuration >= 1 { - durationString = - "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min" - } else if newDuration > 0 { - durationString = - "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s" + if remaining > .zero { + durationString = remaining.formattedTimeSpan() + spokenDurationString = remaining.formattedSpokenTimeSpan() } else { /// Do not show the Temp Target anymore Task { @@ -179,33 +168,37 @@ extension Home.RootView { } let components = [targetString, durationString].filter { !$0.isEmpty } - return components.isEmpty ? nil : components.joined(separator: ", ") + let spokenComponents = [targetString, spokenDurationString].filter { !$0.isEmpty } + + guard !components.isEmpty else { return nil } + return (components.joined(separator: ", "), spokenComponents.joined(separator: ", ")) } - @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View { + @ViewBuilder func adjustmentsOverrideView(_ overrideString: (text: String, spoken: String)) -> some View { Group { adjustmentIcon("clock.arrow.2.circlepath", tint: Color.purple) VStack(alignment: .leading, spacing: 1) { Text(latestOverride.first?.name ?? String(localized: "Custom Override")) .font(.subheadline).fontWeight(.semibold) .frame(alignment: .leading) - - Text(overrideString) + Text(overrideString.text) .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel(overrideString.spoken) } } } - @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View { + @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: (text: String, spoken: String)) -> some View { Group { adjustmentIcon("target", tint: Color.loopGreen) VStack(alignment: .leading, spacing: 1) { Text(latestTempTarget.first?.name ?? String(localized: "Temp Target")) .font(.subheadline).fontWeight(.semibold) - Text(tempTargetString) + Text(tempTargetString.text) .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel(tempTargetString.spoken) } } } diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index bff06f5027..8a725be6c6 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -624,22 +624,6 @@ func is24HourFormat() -> Bool { return !dateString.contains("AM") && !dateString.contains("PM") } -/// Converts a duration in minutes to a formatted string (e.g., "1 h 30 m"). -func formatHrMin(_ durationInMinutes: Int) -> String { - let hours = durationInMinutes / 60 - let minutes = durationInMinutes % 60 - - switch (hours, minutes) { - case let (0, m): - return "\(m)\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - case let (h, 0): - return "\(h)\u{00A0}" + String(localized: "h", comment: "h") - default: - return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + minutes - .description + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") - } -} - // Helper function to convert a start and end hour to either 24-hour or AM/PM format func formatTimeRange(start: String?, end: String?) -> String { guard let start = start, let end = end else { diff --git a/Trio/Sources/Services/ContactImage/ContactPicture.swift b/Trio/Sources/Services/ContactImage/ContactPicture.swift index 889b0e15b4..b24a5b3f32 100644 --- a/Trio/Sources/Services/ContactImage/ContactPicture.swift +++ b/Trio/Sources/Services/ContactImage/ContactPicture.swift @@ -346,7 +346,7 @@ struct ContactPicture: View { let view = GlucoseBobbleContactView( glucoseText: state.glucose ?? "– –", minutesAgoText: hasReading && contact.bobbleShowMinutesAgo - ? TimeAgoFormatter.minutesAgo(from: state.glucoseDate) : nil, + ? state.glucoseDate?.formattedTimeAgo() : nil, deltaText: hasReading && contact.bobbleShowDelta ? state.delta : nil, glucoseColor: hasReading ? (contact.colorMode == .color ? dynamicGlucoseColor(for: state) : .white) diff --git a/Trio/Sources/Shortcuts/State/ListStateView.swift b/Trio/Sources/Shortcuts/State/ListStateView.swift index 14670e14cf..4034ffa412 100644 --- a/Trio/Sources/Shortcuts/State/ListStateView.swift +++ b/Trio/Sources/Shortcuts/State/ListStateView.swift @@ -12,14 +12,6 @@ struct ListStateView: View { return formatter } - private var timaAgoFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - formatter.negativePrefix = "" - return formatter - } - private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.timeStyle = .short @@ -55,15 +47,9 @@ struct ListStateView: View { image } HStack { - let minutes = -1 * state.date.timeIntervalSinceNow / 60 - let text = timaAgoFormatter.string(for: Double(minutes)) ?? "" - Text( - minutes <= 1 ? "< 1 " + String(localized: "min", comment: "Short form for minutes") : ( - text + " " + - String(localized: "min", comment: "Short form for minutes") + " " - ) - ) - .font(.caption2).foregroundColor(.secondary) + Text(state.date.formattedTimeAgo()) + .font(.caption2).foregroundColor(.secondary) + .accessibilityLabel(state.date.formattedSpokenTimeAgo()) Text( state.delta diff --git a/TrioTests/DurationFormattingTests.swift b/TrioTests/DurationFormattingTests.swift new file mode 100644 index 0000000000..ee5a2bce16 --- /dev/null +++ b/TrioTests/DurationFormattingTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Duration Formatting") struct DurationFormattingTests { + private let english = Locale(identifier: "en_US") + private let french = Locale(identifier: "fr_FR") + + @Test("Lengths round down, with hours and minutes under a day and days and hours from a day", arguments: [ + (0, "0m", "0min", "0 minutes"), + (59, "<\u{00A0}1m", "<\u{00A0}1min", "<\u{00A0}1 minute"), + (-30, "<\u{00A0}1m", "<\u{00A0}1min", "<\u{00A0}1 minute"), + (89, "1m", "1min", "1 minute"), + (7200, "2h", "2h", "2 hours"), + (86399, "23h 59m", "23h 59min", "23 hours, 59 minutes"), + (86400, "1d", "1j", "1 day"), + (485_940, "5d 14h", "5j 14h", "5 days, 14 hours") + ]) func timeSpan(seconds: Int, expected: String, expectedFrench: String, expectedSpoken: String) { + let duration = Duration.seconds(seconds) + #expect(duration.formattedTimeSpan(locale: english) == expected) + #expect(duration.formattedTimeSpan(locale: french) == expectedFrench) + #expect(duration.formattedSpokenTimeSpan(locale: english) == expectedSpoken) + } + + // shortcut: reads the real clock, so the 1-day case can fail in the 24 hours after clocks go back; + // pass a reference date (Date.AnchoredRelativeFormatStyle) once iOS 18 is the minimum. + @Test("VoiceOver time ago matches the visible text under a day and names the calendar day after", arguments: [ + (30, "<\u{00A0}1m", "less than a minute ago"), + (-30, "<\u{00A0}1m", "less than a minute ago"), + (479, "7m", "7 minutes ago"), + (10740, "2h 59m", "2 hours ago"), + (86400, "1d", "yesterday") + ]) func timeAgo(secondsAgo: Int, expected: String, expectedSpoken: String) { + let date = Date(timeIntervalSinceNow: TimeInterval(-secondsAgo)) + #expect(date.formattedTimeAgo(locale: english) == expected) + #expect(date.formattedSpokenTimeAgo(locale: english) == expectedSpoken) + } + + @Test("Spoken time ago uses the locale's grammar") func spokenTimeAgoFrench() { + #expect(Date(timeIntervalSinceNow: -479).formattedSpokenTimeAgo(locale: french) == "il y a 7 minutes") + } + + @Test("Stored minutes clamp instead of trapping on corrupt values") func storedMinutes() { + #expect(Duration(minutes: 90) == .seconds(5400)) + #expect(Duration(minutes: .nan) == .zero) + #expect(Duration(minutes: -5) == .zero) + #expect(Duration(minutes: Decimal(string: "1e30")!) == .seconds(Int(Int32.max) * 60)) + } +} From d785ea9b501dd25e37217e788b482283530080b4 Mon Sep 17 00:00:00 2001 From: David Cintron Date: Fri, 6 Nov 2026 10:45:52 -0600 Subject: [PATCH 05/20] Trim Tidepool delegate comments Drop explanations that said Trio lacks device logs and dosing mode history, which it has. Keep one line on why getBasalHistory returns an empty array. Co-Authored-By: Claude Sonnet 5.5 --- .../Services/Network/TidepoolManager.swift | 18 +++--------------- TrioTests/TidepoolTherapySettingsTests.swift | 5 +---- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 9f4d100d3e..1214b1eadf 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -59,8 +59,6 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { private var tidepoolService: RemoteDataService? { didSet { if let tidepoolService = tidepoolService { - // `TidepoolService.uploadDoseData` throws `.configuration` without this delegate. It is set - // here, where every created or restored service passes through, so no path can miss it. tidepoolService.remoteDataServiceDelegate = self rawTidepoolManager = tidepoolService.rawValue } else { @@ -228,28 +226,18 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { } } -/// TidepoolService asks its `RemoteDataServiceDelegate` for context while annotating doses before an upload, -/// and for device logs. Without a delegate every dose upload fails with `TidepoolServiceError.configuration`. extension BaseTidepoolManager: RemoteDataServiceDelegate { - /// Trio has no device log to offer; an empty result makes the uploader skip the interval. + /// Device logs are not uploaded. func fetchDeviceLogs(startDate _: Date, endDate _: Date) async throws -> [StoredDeviceLogEntry] { [] } - /// Deliberately empty. With a non-empty history, TidepoolService's `annotated(with:)` hits - /// `preconditionFailure("temp basal without rate unsupported")` for any temp basal whose unit is `.units`, - /// and Trio builds its temp basal doses that way (see `processTempBasalEvent`). Empty makes - /// TidepoolService leave every dose as Trio built it, including the `scheduledBasalRate` Trio already sets, - /// which is how the TidepoolService pinned on `main` (no annotation step) uploads them. + /// Empty on purpose: a non-empty history makes `DoseEntry.annotated(with:)` hit a `preconditionFailure` + /// for temp basals with unit `.units`, which is how Trio builds them. func getBasalHistory(startDate _: Date, endDate _: Date) async throws -> [AbsoluteScheduleValue] { [] } - /// Whether the doses in the range were automated. - /// - /// Approximation: Trio doesn't record when the dosing mode changed, so the current mode is applied to - /// the whole range. It only matters for doses without an explicit `automatic` flag, and Trio sets that - /// flag on every dose it uploads (`overlayAutomationHistory` skips those). func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { let isAutomated = settingsManager.settings.dosingMode == .closed return [AbsoluteScheduleValue(startDate: start, endDate: end, value: isAutomated)] diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index 349499a03e..6eabe5b2db 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -559,14 +559,11 @@ private extension StoredSettings { } } -// MARK: - Dose annotation (RemoteDataServiceDelegate contract) +// MARK: - Dose annotation -/// `TidepoolService.uploadDoseData` annotates doses through `BaseTidepoolManager`'s `RemoteDataServiceDelegate`. -/// These pin down what the delegate's "no basal history" answer does to doses built the way Trio builds them. @Suite("Tidepool dose annotation Tests") struct TidepoolDoseAnnotationTests { private static let start = Date(timeIntervalSince1970: 1_800_000_000) - /// Same shape as the temp basal `BaseTidepoolManager.processTempBasalEvent` creates. private static func trioTempBasal(automatic: Bool?) -> DoseEntry { DoseEntry( type: .tempBasal, From 7040d5ac34c2deeec7e0e91ba33a2de2b7468a0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Nov 2026 15:30:28 +0000 Subject: [PATCH 06/20] CI: Bump APP_DEV_VERSION to 1.0.2.60 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 58cb6a481b..f2582cd7d7 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.59 +APP_DEV_VERSION = 1.0.2.60 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From e7ded6fe89df9d6816b30f74cfd72225fab7b7d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Nov 2026 03:39:28 +0000 Subject: [PATCH 07/20] CI: Bump APP_DEV_VERSION to 1.0.2.61 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f2582cd7d7..f33edf8604 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.60 +APP_DEV_VERSION = 1.0.2.61 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 66494b6b3ae6b54fa25bccccef21746a205bb1df Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Sun, 8 Nov 2026 14:25:37 +0100 Subject: [PATCH 08/20] Fix sensitivity ratio decimals and rename translation keys --- Trio/Sources/Helpers/Formatters.swift | 4 + .../Localizations/Main/Localizable.xcstrings | 94 +++++++++---------- .../View/AutosensSettingsRootView.swift | 2 +- .../View/HomeRootView+BottomControls.swift | 2 +- TrioTests/PercentFormattingTests.swift | 6 ++ 5 files changed, 55 insertions(+), 53 deletions(-) diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index adb34902b3..38670d9894 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -96,6 +96,9 @@ extension FormatStyle where Self == FloatingPointFormatStyle.Percent { /// Up to one decimal: 0.725 → "72.5%", 0.72 → "72%". static var upToOneDecimalPercent: Self { roundedPercent(0 ... 1) } + /// Up to two decimals: 0.7255 → "72.55%", 0.72 → "72%". + static var upToTwoDecimalPercent: Self { roundedPercent(0 ... 2) } + /// Rounds halves away from zero, like glucose. private static func roundedPercent(_ fractionLength: ClosedRange) -> Self { .percent.precision(.fractionLength(fractionLength)).rounded(rule: .toNearestOrAwayFromZero) @@ -107,6 +110,7 @@ extension FormatStyle where Self == Decimal.FormatStyle.Percent { static var wholePercent: Self { roundedPercent(0 ... 0) } static var oneDecimalPercent: Self { roundedPercent(1 ... 1) } static var upToOneDecimalPercent: Self { roundedPercent(0 ... 1) } + static var upToTwoDecimalPercent: Self { roundedPercent(0 ... 2) } private static func roundedPercent(_ fractionLength: ClosedRange) -> Self { .percent.precision(.fractionLength(fractionLength)).rounded(rule: .toNearestOrAwayFromZero) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 61f5f402b1..51eb1bb733 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -10570,9 +10570,6 @@ } } } - }, - "%@ Insulin" : { - }, "%@ is on. %@" : { "localizations" : { @@ -11770,133 +11767,132 @@ } } }, - "%@%% Insulin" : { - "extractionState" : "stale", + "%@ Insulin" : { "localizations" : { "bg" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Инсулин" + "value" : "%@ Инсулин" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Inzulín" + "value" : "%@ Inzulín" } }, "da" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulina" + "value" : "%@ Insulina" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Insuline %@ %%" + "value" : "Insuline %@" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% אינסולין" + "value" : "%@ אינסולין" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulina" + "value" : "%@ Insulina" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% 인슐린" + "value" : "%@ 인슐린" } }, "nb-NO" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insuline" + "value" : "%@ Insuline" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulina" + "value" : "%@ Insulina" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulina" + "value" : "%@ Insulina" } }, "ro" : { "stringUnit" : { "state" : "new", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Инсулин" + "value" : "%@ Инсулин" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "tr" : { "stringUnit" : { "state" : "new", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% Insulin" + "value" : "%@ Insulin" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% 胰岛素" + "value" : "%@ 胰岛素" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "%@%% 胰島素" + "value" : "%@ 胰島素" } } } @@ -241130,135 +241126,131 @@ } }, "Profile at %@" : { - - }, - "Profile at 100 %" : { - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { "state" : "translated", - "value" : "Профил при 100 %" + "value" : "Профил при %@" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Profil ve 100 %" + "value" : "Profil ve %@" } }, "da" : { "stringUnit" : { "state" : "translated", - "value" : "Profil ved 100 %" + "value" : "Profil ved %@" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Profil bei 100 %" + "value" : "Profil bei %@" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Perfil al 100 %" + "value" : "Perfil al %@" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Profil à 100 %" + "value" : "Profil à %@" } }, "he" : { "stringUnit" : { "state" : "new", - "value" : "Profile at 100 %" + "value" : "Profile at %@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Profilo al 100 %" + "value" : "Profilo al %@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "100% 프로필" + "value" : "%@ 프로필" } }, "nb-NO" : { "stringUnit" : { "state" : "translated", - "value" : "Profil på 100%" + "value" : "Profil på %@" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Profiel op 100% %" + "value" : "Profiel op %@" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Profil na 100 %" + "value" : "Profil na %@" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Perfil a 100 %" + "value" : "Perfil a %@" } }, "ro" : { "stringUnit" : { "state" : "new", - "value" : "Profile at 100 %" + "value" : "Profile at %@" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Профиль на 100 %" + "value" : "Профиль на %@" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Profil på 100 %" + "value" : "Profil på %@" } }, "tr" : { "stringUnit" : { "state" : "new", - "value" : "Profile at 100 %" + "value" : "Profile at %@" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Профіль у 100%" + "value" : "Профіль у %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hồ sơ ở mức 100%" + "value" : "Hồ sơ ở mức %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "配置文件倍率 100%" + "value" : "配置文件倍率 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "設定檔 100 %" + "value" : "設定檔 %@" } } } diff --git a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift index ee4f8684c8..08b011fe3a 100644 --- a/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift +++ b/Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift @@ -72,7 +72,7 @@ extension AutosensSettings { HStack { Text("Sensitivity Ratio") Spacer() - Text((decimalValue as Decimal).formatted(.upToOneDecimalPercent)) + Text((decimalValue as Decimal).formatted(.upToTwoDecimalPercent)) }.padding(.vertical) HStack { Text("Calculated Sensitivity") diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 301502d7ba..7756b2b6e9 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -551,7 +551,7 @@ extension Home.RootView { let gmiValueString = gmiString(stats.meanGlucose) let tirString = hasData ? (stats.inRangePct / 100).formatted(.upToOneDecimalPercent) - : "-- %" + : "--" let segments: [(color: Color, fraction: CGFloat)] = hasData ? [ (.red, CGFloat(stats.veryLowPct / 100)), (.orange, CGFloat(stats.lowPct / 100)), diff --git a/TrioTests/PercentFormattingTests.swift b/TrioTests/PercentFormattingTests.swift index c4ba4dd805..f260171292 100644 --- a/TrioTests/PercentFormattingTests.swift +++ b/TrioTests/PercentFormattingTests.swift @@ -24,6 +24,12 @@ import Testing check(input, expected, double: .upToOneDecimalPercent, decimal: .upToOneDecimalPercent) } + @Test("Up to two decimals drops trailing zeros", arguments: [ + ("0.8333", "83.33%"), ("0.72", "72%") + ]) func upToTwoDecimals(input: String, expected: String) { + check(input, expected, double: .upToTwoDecimalPercent, decimal: .upToTwoDecimalPercent) + } + /// The Double and Decimal styles are separate copies, so both are checked. private func check( _ input: String, From f3a266ef9985cc20dc8a76ff2552077564f60e9b Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Mon, 9 Nov 2026 12:32:07 +0100 Subject: [PATCH 09/20] Read time ago as the visible span and keep minutes in stored durations --- Trio/Sources/Helpers/Formatters.swift | 63 ++++++++++++------- .../Localizations/Main/Localizable.xcstrings | 6 ++ .../AdjustmentsRootView+TempTargets.swift | 2 +- .../Home/View/Header/CurrentGlucoseView.swift | 14 ++++- .../Modules/Home/View/Header/PumpView.swift | 4 +- .../View/HomeRootView+BottomControls.swift | 8 +-- 6 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Trio/Sources/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index 683a7968a9..91a10e70df 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -68,22 +68,42 @@ extension Duration { self = minutes.isNaN ? .zero : .seconds(min(max(minutes, 0), Double(Int32.max)) * 60) } + /// Which units a span shows. All round down. + enum SpanStyle { + /// Days, hours and minutes, for stored values: "1d 1h 30m". + case exact + /// Hours and minutes under a day, days and hours from a day on, for countdowns and ages: "2d 16h". + case largestTwoUnits + /// Minutes only, for settings counted in minutes: "90m". + case minutes + } + /// Compact span, rounded down: "45m", "1h 30m", "5d 14h", "< 1m". - func formattedTimeSpan(locale: Locale = .autoupdatingCurrent) -> String { - timeSpan(width: .narrow, locale: locale) + func formattedTimeSpan(_ style: SpanStyle = .exact, locale: Locale = .autoupdatingCurrent) -> String { + if isUnderAMinute { + return "<\u{00A0}" + Duration.seconds(60).timeSpan(style, width: .narrow, locale: locale) + } + return timeSpan(style, width: .narrow, locale: locale) } - /// VoiceOver form of `formattedTimeSpan`: "1 hour, 30 minutes". - func formattedSpokenTimeSpan(locale: Locale = .autoupdatingCurrent) -> String { - timeSpan(width: .wide, locale: locale) + /// VoiceOver form of `formattedTimeSpan`: "1 hour, 30 minutes", "less than a minute". + /// `locale` formats the units; "less than a minute" follows the app language. + func formattedSpokenTimeSpan(_ style: SpanStyle = .exact, locale: Locale = .autoupdatingCurrent) -> String { + if isUnderAMinute { + return String(localized: "less than a minute", comment: "Accessibility: a duration under one minute") + } + return timeSpan(style, width: .wide, locale: locale) } - private func timeSpan(width: UnitsFormatStyle.UnitWidth, locale: Locale) -> String { - // Zero is an exact length, like an unset duration picker, not "less than a minute". - if self < .seconds(60), self != .zero { - return "<\u{00A0}" + Duration.seconds(60).timeSpan(width: width, locale: locale) + /// Zero is an exact length, like an unset duration picker, so it stays "0m" rather than "< 1m". + var isUnderAMinute: Bool { self < .seconds(60) && self != .zero } + + private func timeSpan(_ style: SpanStyle, width: UnitsFormatStyle.UnitWidth, locale: Locale) -> String { + let units: Set = switch style { + case .exact: [.days, .hours, .minutes] + case .largestTwoUnits: self < .seconds(86400) ? [.hours, .minutes] : [.days, .hours] + case .minutes: [.minutes] } - let units: Set = self < .seconds(86400) ? [.hours, .minutes] : [.days, .hours] return formatted(.units(allowed: units, width: width, fractionalPart: .hide(rounded: .down)).locale(locale)) } } @@ -91,23 +111,22 @@ extension Duration { extension Date { /// Time since this date, rounded down: "7m", "1h 30m", "< 1m". func formattedTimeAgo(locale: Locale = .autoupdatingCurrent) -> String { - Duration.seconds(-timeIntervalSinceNow).formattedTimeSpan(locale: locale) + Duration.seconds(-timeIntervalSinceNow).formattedTimeSpan(.largestTwoUnits, locale: locale) } - /// VoiceOver: "7 minutes ago", "1 hour ago", "yesterday", "less than a minute ago". + /// VoiceOver form of `formattedTimeAgo`: "1 hour, 59 minutes ago", "less than a minute ago". + /// `locale` formats the duration; the words around it follow the app language, like `String(localized:locale:)`. func formattedSpokenTimeAgo(locale: Locale = .autoupdatingCurrent) -> String { - let elapsed = -timeIntervalSinceNow - guard elapsed >= 60 else { + let elapsed = Duration.seconds(-timeIntervalSinceNow) + // One phrase rather than span + "ago", so translators can get its grammar right. + if elapsed.isUnderAMinute { return String(localized: "less than a minute ago", comment: "Accessibility: elapsed time") } - // Under a day the style rounds to the nearest minute or hour; floor so VoiceOver matches the visible text. - // From a day on it counts calendar days ("yesterday"); shifting the date could cross midnight. - let remainder: TimeInterval = switch elapsed { - case ..<3600: elapsed.truncatingRemainder(dividingBy: 60) - case ..<86400: elapsed.truncatingRemainder(dividingBy: 3600) - default: 0 - } - return addingTimeInterval(remainder).formatted(.relative(presentation: .named, unitsStyle: .wide).locale(locale)) + + return String( + localized: "\(elapsed.formattedSpokenTimeSpan(.largestTwoUnits, locale: locale)) ago", + comment: "Accessibility: elapsed time; %@ is a duration like \"1 hour, 59 minutes\". Word it so %@ stays unchanged (German: \"%@ her\")." + ) } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 1a99f0a5b3..f1465ea869 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -9500,6 +9500,9 @@ } } }, + "%@ ago" : { + "comment" : "Accessibility: elapsed time; %@ is a duration like \"1 hour, 59 minutes\". Word it so %@ stays unchanged (German: \"%@ her\")." + }, "%@ and %lld g fat" : { "comment" : "The text to be displayed when fat is > 0 for the Add Carbs shortcut", "localizations" : { @@ -180832,6 +180835,9 @@ } } }, + "less than a minute" : { + "comment" : "Accessibility: a duration under one minute" + }, "less than a minute ago" : { "comment" : "Accessibility: elapsed time", "localizations" : { diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift index ea5dfc01a9..79b64a9a0e 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -172,7 +172,7 @@ extension Adjustments.RootView { Text(tempTarget.name ?? "") Spacer() if remainingTime > 0 { - Text("Starts in \(Duration.seconds(remainingTime).formattedTimeSpan())") + Text("Starts in \(Duration.seconds(remainingTime).formattedTimeSpan(.largestTwoUnits))") .foregroundColor(colorScheme == .dark ? .orange : .accentColor) } } diff --git a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index 1398787124..8e91cdccd5 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -312,7 +312,12 @@ struct CurrentGlucoseView: View { if isInWarmup { if let endsAt = cgmWarmupEndsAt { let remaining = Duration.seconds(endsAt.timeIntervalSinceNow) - return (remaining.formattedTimeSpan(), remaining.formattedSpokenTimeSpan(), .orange, "hourglass") + return ( + remaining.formattedTimeSpan(.largestTwoUnits), + remaining.formattedSpokenTimeSpan(.largestTwoUnits), + .orange, + "hourglass" + ) } return ("Warming up", "Warming up", .orange, "hourglass") } @@ -338,7 +343,12 @@ struct CurrentGlucoseView: View { case .warning: theme = .orange default: theme = .green } - return (remaining.formattedTimeSpan(), remaining.formattedSpokenTimeSpan(), theme, nil) + return ( + remaining.formattedTimeSpan(.largestTwoUnits), + remaining.formattedSpokenTimeSpan(.largestTwoUnits), + theme, + nil + ) } return nil } diff --git a/Trio/Sources/Modules/Home/View/Header/PumpView.swift b/Trio/Sources/Modules/Home/View/Header/PumpView.swift index 9614852225..834f2abb03 100644 --- a/Trio/Sources/Modules/Home/View/Header/PumpView.swift +++ b/Trio/Sources/Modules/Home/View/Header/PumpView.swift @@ -141,7 +141,7 @@ struct PumpView: View { guard time > 0 else { return String(localized: "Replace pod", comment: "View/Header when pod expired") } - return Duration.seconds(time).formattedTimeSpan() + return Duration.seconds(time).formattedTimeSpan(.largestTwoUnits) } /// Spoken remaining pod time, so VoiceOver doesn't read the compact "m" as "meters". @@ -149,7 +149,7 @@ struct PumpView: View { guard time > 0 else { return String(localized: "Replace pod", comment: "View/Header when pod expired") } - return Duration.seconds(time).formattedSpokenTimeSpan() + return Duration.seconds(time).formattedSpokenTimeSpan(.largestTwoUnits) } /// Reservoir amount plus a spoken severity word, so the color-coded low state is not lost. diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 5c6d2b12e1..496456d5f6 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -84,8 +84,8 @@ extension Home.RootView { if !indefinite { if remaining > .zero { - durationString = remaining.formattedTimeSpan() - spokenDurationString = remaining.formattedSpokenTimeSpan() + durationString = remaining.formattedTimeSpan(.largestTwoUnits) + spokenDurationString = remaining.formattedSpokenTimeSpan(.largestTwoUnits) } else { /// Do not show the Override anymore Task { @@ -157,8 +157,8 @@ extension Home.RootView { let targetString = target == 0 ? "" : target.formatted(withUnits: state.units) + percentageString if remaining > .zero { - durationString = remaining.formattedTimeSpan() - spokenDurationString = remaining.formattedSpokenTimeSpan() + durationString = remaining.formattedTimeSpan(.largestTwoUnits) + spokenDurationString = remaining.formattedSpokenTimeSpan(.largestTwoUnits) } else { /// Do not show the Temp Target anymore Task { From de46cd95b0b5412e6101e03d36531bf717d313a9 Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Mon, 9 Nov 2026 14:19:42 +0100 Subject: [PATCH 10/20] Fix SMB/UAM minute labels and update duration tests --- .../Localizations/Main/Localizable.xcstrings | 1 + .../AdjustmentsRootView+Overrides.swift | 30 +++++++------ .../View/HomeRootView+BottomControls.swift | 43 +++++++++++-------- TrioTests/DurationFormattingTests.swift | 41 ++++++++---------- 4 files changed, 64 insertions(+), 51 deletions(-) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index f1465ea869..c63876365b 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -191912,6 +191912,7 @@ }, "m" : { "comment" : "Abbreviation for Minutes\nabbreviation for minutes", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 5076aafb52..393ef29628 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -155,7 +155,11 @@ extension Adjustments.RootView { let targetString = target.isEmpty ? "" : "\(target) \(state.units.rawValue)" - let durationSpan: Duration? = indefinite ? nil : Duration(minutes: duration) + let durationLabel: (text: String, spoken: String) = { + guard !indefinite else { return ("", "") } + let span = Duration(minutes: duration) + return (span.formattedTimeSpan(), span.formattedSpokenTimeSpan()) + }() let scheduledSMBString: String = { guard preset.smbIsScheduledOff, preset.start != preset.end else { return "" } @@ -167,16 +171,18 @@ extension Adjustments.RootView { return "SMBs Off\(scheduledSMBString)" }() - let maxSmbMinsString: String = { + let maxSmbMins: (text: String, spoken: String) = { guard smbMinutes != 0, preset.advancedSettings, !preset.smbIsOff, - smbMinutes != state.defaultSmbMinutes else { return "" } - return "\(smbMinutes.formatted()) min SMB" + smbMinutes != state.defaultSmbMinutes else { return ("", "") } + let span = Duration(minutes: smbMinutes) + return ("SMB\u{00A0}" + span.formattedTimeSpan(.minutes), "SMB\u{00A0}" + span.formattedSpokenTimeSpan(.minutes)) }() - let maxUamMinsString: String = { + let maxUamMins: (text: String, spoken: String) = { guard uamMinutes != 0, preset.advancedSettings, !preset.smbIsOff, - uamMinutes != state.defaultUamMinutes else { return "" } - return "\(uamMinutes.formatted()) min UAM" + uamMinutes != state.defaultUamMinutes else { return ("", "") } + let span = Duration(minutes: uamMinutes) + return ("UAM\u{00A0}" + span.formattedTimeSpan(.minutes), "UAM\u{00A0}" + span.formattedSpokenTimeSpan(.minutes)) }() let isfAndCrString: String = { @@ -200,11 +206,11 @@ extension Adjustments.RootView { return "\(Int(percentage))%\(isfAndCrString)" }() - // Combine all labels into a single array, filtering out empty strings - let otherLabels = [percentageString, targetString, smbString, maxSmbMinsString, maxUamMinsString] - let labels = ([durationSpan?.formattedTimeSpan() ?? ""] + otherLabels).filter { !$0.isEmpty } - // VoiceOver reads the duration spelled out - let spokenLabels = ([durationSpan?.formattedSpokenTimeSpan() ?? ""] + otherLabels).filter { !$0.isEmpty } + let otherLabels = [percentageString, targetString, smbString] + let labels = ([durationLabel.text] + otherLabels + [maxSmbMins.text, maxUamMins.text]) + .filter { !$0.isEmpty } + let spokenLabels = ([durationLabel.spoken] + otherLabels + [maxSmbMins.spoken, maxUamMins.spoken]) + .filter { !$0.isEmpty } if !name.isEmpty { ZStack(alignment: .trailing) { diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 496456d5f6..ee4132279d 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -79,13 +79,14 @@ extension Home.RootView { let date = latestOverride.date ?? Date() let remaining = Duration(minutes: (latestOverride.duration ?? 0) as Decimal) + .seconds(date.timeIntervalSinceNow) let indefinite = latestOverride.indefinite - var durationString = "" - var spokenDurationString = "" + var durationLabel = (text: "", spoken: "") if !indefinite { if remaining > .zero { - durationString = remaining.formattedTimeSpan(.largestTwoUnits) - spokenDurationString = remaining.formattedSpokenTimeSpan(.largestTwoUnits) + durationLabel = ( + remaining.formattedTimeSpan(.largestTwoUnits), + remaining.formattedSpokenTimeSpan(.largestTwoUnits) + ) } else { /// Do not show the Override anymore Task { @@ -103,28 +104,36 @@ extension Home.RootView { let smbToggleString = latestOverride.smbIsOff || latestOverride .smbIsScheduledOff ? String(localized: "SMBs Off\(smbScheduleString)") : "" - var smbMinuteString: String = "" - var uamMinuteString: String = "" + var smbMinuteLabel = (text: "", spoken: "") + var uamMinuteLabel = (text: "", spoken: "") if !latestOverride.smbIsOff, latestOverride.advancedSettings { - if let smbMinutes = latestOverride.smbMinutes, - smbMinutes.decimalValue != settingsManager.preferences.maxSMBBasalMinutes + if let smbMinutes = latestOverride.smbMinutes?.decimalValue, + smbMinutes != settingsManager.preferences.maxSMBBasalMinutes { - smbMinuteString = "SMB\u{00A0}\(smbMinutes)\u{00A0}" + - String(localized: "m", comment: "Abbreviation for Minutes") + let span = Duration(minutes: smbMinutes) + smbMinuteLabel = ( + "SMB\u{00A0}" + span.formattedTimeSpan(.minutes), + "SMB\u{00A0}" + span.formattedSpokenTimeSpan(.minutes) + ) } - if let uamMinutes = latestOverride.uamMinutes, - uamMinutes.decimalValue != settingsManager.preferences.maxUAMSMBBasalMinutes + if let uamMinutes = latestOverride.uamMinutes?.decimalValue, + uamMinutes != settingsManager.preferences.maxUAMSMBBasalMinutes { - uamMinuteString = "UAM\u{00A0}\(uamMinutes)\u{00A0}" + - String(localized: "m", comment: "Abbreviation for Minutes") + let span = Duration(minutes: uamMinutes) + uamMinuteLabel = ( + "UAM\u{00A0}" + span.formattedTimeSpan(.minutes), + "UAM\u{00A0}" + span.formattedSpokenTimeSpan(.minutes) + ) } } - let otherComponents = [percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString] - let components = ([durationString] + otherComponents).filter { !$0.isEmpty } - let spokenComponents = ([spokenDurationString] + otherComponents).filter { !$0.isEmpty } + let otherComponents = [percentString, targetString, smbToggleString] + let components = ([durationLabel.text] + otherComponents + [smbMinuteLabel.text, uamMinuteLabel.text]) + .filter { !$0.isEmpty } + let spokenComponents = ([durationLabel.spoken] + otherComponents + [smbMinuteLabel.spoken, uamMinuteLabel.spoken]) + .filter { !$0.isEmpty } guard !components.isEmpty else { return nil } return (components.joined(separator: ", "), spokenComponents.joined(separator: ", ")) diff --git a/TrioTests/DurationFormattingTests.swift b/TrioTests/DurationFormattingTests.swift index ee5a2bce16..d5c5f2ffd8 100644 --- a/TrioTests/DurationFormattingTests.swift +++ b/TrioTests/DurationFormattingTests.swift @@ -5,40 +5,37 @@ import Testing @Suite("Duration Formatting") struct DurationFormattingTests { private let english = Locale(identifier: "en_US") - private let french = Locale(identifier: "fr_FR") - @Test("Lengths round down, with hours and minutes under a day and days and hours from a day", arguments: [ - (0, "0m", "0min", "0 minutes"), - (59, "<\u{00A0}1m", "<\u{00A0}1min", "<\u{00A0}1 minute"), - (-30, "<\u{00A0}1m", "<\u{00A0}1min", "<\u{00A0}1 minute"), - (89, "1m", "1min", "1 minute"), - (7200, "2h", "2h", "2 hours"), - (86399, "23h 59m", "23h 59min", "23 hours, 59 minutes"), - (86400, "1d", "1j", "1 day"), - (485_940, "5d 14h", "5j 14h", "5 days, 14 hours") - ]) func timeSpan(seconds: Int, expected: String, expectedFrench: String, expectedSpoken: String) { + @Test("Each style shows its units, rounded down", arguments: [ + (0, Duration.SpanStyle.exact, "0m", "0 minutes"), + (59, .exact, "<\u{00A0}1m", "less than a minute"), + (1530 * 60, .exact, "1d 1h 30m", "1 day, 1 hour, 30 minutes"), + (86399, .largestTwoUnits, "23h 59m", "23 hours, 59 minutes"), + (86400, .largestTwoUnits, "1d", "1 day"), + (485_940, .largestTwoUnits, "5d 14h", "5 days, 14 hours"), + (90 * 60, .minutes, "90m", "90 minutes") + ]) func timeSpan(seconds: Int, style: Duration.SpanStyle, expected: String, expectedSpoken: String) { let duration = Duration.seconds(seconds) - #expect(duration.formattedTimeSpan(locale: english) == expected) - #expect(duration.formattedTimeSpan(locale: french) == expectedFrench) - #expect(duration.formattedSpokenTimeSpan(locale: english) == expectedSpoken) + #expect(duration.formattedTimeSpan(style, locale: english) == expected) + #expect(duration.formattedSpokenTimeSpan(style, locale: english) == expectedSpoken) } - // shortcut: reads the real clock, so the 1-day case can fail in the 24 hours after clocks go back; - // pass a reference date (Date.AnchoredRelativeFormatStyle) once iOS 18 is the minimum. - @Test("VoiceOver time ago matches the visible text under a day and names the calendar day after", arguments: [ + @Test("Time ago shows the two largest units, and VoiceOver reads the same span", arguments: [ (30, "<\u{00A0}1m", "less than a minute ago"), (-30, "<\u{00A0}1m", "less than a minute ago"), - (479, "7m", "7 minutes ago"), - (10740, "2h 59m", "2 hours ago"), - (86400, "1d", "yesterday") + (7140, "1h 59m", "1 hour, 59 minutes ago"), + (172_740, "1d 23h", "1 day, 23 hours ago") ]) func timeAgo(secondsAgo: Int, expected: String, expectedSpoken: String) { let date = Date(timeIntervalSinceNow: TimeInterval(-secondsAgo)) #expect(date.formattedTimeAgo(locale: english) == expected) #expect(date.formattedSpokenTimeAgo(locale: english) == expectedSpoken) } - @Test("Spoken time ago uses the locale's grammar") func spokenTimeAgoFrench() { - #expect(Date(timeIntervalSinceNow: -479).formattedSpokenTimeAgo(locale: french) == "il y a 7 minutes") + @Test("Locale formats the units; the words around them follow the app language") func locale() { + let french = Locale(identifier: "fr_FR") + #expect(Duration.seconds(5400).formattedTimeSpan(locale: french) == "1h 30min") + #expect(Duration.seconds(30).formattedTimeSpan(locale: french) == "<\u{00A0}1min") + #expect(Date(timeIntervalSinceNow: -7200).formattedSpokenTimeAgo(locale: french) == "2\u{00A0}heures ago") } @Test("Stored minutes clamp instead of trapping on corrupt values") func storedMinutes() { From 569a1e562e3e9704143b6a2dcd8b6e074d3a171f Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Mon, 9 Nov 2026 15:35:47 +0100 Subject: [PATCH 11/20] Translate SMBs Off in the Adjustments override row --- .../View/Overrides/AdjustmentsRootView+Overrides.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 393ef29628..01a21fcff0 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -168,7 +168,7 @@ extension Adjustments.RootView { let smbString: String = { guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" } - return "SMBs Off\(scheduledSMBString)" + return String(localized: "SMBs Off\(scheduledSMBString)") }() let maxSmbMins: (text: String, spoken: String) = { From a349f86d427cc35a4f43dd36f9090da0050f2abb Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:02:40 -0700 Subject: [PATCH 12/20] Redact signed-out Dexcom Share accounts in export --- Trio/Sources/Logger/Export/LogExport.swift | 1 + .../Logger/Export/SensitiveValues.swift | 9 +++++++++ TrioTests/Logging/LogExportTests.swift | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index f81295c7e1..2c2a57a4db 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -271,6 +271,7 @@ final class LogExport: Injectable { try Task.checkCancellation() for entry in await deviceLogStore.entries(in: slice) { values.add(entry.deviceIdentifier, .device) + values.add(deviceLogMessage: entry.message) } } } diff --git a/Trio/Sources/Logger/Export/SensitiveValues.swift b/Trio/Sources/Logger/Export/SensitiveValues.swift index 9aaa4fb7ea..73775a881f 100644 --- a/Trio/Sources/Logger/Export/SensitiveValues.swift +++ b/Trio/Sources/Logger/Export/SensitiveValues.swift @@ -124,6 +124,15 @@ struct SensitiveValues { add(PropertyPersistentFlags.shared.telemetryInstallId, .installID) } + // G7 logs the Share account at sign-in; after a sign-out that line is the only record of it + mutating func add(deviceLogMessage message: String) { + let prefix = "Signed in to Dexcom Share as " + guard message.hasPrefix(prefix) else { return } + let rest = message.dropFirst(prefix.count) + guard let server = rest.range(of: " (", options: .backwards) else { return } + add(String(rest[../api/v1/entries")) } + @Test("A Share account signed out before export is still replaced") func redactsSignedOutShareAccount() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + await Self.log(store, manager: "com.example.cgm", device: nil, message: "Signed in to Dexcom Share as share-user (us)") + await Self.log(store, manager: "com.example.cgm", device: nil, message: "Upload for share-user failed") + await Self.log(store, manager: "com.example.cgm", device: nil, message: "Signed out of Dexcom Share") + let export = LogExport(appLog: InMemoryLogSink(), deviceLogStore: store, pump: nil, cgm: StubCGMManager()) + + let text = try await Self.contents(of: .all, in: Fixture(directory: directory, export: export)).text + #expect(!text.contains("share-user")) + #expect(text.contains("Signed in to Dexcom Share as (us)")) + #expect(text.contains("Upload for failed")) + } + @Test("Determinations in the window become Algorithm lines") func algorithmLines() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } From ab0a7a1b9bb2fbe8a269f0d2c0578b39ec7fbe33 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:02:40 -0700 Subject: [PATCH 13/20] Fix Medtrum crash loop and device delete races --- LibreLoop | 2 +- MedtrumKit | 2 +- SyaiKit | 2 +- Trio/Sources/APS/DeviceDataManager.swift | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/LibreLoop b/LibreLoop index 0c90cd9a05..dbe4f9cdd1 160000 --- a/LibreLoop +++ b/LibreLoop @@ -1 +1 @@ -Subproject commit 0c90cd9a05353ed60642100e0bf1f7c1ec05f76a +Subproject commit dbe4f9cdd165b0c7b50be7b583b5b6c32d97af8d diff --git a/MedtrumKit b/MedtrumKit index 1e761ef6ac..eee17b0fc4 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit 1e761ef6acee446262e6cedd5e9cff5979260d10 +Subproject commit eee17b0fc4768fb9d9dedd9cb857d010c668b564 diff --git a/SyaiKit b/SyaiKit index d46674c540..ef0aed3420 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit d46674c5405d16418b55709d2e7464cd95d92412 +Subproject commit ef0aed34209e0842dc6ced360c85bfc677cfb8ab diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index ac1f2db4e7..ede265f9b2 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -78,8 +78,9 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { trioAlertManager?.unregister(managerIdentifier: oldValue.pluginIdentifier) } if let pumpManager = pumpManager { - pumpManager.pumpManagerDelegate = self + // Queue before delegate, so no delegate call lands on the default main queue pumpManager.delegateQueue = processQueue + pumpManager.pumpManagerDelegate = self // Re-apply the latest CGM-aligned heartbeat request to a freshly set pump if let heartbeatRequest = lastPumpHeartbeatRequest { From ed57a06c52940f4912632fc6969284b84dea795f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Nov 2026 21:14:51 +0000 Subject: [PATCH 14/20] CI: Bump APP_DEV_VERSION to 1.0.2.62 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f33edf8604..9bd478f417 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.61 +APP_DEV_VERSION = 1.0.2.62 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From c6ff7e02c5ab0773e4e2628125e167b3cc0a7a66 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:33:39 -0700 Subject: [PATCH 15/20] Wait for first unlock when Core Data load is denied --- Model/CoreDataStack.swift | 66 ++++++++++++++++++- Trio.xcodeproj/project.pbxproj | 4 ++ .../CoreDataProtectedDataTests.swift | 58 ++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 TrioTests/CoreDataTests/CoreDataProtectedDataTests.swift diff --git a/Model/CoreDataStack.swift b/Model/CoreDataStack.swift index 2aa44552d7..d1d8726c10 100644 --- a/Model/CoreDataStack.swift +++ b/Model/CoreDataStack.swift @@ -2,6 +2,7 @@ import Combine import CoreData import Foundation import OSLog +import UIKit class CoreDataStack: ObservableObject { static let shared = CoreDataStack() @@ -294,8 +295,12 @@ class CoreDataStack: ObservableObject { if retryCount < maxRetries { debug(.storage, "Retrying Core Data initialization (\(retryCount + 1)/\(maxRetries))") - // Wait before retrying - try await Task.sleep(for: .seconds(1)) + // Before the first unlock after a restart the store is unreadable, so wait for the unlock + if Self.isProtectedDataUnavailableError(initError) { + await Self.waitForProtectedData() + } else { + try await Task.sleep(for: .seconds(1)) + } // Retry the initialization try await initializeStack(retryCount: retryCount + 1) @@ -308,6 +313,63 @@ class CoreDataStack: ObservableObject { } } +// MARK: - Protected Data + +extension CoreDataStack { + /// After a restart, iOS can launch Trio in the background before the first unlock, when the store file + /// is unreadable and SQLite reports SQLITE_AUTH (23). `isProtectedDataAvailable` alone can't tell this + /// apart from an ordinary lock, during which the store stays readable. + static func isProtectedDataUnavailableError(_ error: Error) -> Bool { + let nsError = error as NSError + if (nsError.userInfo[NSSQLiteErrorDomain] as? Int) == 23 { + return true + } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + return isProtectedDataUnavailableError(underlying) + } + return false + } + + /// Waits until protected data is available, i.e. the device has been unlocked. + @MainActor static func waitForProtectedData( + isAvailable: @MainActor() -> Bool = { UIApplication.shared.isProtectedDataAvailable }, + notificationCenter: Foundation.NotificationCenter = .default + ) async { + guard !isAvailable() else { return } + info(.storage, "Waiting for the first unlock before opening the Core Data store") + + final class Waiter { + var observer: NSObjectProtocol? + var continuation: CheckedContinuation? + } + let waiter = Waiter() + + await withCheckedContinuation { continuation in + waiter.continuation = continuation + let finish = { + guard let continuation = waiter.continuation else { return } + waiter.continuation = nil + if let observer = waiter.observer { + notificationCenter.removeObserver(observer) + } + continuation.resume() + } + waiter.observer = notificationCenter.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { finish() } + } + // An unlock between the first check and adding the observer would otherwise be missed + if isAvailable() { + finish() + } + } + info(.storage, "Device unlocked, opening the Core Data store") + } +} + // MARK: - Delete extension CoreDataStack { diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 665956197f..8999135513 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -712,6 +712,7 @@ 36BDB3927CB9ABFB548ED2B0 /* CarbEntryMutationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */; }; BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; }; BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; }; + FE9C3A9F2A75014FB09A16B1 /* CoreDataProtectedDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2BADBACD9C568C3682721A /* CoreDataProtectedDataTests.swift */; }; BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; }; BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; }; CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */; }; @@ -1937,6 +1938,7 @@ A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryMutationServiceTests.swift; sourceTree = ""; }; BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = ""; }; BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = ""; }; + 2D2BADBACD9C568C3682721A /* CoreDataProtectedDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataProtectedDataTests.swift; sourceTree = ""; }; BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = ""; }; BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = ""; }; CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolUploadSerializerTests.swift; sourceTree = ""; }; @@ -4345,6 +4347,7 @@ A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */, BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */, BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */, + 2D2BADBACD9C568C3682721A /* CoreDataProtectedDataTests.swift */, BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */, ); path = CoreDataTests; @@ -6536,6 +6539,7 @@ 7A11AC0DE00000000000011D /* PumpEventPredicateTests.swift in Sources */, 7A11AC0DE000000000000115 /* MultiUsePanelStateTests.swift in Sources */, BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */, + FE9C3A9F2A75014FB09A16B1 /* CoreDataProtectedDataTests.swift in Sources */, 3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */, 3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */, BD8FC0542D66186000B95AED /* TestError.swift in Sources */, diff --git a/TrioTests/CoreDataTests/CoreDataProtectedDataTests.swift b/TrioTests/CoreDataTests/CoreDataProtectedDataTests.swift new file mode 100644 index 0000000000..ee61779222 --- /dev/null +++ b/TrioTests/CoreDataTests/CoreDataProtectedDataTests.swift @@ -0,0 +1,58 @@ +import CoreData +import Foundation +import Testing +import UIKit + +@testable import Trio + +@Suite("Core Data waits for protected data") struct CoreDataProtectedDataTests { + @MainActor private final class Flag { + var value: Bool + init(_ value: Bool) { self.value = value } + } + + @Test("Recognizes SQLITE_AUTH as protected data unavailable") func recognizesSQLiteAuth() { + let direct = NSError(domain: NSCocoaErrorDomain, code: 256, userInfo: [NSSQLiteErrorDomain: 23]) + let nested = NSError(domain: NSCocoaErrorDomain, code: 256, userInfo: [NSUnderlyingErrorKey: direct]) + let other = NSError(domain: NSCocoaErrorDomain, code: 256, userInfo: [NSSQLiteErrorDomain: 14]) + + #expect(CoreDataStack.isProtectedDataUnavailableError(direct)) + #expect(CoreDataStack.isProtectedDataUnavailableError(nested)) + #expect(!CoreDataStack.isProtectedDataUnavailableError(other)) + #expect(!CoreDataStack.isProtectedDataUnavailableError(CocoaError(.fileReadUnknown))) + } + + @Test("Returns at once when protected data is available") @MainActor func availableReturnsImmediately() async { + let center = Foundation.NotificationCenter() + await CoreDataStack.waitForProtectedData(isAvailable: { true }, notificationCenter: center) + } + + @Test("Waits until protected data becomes available") @MainActor func waitsForUnlock() async throws { + let center = Foundation.NotificationCenter() + let finished = Flag(false) + let task = Task { @MainActor in + await CoreDataStack.waitForProtectedData(isAvailable: { false }, notificationCenter: center) + finished.value = true + } + + try await Task.sleep(for: .milliseconds(200)) + #expect(!finished.value) + + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + await task.value + #expect(finished.value) + } + + @Test("An unlock during the check is not missed") @MainActor func unlockDuringCheck() async { + let center = Foundation.NotificationCenter() + let checks = Flag(false) + // First check says locked, the re-check after adding the observer says unlocked + await CoreDataStack.waitForProtectedData( + isAvailable: { + defer { checks.value = true } + return checks.value + }, + notificationCenter: center + ) + } +} From 61f91b59f0e39007d23145d7887d9f329cf46785 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 07:55:55 +0000 Subject: [PATCH 16/20] CI: Bump APP_DEV_VERSION to 1.0.2.63 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 9bd478f417..9f641c5ccd 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.62 +APP_DEV_VERSION = 1.0.2.63 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 9ccaad14cbb8ff36dc5b41b465124418f578fcf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 07:57:25 +0000 Subject: [PATCH 17/20] CI: Bump APP_DEV_VERSION to 1.0.2.64 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 9f641c5ccd..af484ebc1b 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.63 +APP_DEV_VERSION = 1.0.2.64 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 8437e8a946fac8b805798a00464a99ace215b3d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 14:05:06 +0000 Subject: [PATCH 18/20] CI: Bump APP_DEV_VERSION to 1.0.2.65 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index af484ebc1b..4c8cbb0e18 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.64 +APP_DEV_VERSION = 1.0.2.65 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 902be75e85520404962a585ee9e708ac5607ca43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 17:11:27 +0000 Subject: [PATCH 19/20] CI: Bump APP_DEV_VERSION to 1.0.2.66 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4c8cbb0e18..f28200b894 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.65 +APP_DEV_VERSION = 1.0.2.66 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 1b77bc0f21544a33ea408ceec8070c9c8b42ff3c Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 11 Nov 2026 07:07:26 +0100 Subject: [PATCH 20/20] fix(basal): resolve picker basal rates against the pump's rate table The basal editor mapped the picker's value back to a rate index with a fixed-scale floor (rounded(scale: 3)). The picker round-trips through Double, so on a U10 grid with a 0.025 pump step 0.0425 came back as 0.0424999..., truncated to 0.042 and was floored to 0.04 - a silent ~6 % under-delivery that was then saved. Move roundBasal's nearest-rate rule (ties upward, zero seed) into Decimal.nearestSupportedBasalRate(in:), have roundBasal call it, and use it in updateFromTherapyItems so the editor resolves rates the way the algorithm does. Tests: a "Basal editor picker rate selection" suite in RoundBasalTests covering the U10 0.0425 regression, U10/U50 four-decimal rates, a round-trip sweep over 0.01/0.025/0.05/0.1 steps x four concentrations, and parity with roundBasal. --- .../DetermineBasal/TempBasalFunctions.swift | 6 +- Trio/Sources/Helpers/Decimal+Extensions.swift | 11 +++ .../BasalProfileEditorStateModel.swift | 7 +- .../OpenAPSSwiftTests/RoundBasalTests.swift | 69 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift index 415b52b51b..4f9233a6e1 100644 --- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift +++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift @@ -25,11 +25,7 @@ enum TempBasalFunctions { return basalRate.rounded(scale: 5, roundingMode: .down) } - return profile.supportedBasalRates.reduce(0) { nearest, rate in - let distance = abs(rate - basalRate) - let nearestDistance = abs(nearest - basalRate) - return distance < nearestDistance || (distance == nearestDistance && rate > nearest) ? rate : nearest - } + return basalRate.nearestSupportedBasalRate(in: profile.supportedBasalRates) } /// defines the max safe basal rate given a profile diff --git a/Trio/Sources/Helpers/Decimal+Extensions.swift b/Trio/Sources/Helpers/Decimal+Extensions.swift index b24afb49dc..45d90ea35c 100644 --- a/Trio/Sources/Helpers/Decimal+Extensions.swift +++ b/Trio/Sources/Helpers/Decimal+Extensions.swift @@ -109,6 +109,17 @@ extension Decimal { let divisor = pow(10.0, Double(places)) return (Double(self) * divisor).rounded() / divisor } + + /// The closest of `rates` to this value, ties resolved upward, with zero seeding the search so + /// a value below every entry still resolves to a deliverable rate — the rule + /// `TempBasalFunctions.roundBasal` applies to basal rates. + func nearestSupportedBasalRate(in rates: [Decimal]) -> Decimal { + rates.reduce(0) { nearest, rate in + let distance = abs(rate - self) + let nearestDistance = abs(nearest - self) + return distance < nearestDistance || (distance == nearestDistance && rate > nearest) ? rate : nearest + } + } } // MARK: - Double Initializer for Decimal diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift index 49b5248b8a..0b7de8e566 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift @@ -59,7 +59,12 @@ extension BasalProfileEditor { func updateFromTherapyItems(_ therapyItems: [TherapySettingItem]) { items = therapyItems.map { therapyItem in let timeIndex = timeValues.firstIndex(where: { abs($0 - therapyItem.time) < 1 }) ?? 0 - let rateIndex = rateValues.lastIndex(where: { $0 <= therapyItem.value.rounded(scale: 3) }) ?? 0 + // The picker hands its value back through `Double`, so it can sit a hair below the + // rate it represents (0.0425 arrives as 0.0424999…). Resolve it against the available + // rates the way the algorithm rounds a basal rate; a fixed decimal scale flattened + // Tai's concentration-adjusted four-decimal grids (U10 on a 0.025 pump step). + let nearest = therapyItem.value.nearestSupportedBasalRate(in: rateValues) + let rateIndex = rateValues.firstIndex(of: nearest) ?? 0 return Item(rateIndex: rateIndex, timeIndex: timeIndex) } } diff --git a/TrioTests/OpenAPSSwiftTests/RoundBasalTests.swift b/TrioTests/OpenAPSSwiftTests/RoundBasalTests.swift index 951e60c52c..ee6bc05585 100644 --- a/TrioTests/OpenAPSSwiftTests/RoundBasalTests.swift +++ b/TrioTests/OpenAPSSwiftTests/RoundBasalTests.swift @@ -156,3 +156,72 @@ private extension Determination { ) } } + +/// The basal editor's picker round-trips its value through `Double`, then resolves it against the +/// concentration-adjusted rate grid with `nearestSupportedBasalRate`, the rule `roundBasal` uses. +@Suite("Basal editor picker rate selection") struct BasalEditorPickerRateTests { + private static let decimilliTable: [Decimal] = (0 ... 350).map { Decimal($0) / 10 } + private static let tables: [[Decimal]] = [PumpRateTables.dana, PumpRateTables.minimedGen23, PumpRateTables.flat, decimilliTable] + private static let concentrations: [Decimal] = [Decimal(1) / 10, Decimal(1) / 2, 1, 2] + + /// What the picker's `tag(Double(value))` / `Decimal($0)` round-trip hands back. + private func pickerRoundTrip(_ value: Decimal) -> Decimal { + Decimal(Double(truncating: value as NSNumber)) + } + + @Test("U10 on a 0.025 pump keeps 0.0425 instead of flattening it to 0.04") func u10Regression() throws { + let grid = PumpRateTables.minimedGen23.map { $0 / 10 } + let target = Decimal(425) / 10000 + let targetIndex = try #require(grid.firstIndex(of: target)) + let picked = pickerRoundTrip(grid[targetIndex]) + + // the old fixed-scale floor truncated the round-tripped value below the rate it represents + #expect(picked.rounded(scale: 3) < target) + + let nearest = picked.nearestSupportedBasalRate(in: grid) + #expect(nearest == target) + #expect(grid.firstIndex(of: nearest) == targetIndex) + #expect(nearest != Decimal(4) / 100) + } + + @Test("U10 on a 0.025 pump resolves neighbouring four-decimal rates to themselves", arguments: [ + Decimal(25) / 1000, Decimal(175) / 1000, Decimal(575) / 1000, Decimal(825) / 1000 + ]) func u10Neighbours(pumpRate: Decimal) { + let grid = PumpRateTables.minimedGen23.map { $0 / 10 } + let rate = pumpRate / 10 + #expect(grid.contains(rate)) + #expect(pickerRoundTrip(rate).nearestSupportedBasalRate(in: grid) == rate) + } + + @Test("U50 on a 0.025 pump resolves four-decimal rates to themselves", arguments: [ + Decimal(125) / 10000, Decimal(375) / 10000, Decimal(625) / 10000 + ]) func u50FourDecimals(rate: Decimal) { + let grid = PumpRateTables.minimedGen23.map { $0 / 2 } + #expect(grid.contains(rate)) + #expect(pickerRoundTrip(rate).nearestSupportedBasalRate(in: grid) == rate) + } + + @Test("every grid entry survives the picker round-trip for each step and concentration") func everyGridEntry() { + var mismatches: [String] = [] + for table in Self.tables { + for concentration in Self.concentrations { + let grid = table.map { $0 * concentration } + for rate in grid where pickerRoundTrip(rate).nearestSupportedBasalRate(in: grid) != rate { + mismatches.append("\(rate) at concentration \(concentration)") + } + } + } + #expect(mismatches.isEmpty, "\(mismatches.prefix(10))") + } + + @Test("matches roundBasal on the same tables") func parityWithRoundBasal() { + let probes: [Decimal] = [0, 0.001, 0.0124, 0.0125, 0.03, 0.0425, 0.4, 0.999, 1.0, 1.234, 5.375, 9.99, 10.06, 29.999, 40] + for table in Self.tables { + var profile = Profile() + profile.supportedBasalRates = table + for probe in probes { + #expect(probe.nearestSupportedBasalRate(in: table) == TempBasalFunctions.roundBasal(profile: profile, basalRate: probe)) + } + } + } +}