diff --git a/Config.xcconfig b/Config.xcconfig index bba3fcad37..6795681025 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.66 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = autoISF 3.01 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/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/SyaiKit b/SyaiKit index d46674c540..ef0aed3420 160000 --- a/SyaiKit +++ b/SyaiKit @@ -1 +1 @@ -Subproject commit d46674c5405d16418b55709d2e7464cd95d92412 +Subproject commit ef0aed34209e0842dc6ced360c85bfc677cfb8ab diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 9625ec270e..c1892503cc 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -672,7 +672,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 */; }; @@ -724,6 +723,8 @@ 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 */; }; + 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 */; }; @@ -752,6 +753,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 */; }; @@ -1070,7 +1072,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 */; }; @@ -1993,7 +1994,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 = ""; }; @@ -2043,6 +2043,8 @@ 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 = ""; }; + 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 = ""; }; @@ -2068,6 +2070,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 = ""; }; @@ -2391,7 +2394,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 = ""; }; @@ -3585,7 +3587,6 @@ 38E98A3625F5509500C0CED0 /* String+Extensions.swift */, 49239B422EEA27AD00469145 /* TempTargetCalculations.swift */, DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */, - DDD5889C2DDDC9A900C8848D /* TimeAgoFormatter.swift */, D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */, CEB434E428B8FF5D00B70274 /* UIColor.swift */, FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */, @@ -3734,6 +3735,8 @@ isa = PBXGroup; children = ( A1A0EDA730974FFF0028A032 /* GlucoseFormattingTests.swift */, + A1EF5D9A309CEF01007282CC /* DurationFormattingTests.swift */, + A1A0EDF43097CE470028A032 /* PercentFormattingTests.swift */, DDDD0FFD2F4E231600F9C645 /* Mocks */, DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */, 7A11AC0DE000000000000015 /* CoreDataTests/MultiUsePanelStateTests.swift */, @@ -4520,7 +4523,6 @@ BD11C002000000000000C000 /* SensorLifecycle */ = { isa = PBXGroup; children = ( - BD11C000000000000000C001 /* CGMSensorDisplayState.swift */, BD11C000000000000000C003 /* SensorLifecycleArcView.swift */, BD11C000000000000000C004 /* SensorStatusTagView.swift */, ); @@ -4677,6 +4679,7 @@ A19B7FC1E8CD873546B563CB /* CarbEntryMutationServiceTests.swift */, BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */, BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */, + 2D2BADBACD9C568C3682721A /* CoreDataProtectedDataTests.swift */, BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */, ); path = CoreDataTests; @@ -6328,7 +6331,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 */, @@ -6848,7 +6850,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 */, @@ -7061,6 +7062,8 @@ 3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */, 3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */, A1A0EDA830974FFF0028A032 /* GlucoseFormattingTests.swift in Sources */, + A1EF5D9B309CEF01007282CC /* DurationFormattingTests.swift in Sources */, + A1A0EDF53097CE470028A032 /* PercentFormattingTests.swift in Sources */, 3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */, BD11A011000000000000A011 /* RoundBasalTests.swift in Sources */, BD11A015000000000000A011 /* PumpRateTables.swift in Sources */, @@ -7076,6 +7079,7 @@ 7A11AC0DE000000000000109 /* PumpEventFinalizationTests.swift in Sources */, 7A11AC0DE00000000000011D /* PumpEventPredicateTests.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/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 967a1b8115..a8fc3177a5 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -79,8 +79,9 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } let concentration = settingsManager.settings.insulinConcentration 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 { diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index c4c1a87604..3716ac3a21 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -565,7 +565,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/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/Helpers/Formatters.swift b/Trio/Sources/Helpers/Formatters.swift index d5f2f01ee1..f08d303104 100644 --- a/Trio/Sources/Helpers/Formatters.swift +++ b/Trio/Sources/Helpers/Formatters.swift @@ -110,14 +110,76 @@ 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) + } + + /// 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(_ 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", "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) + } + + /// 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] + } + 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(.largestTwoUnits, locale: locale) + } + + /// 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 = 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") + } + + 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\")." + ) + } } extension JSONDecoder.DateDecodingStrategy { @@ -137,3 +199,34 @@ 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) } + + /// 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) + } +} + +/// 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) } + 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/Helpers/TimeAgoFormatter.swift b/Trio/Sources/Helpers/TimeAgoFormatter.swift deleted file mode 100644 index 2fb654c1b9..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 "<" + "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..2323913e85 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" : { @@ -9500,6 +9504,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" : { @@ -11763,132 +11770,132 @@ } } }, - "%@%% Insulin" : { + "%@ 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" : "%@ 胰島素" } } } @@ -12032,6 +12039,7 @@ } }, "%1$d days %2$d hours" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12163,6 +12171,7 @@ } }, "%1$d hours %2$d minutes" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12425,6 +12434,7 @@ } }, "%d hours" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12556,6 +12566,7 @@ } }, "%d minutes" : { + "extractionState" : "stale", "comment" : "Accessibility: pod time", "localizations" : { "bg" : { @@ -12687,6 +12698,7 @@ } }, "%d minutes ago" : { + "extractionState" : "stale", "comment" : "Accessibility: elapsed time", "localizations" : { "bg" : { @@ -13341,6 +13353,7 @@ } }, "%lld %%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -14251,6 +14264,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -94468,6 +94482,7 @@ } }, "d" : { + "extractionState" : "stale", "comment" : "Abbreviation for Days\nabbreviation for days", "localizations" : { "bg" : { @@ -157537,6 +157552,7 @@ "isCommentAutoGenerated" : true }, "h" : { + "extractionState" : "stale", "comment" : "Abbreviation for Hours\nabbreviation for hours\nh", "localizations" : { "bg" : { @@ -164571,14 +164587,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 +172380,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" : { @@ -180825,6 +180845,9 @@ } } }, + "less than a minute" : { + "comment" : "Accessibility: a duration under one minute" + }, "less than a minute ago" : { "comment" : "Accessibility: elapsed time", "localizations" : { @@ -191899,6 +191922,7 @@ }, "m" : { "comment" : "Abbreviation for Minutes\nabbreviation for minutes", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -233864,6 +233888,7 @@ }, "percent" : { "comment" : "Accessibility: spoken unit", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -241114,132 +241139,132 @@ } } }, - "Profile at 100 %" : { + "Profile at %@" : { "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" : "設定檔 %@" } } } @@ -312218,6 +312243,7 @@ } }, "time unknown" : { + "extractionState" : "stale", "comment" : "Accessibility: elapsed time unavailable", "localizations" : { "bg" : { diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index 80db51d277..b9342245e3 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[.. "milligrams per deciliter per units". diff --git a/Trio/Sources/Modules/AdaptProfile/View/AdaptProfileRootView.swift b/Trio/Sources/Modules/AdaptProfile/View/AdaptProfileRootView.swift index 2b4d9ab2d6..aa378baa53 100644 --- a/Trio/Sources/Modules/AdaptProfile/View/AdaptProfileRootView.swift +++ b/Trio/Sources/Modules/AdaptProfile/View/AdaptProfileRootView.swift @@ -260,7 +260,7 @@ extension AdaptProfile { } if let expiresAt = item.expiresAt { let minutesLeft = max(0, Int(expiresAt.timeIntervalSinceNow / 60)) - Text(minutesLeft > 0 ? formatHrMin(minutesLeft) : String( + Text(minutesLeft > 0 ? Duration.seconds(TimeInterval(minutesLeft) * 60).formattedTimeSpan(.largestTwoUnits) : String( localized: "Expiring", comment: "Countdown label shown on the active-profile banner when less than a minute of a timed activation remains" )) diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Helpers.swift index dce49fb9f4..94204bcbd0 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. @@ -47,22 +45,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/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift index aa48ef8a61..14126b271e 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift @@ -137,7 +137,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, @@ -204,7 +204,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, @@ -230,7 +230,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/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index 708b3fd372..f44483470d 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -412,21 +412,6 @@ extension Adjustments { EmptyView() } } - - 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 218b6aca29..b15405de08 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AddOverrideForm.swift @@ -82,7 +82,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) @@ -95,7 +95,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, @@ -124,7 +124,7 @@ struct AddOverrideForm: View { )), id: \.self ) { percent in - Text("\(percent) %").tag(percent) + Text(percent.formatted(.percent)).tag(percent) } } .pickerStyle(WheelPickerStyle()) @@ -361,7 +361,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..fd5744ab96 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -155,13 +155,10 @@ 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 durationLabel: (text: String, spoken: String) = { + guard !indefinite else { return ("", "") } + let span = Duration(minutes: duration) + return (span.formattedTimeSpan(), span.formattedSpokenTimeSpan()) }() let scheduledSMBString: String = { @@ -171,19 +168,21 @@ extension Adjustments.RootView { let smbString: String = { guard preset.smbIsOff || preset.smbIsScheduledOff else { return "" } - return "SMBs Off\(scheduledSMBString)" + return String(localized: "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,22 +199,13 @@ 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] = [ - durationString, - percentageString, - targetString, - smbString, - maxSmbMinsString, - maxUamMinsString - ].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) { @@ -257,7 +247,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 340e9f1aca..ce197539f5 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -177,7 +177,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) @@ -190,7 +190,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) } @@ -214,7 +214,7 @@ struct EditOverrideForm: View { )), id: \.self ) { percent in - Text("\(Int(percent)) %").tag(percent) + Text((percent / 100).formatted(.wholePercent)).tag(percent) } } .pickerStyle(WheelPickerStyle()) @@ -484,7 +484,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 d51b6bfb07..2cbe0f8953 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -153,7 +153,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) @@ -165,9 +165,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( @@ -192,7 +192,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 @@ -347,11 +347,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..9ebbd13ad0 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -155,14 +155,13 @@ 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 duration = Duration(minutes: (tempTarget.duration ?? 0) as Decimal) let row = ZStack(alignment: .trailing) { HStack { @@ -171,7 +170,7 @@ extension Adjustments.RootView { Text(tempTarget.name ?? "") Spacer() if remainingTime > 0 { - Text("Starts in \(formattedTimeRemaining(remainingTime))") + Text("Starts in \(Duration.seconds(remainingTime).formattedTimeSpan(.largestTwoUnits))") .foregroundColor(colorScheme == .dark ? .orange : .accentColor) } } @@ -182,14 +181,11 @@ 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) { - Text(", \(percentage)%") + Text(", " + percentageText) .foregroundColor(.secondary) .font(.caption) } @@ -218,9 +214,8 @@ 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") - + (state.isAdjustSensEnabled(usingTarget: tempTargetValue) ? ", \(percentage)%" : "") + + duration.formattedSpokenTimeSpan() + + (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 347460b768..5a1a9b254b 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -217,7 +217,7 @@ struct EditTempTargetForm: View { } } - Text("\(formattedPercentage(percentage))% Insulin") + Text("\((percentage / 100).formatted(.wholePercent)) Insulin") .foregroundColor(isUsingSlider ? .orange : Color.tabBar) .font(.title3) .fontWeight(.bold) @@ -243,10 +243,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) } @@ -266,7 +266,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) @@ -339,7 +339,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 } @@ -424,16 +424,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 e55b27a8da..4ae303240c 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.scope.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(.upToTwoDecimalPercent)) }.padding(.vertical) HStack { Text("Calculated Sensitivity") diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index 3f6bef9f54..d736bbbb29 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift @@ -30,7 +30,7 @@ extension BasalProfileEditor { let syncValues = profile.map { RepeatingScheduleValue( startTime: TimeInterval($0.minutes * 60), - value: Double($0.rate) / Double(settingsManager.settings.insulinConcentration) + value: (Double($0.rate) / Double(settingsManager.settings.insulinConcentration)).roundedDouble(toPlaces: 3) ) } diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift index 9c73e89570..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.firstIndex(of: therapyItem.value) ?? 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/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 068b8ac119..873a315882 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -919,7 +919,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/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index 0ac7839c4a..61a27f343c 100644 --- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -206,7 +206,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 @@ -218,7 +218,7 @@ 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 icon overlay is a child that the ignore above would otherwise drop. if let tag = sensorLifecycleAccessibilityText, !trendCollidesWithTag, !tag.isEmpty { @@ -240,11 +240,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) @@ -401,7 +400,7 @@ struct CurrentGlucoseView: View { guard let endsAt = cgmWarmupEndsAt else { return String(localized: "warming up", comment: "Accessibility: sensor lifecycle tag") } - return SensorRemainingTimeFormatter.format(until: endsAt) + return Duration.seconds(endsAt.timeIntervalSinceNow).formattedSpokenTimeSpan(.largestTwoUnits) } if isStabilizing { return String(localized: "stabilizing", comment: "Accessibility: sensor lifecycle tag") @@ -411,7 +410,7 @@ struct CurrentGlucoseView: View { } guard shouldShowArc else { return nil } if let expiresAt = cgmSensorExpiresAt { - return SensorRemainingTimeFormatter.format(until: expiresAt) + return Duration.seconds(expiresAt.timeIntervalSinceNow).formattedSpokenTimeSpan(.largestTwoUnits) } if let status = cgmStatus { return status.localizedMessage.replacingOccurrences(of: "\n", with: " ") diff --git a/Trio/Sources/Modules/Home/View/Header/LoopView.swift b/Trio/Sources/Modules/Home/View/Header/LoopView.swift index 1d245ab89d..ca95a39353 100644 --- a/Trio/Sources/Modules/Home/View/Header/LoopView.swift +++ b/Trio/Sources/Modules/Home/View/Header/LoopView.swift @@ -141,13 +141,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 = "" @@ -159,12 +159,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..0dd3ccdbaa 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) @@ -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(.largestTwoUnits) } - /// 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(.largestTwoUnits) } /// Reservoir amount plus a spoken severity word, so the color-coded low state is not lost. @@ -210,8 +171,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/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 11880b7123..f7f9cc4aa5 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -8,7 +8,7 @@ extension Home.RootView { Formatter.bolusProgressFormatter(for: state.bolusIncrement) } - var overrideString: String? { + var overrideString: (text: String, spoken: String)? { guard let latestOverride = latestOverride.first else { return nil } @@ -18,7 +18,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 @@ -28,22 +28,17 @@ 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 durationLabel = (text: "", spoken: "") if !indefinite { - if newDuration >= 1 { - durationString = formatHrMin(Int(newDuration)) - } else if newDuration > 0 { - durationString = "\(Int(newDuration * 60)) s" - + if remaining > .zero { + durationLabel = ( + remaining.formattedTimeSpan(.largestTwoUnits), + remaining.formattedSpokenTimeSpan(.largestTwoUnits) + ) } else { /// Do not show the Override anymore Task { @@ -64,42 +59,49 @@ extension Home.RootView { comment: "Override subtitle fragment on Home showing that SMBs are disabled — interpolated value is an optional time-range like ' 08:00-10:00'" ) : "" - 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 components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString] + let otherComponents = [percentString, targetString, smbToggleString] + let components = ([durationLabel.text] + otherComponents + [smbMinuteLabel.text, uamMinuteLabel.text]) .filter { !$0.isEmpty } - return components.isEmpty ? nil : components.joined(separator: ", ") + 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: ", ")) } - 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) @@ -113,17 +115,18 @@ extension Home.RootView { if target > 100, 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 - 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(.largestTwoUnits) + spokenDurationString = remaining.formattedSpokenTimeSpan(.largestTwoUnits) } else { /// Do not show the Temp Target anymore Task { @@ -133,7 +136,10 @@ 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: ", ")) } /// Zone E: one fixed slot. The multi-use panel shows the highest-priority @@ -464,7 +470,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 { @@ -475,8 +481,8 @@ 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)), (.orange, CGFloat(stats.lowPct / 100)), @@ -566,7 +572,7 @@ extension Home.RootView { .buttonStyle(.plain) } - @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View { + @ViewBuilder func adjustmentsOverrideView(_ overrideString: (text: String, spoken: String)) -> some View { HStack { Image(systemName: "clock.arrow.2.circlepath") .font(.title2) @@ -580,9 +586,10 @@ extension Home.RootView { .font(.subheadline) .frame(alignment: .leading) - Text(overrideString) + Text(overrideString.text) .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel(overrideString.spoken) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -593,7 +600,7 @@ extension Home.RootView { } } - @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View { + @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: (text: String, spoken: String)) -> some View { HStack { let targetValue = latestTempTarget.first?.target?.doubleValue ?? 0.0 let rotationValue: Double = targetValue < 100 ? 180 : 0 @@ -609,9 +616,10 @@ extension Home.RootView { )) .font(.subheadline) .frame(alignment: .leading) - Text(tempTargetString) + Text(tempTargetString.text) .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel(tempTargetString.spoken) .frame(alignment: .leading) } } @@ -681,7 +689,7 @@ extension Home.RootView { var parts: [String] = [] if let expires = profile.expiresAt { let minutesLeft = Int(expires.timeIntervalSince(now) / 60) - let countdown = minutesLeft > 0 ? formatHrMin(minutesLeft) : String( + let countdown = minutesLeft > 0 ? Duration.seconds(TimeInterval(minutesLeft) * 60).formattedTimeSpan(.largestTwoUnits) : String( localized: "Expiring", comment: "Countdown text on Home profile banner when less than a minute of a timed activation remains" ) @@ -813,7 +821,7 @@ extension Home.RootView { Text("No Active Adjustment") .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) - Text("Profile at 100 %") + Text("Profile at \(1.0.formatted(.wholePercent))") .font(.caption) .frame(maxWidth: .infinity, alignment: .leading) }.padding(.leading, 10) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index e5f0857713..d2e9c0be9d 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -902,22 +902,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/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 2a911a6232..9eecd0a1ce 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/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/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 5b00c9fc3f..1214b1eadf 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -59,6 +59,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { private var tidepoolService: RemoteDataService? { didSet { if let tidepoolService = tidepoolService { + tidepoolService.remoteDataServiceDelegate = self rawTidepoolManager = tidepoolService.rawValue } else { rawTidepoolManager = nil @@ -225,6 +226,24 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { } } +extension BaseTidepoolManager: RemoteDataServiceDelegate { + /// Device logs are not uploaded. + func fetchDeviceLogs(startDate _: Date, endDate _: Date) async throws -> [StoredDeviceLogEntry] { + [] + } + + /// 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] { + [] + } + + 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/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/Trio/Sources/Views/SettingInputSection.swift b/Trio/Sources/Views/SettingInputSection.swift index 7a47cd2729..a908cb8fc7 100644 --- a/Trio/Sources/Views/SettingInputSection.swift +++ b/Trio/Sources/Views/SettingInputSection.swift @@ -159,7 +159,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 .factorRaw: return "\(decimalValue)" case .insulinUnit: @@ -183,7 +183,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 .factorRaw: return "\(decimalValue)" case .insulinUnit: 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 + ) + } +} diff --git a/TrioTests/DurationFormattingTests.swift b/TrioTests/DurationFormattingTests.swift new file mode 100644 index 0000000000..d5c5f2ffd8 --- /dev/null +++ b/TrioTests/DurationFormattingTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Duration Formatting") struct DurationFormattingTests { + private let english = Locale(identifier: "en_US") + + @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(style, locale: english) == expected) + #expect(duration.formattedSpokenTimeSpan(style, locale: english) == expectedSpoken) + } + + @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"), + (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("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() { + #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)) + } +} diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 98a7b5cb11..32a3b83148 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -257,6 +257,25 @@ import Testing #expect(all.contains("Trio [Service] upload to /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) } 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)) + } + } + } +} diff --git a/TrioTests/PercentFormattingTests.swift b/TrioTests/PercentFormattingTests.swift new file mode 100644 index 0000000000..f260171292 --- /dev/null +++ b/TrioTests/PercentFormattingTests.swift @@ -0,0 +1,43 @@ +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) + } + + @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, + _ 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) + } +} diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift index 4c8696733b..6eabe5b2db 100644 --- a/TrioTests/TidepoolTherapySettingsTests.swift +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -558,3 +558,62 @@ private extension StoredSettings { ) } } + +// MARK: - Dose annotation + +@Suite("Tidepool dose annotation Tests") struct TidepoolDoseAnnotationTests { + private static let start = Date(timeIntervalSince1970: 1_800_000_000) + + 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) + } +}