diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 3c96457524..fe9cda4d1b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -447,7 +447,6 @@ C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */; }; C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; - C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D5D286778D000500CF8 /* LoopKitUI.framework */; }; C11B9D62286779C000500CF8 /* MockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D60286779C000500CF8 /* MockKit.framework */; }; @@ -1274,7 +1273,6 @@ C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+GlucoseDisplayUnit.swift"; sourceTree = ""; }; C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveOverrideView.swift; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; - C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; C11AA5C7258736CF00BDE12F /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; C11B9D5D286778D000500CF8 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C11B9D60286779C000500CF8 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2401,7 +2399,6 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( - C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */, 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */, @@ -3472,7 +3469,6 @@ A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, - C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */, @@ -4275,7 +4271,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -4387,7 +4382,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -4808,7 +4802,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 5ff2e689bc..21fa1e2dee 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -283,8 +283,10 @@ extension AlertPermissionsChecker { message: alert.alertBody, preferredStyle: .alert) let titleImageAttachment = NSTextAttachment() - titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical, renderingMode: .alwaysOriginal) - titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) + let iconSize = CGSize(width: 40, height: 35) + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")? + .tintedForTextAttachment(.critical, size: iconSize) + titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: iconSize.width, height: iconSize.height) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) titleWithImage.append(NSMutableAttributedString(string: alert.alertTitle, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift index b72a4fd031..a57b52d970 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -26,6 +26,8 @@ class StatusChartsManager: ChartsManager { let iob: IOBChart let cob: COBChart + fileprivate var lastPredictedGlucoseValues: [GlucoseValue] = [] + init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { let glucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) @@ -59,6 +61,13 @@ extension StatusChartsManager { } func setPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) { + // Skip invalidating the chart when the prediction is unchanged to avoid needless redraws + if glucoseValues.count == lastPredictedGlucoseValues.count, + zip(glucoseValues, lastPredictedGlucoseValues).allSatisfy({ $0.startDate == $1.startDate && $0.quantity == $1.quantity }) + { + return + } + lastPredictedGlucoseValues = glucoseValues glucose.setPredictedGlucoseValues(glucoseValues) invalidateChart(atIndex: ChartIndex.glucose.rawValue) } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index f41af52d7c..3e920170ef 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -33,7 +33,7 @@ final class StatusTableViewController: LoopChartsTableViewController { lazy var insulinFormatter: QuantityFormatter = { let formatter = QuantityFormatter(for: .internationalUnit) - formatter.numberFormatter.maximumFractionDigits = 3 + formatter.numberFormatter.maximumFractionDigits = 2 return formatter }() diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 7612de1478..b9926c57ea 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -308,7 +308,11 @@ class LoopStatusModalViewModel { guard let ago, let timeString = ago.truncatedTimeAgoString else { return nil } - - return NSLocalizedString("\(timeString) ago", comment: "last loop completed string") + + if ago > .hours(1) { + return String(format: NSLocalizedString(">%@ ago", comment: "last loop completed string with greater than sign. (1: truncated time ago)"), timeString) + } else { + return String(format: NSLocalizedString("%@ ago", comment: "last loop completed string. (1: truncated time ago)"), timeString) + } } } diff --git a/Loop/Views/Presets/CorrectionRangeInformationView.swift b/Loop/Views/Presets/CorrectionRangeInformationView.swift deleted file mode 100644 index c389992dfa..0000000000 --- a/Loop/Views/Presets/CorrectionRangeInformationView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// InsulinScaleInformationView.swift -// Loop -// -// Created by Pete Schwamb on 2/25/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - - -import SwiftUI - -struct CorrectionRangeInformationView: View { - @State private var insulinPercentage: Double = 100 - @Environment(\.dismiss) private var dismiss - @Environment(\.appName) private var appName - - var body: some View { - VStack(spacing: 0) { - // Close button - VStack { - Button("Close") { - dismiss() - } - .font(.title3) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding() - } - .background(Color(.systemBackground)) - - // Header - VStack(alignment: .leading) { - Text("Correction Range") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.vertical) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .background(Color(.systemBackground)) - - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Description Text - (Text("Correction range is a ") + Text("safety").fontWeight(.semibold) + Text(" setting. Adjusting it can help reduce the risk of low glucose if you expect unusual fluctuations.")) - .padding(.top) - Text(String(format: NSLocalizedString("Set the glucose value (or values) you want %1$@ to aim for in adjusting your basal insulin.", comment: "Correction range description (1: app name)"), appName)) - Text("You do not have to set a new correction range for each preset, but before deciding to adjust your correction range, ") + - Text("ask yourself, am I more likely to go high or low during this event?") - .fontWeight(.semibold) - - // Tip section - CorrectionRangeTipSection() - } - .padding() - } - } - } -} - -struct CorrectionRangeTipSection: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "lightbulb.fill") - .foregroundColor(.blue) - - Text("Tip") - .font(.headline) - .foregroundColor(.blue) - } - .padding(.bottom, 4) - - Text("To help avoid lows, set a range higher than your typical correction range.") - } - .padding() - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) - } -} - -struct CorrectionRangeInformationView_Previews: PreviewProvider { - static var previews: some View { - CorrectionRangeInformationView() - } -} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index e31efd6219..f3c28115a8 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -49,6 +49,11 @@ enum ActiveSheet: Identifiable { struct PresetsView: View { + // Define navigation routes + enum NavigationDestination: Hashable { + case presetsHistory + } + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.appName) private var appName @Environment(\.settingsManager) private var settingsManager @@ -182,13 +187,33 @@ struct PresetsView: View { } } - if trainingCompletion.isComplete { - // Support Section - VStack(alignment: .leading, spacing: 16) { - Text("Support") - .font(.headline.weight(.semibold)) - .padding(.horizontal, 10) - + // Support Section + VStack(alignment: .leading, spacing: 16) { + Text("Support") + .font(.headline.weight(.semibold)) + .padding(.horizontal, 10) + + NavigationLink(value: NavigationDestination.presetsHistory) { + HStack { + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + Text("Performance History") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + } + .padding(10) + .foregroundStyle(.primary) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + + if trainingCompletion.isComplete { Button { activeSheet = .training() } label: { @@ -197,7 +222,7 @@ struct PresetsView: View { .resizable() .scaledToFit() .frame(width: 32, height: 32) - + Text("Learning Hub") Spacer() Image(systemName: "chevron.right") @@ -219,6 +244,18 @@ struct PresetsView: View { .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) + .navigationDestination(for: NavigationDestination.self) { route in + switch route { + case .presetsHistory: + PresetsHistoryView( + temporaryPresetsManager: temporaryPresetsManager, + glucoseStore: glucoseStore, + carbStore: carbStore, + doseStore: doseStore, + automationHistory: automationHistory + ) + } + } } .sheet(item: $activeSheet) { sheet in switch sheet { diff --git a/LoopTests/Models/TempBasalRecommendationTests.swift b/LoopTests/Models/TempBasalRecommendationTests.swift index 8c0c7ab1f4..51366a5fcc 100644 --- a/LoopTests/Models/TempBasalRecommendationTests.swift +++ b/LoopTests/Models/TempBasalRecommendationTests.swift @@ -7,6 +7,7 @@ // import XCTest +import LoopKit import LoopAlgorithm @testable import Loop @@ -23,4 +24,190 @@ class TempBasalRecommendationTests: XCTestCase { XCTAssertEqual(tempBasalRecommendation.unitsPerHour, 1.23) XCTAssertEqual(tempBasalRecommendation.duration, 4.56) } + + // MARK: - adjustForCurrentDelivery + + private let now = Date(timeIntervalSinceReferenceDate: 700_000_000) + private let continuationInterval: TimeInterval = .minutes(11) + + private func activeTemp(rate: Double, startedAgo: TimeInterval = .minutes(5), endingIn: TimeInterval) -> DoseEntry { + DoseEntry( + type: .tempBasal, + startDate: now.addingTimeInterval(-startedAgo), + endDate: now.addingTimeInterval(endingIn), + value: rate, + unit: .unitsPerHour, + decisionId: nil + ) + } + + // No current temp basal + + func testAdjustForCurrentDelivery_NoCurrentTemp_RecMatchesSchedule_PumpMatches_ReturnsNil() { + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: nil, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertNil(result) + } + + func testAdjustForCurrentDelivery_NoCurrentTemp_RecMatchesSchedule_OverrideActive_ReturnsSelf() { + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: nil, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: false + ) + XCTAssertEqual(result, rec) + } + + func testAdjustForCurrentDelivery_NoCurrentTemp_RecDiffersFromSchedule_ReturnsSelf() { + let rec = TempBasalRecommendation(unitsPerHour: 1.5, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: nil, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, rec) + } + + // Current dose is not an active temp basal + + func testAdjustForCurrentDelivery_CurrentDoseIsScheduledBasal_TreatedAsNoTemp() { + let scheduled = DoseEntry( + type: .basal, + startDate: now.addingTimeInterval(-.minutes(5)), + endDate: now.addingTimeInterval(.minutes(25)), + value: 1.0, + unit: .unitsPerHour, + decisionId: nil + ) + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: scheduled, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertNil(result) + } + + func testAdjustForCurrentDelivery_ExpiredTempBasal_TreatedAsNoTemp() { + let expired = activeTemp(rate: 1.5, startedAgo: .minutes(30), endingIn: -.minutes(1)) + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: expired, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertNil(result) + } + + // Active temp basal + + func testAdjustForCurrentDelivery_ActiveTemp_SameRate_PlentyOfTimeLeft_ReturnsNil() { + let active = activeTemp(rate: 1.5, endingIn: .minutes(25)) + let rec = TempBasalRecommendation(unitsPerHour: 1.5, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertNil(result) + } + + func testAdjustForCurrentDelivery_ActiveTemp_SameRate_NearingExpiry_RecMatchesScheduleAndPump_ReturnsCancel() { + // Temp running at the schedule rate (unusual but possible), about to expire — should cancel. + let active = activeTemp(rate: 1.0, endingIn: .minutes(5)) + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, .cancel) + } + + func testAdjustForCurrentDelivery_ActiveTemp_SameRate_NearingExpiry_RecDiffersFromSchedule_ReturnsSelf() { + let active = activeTemp(rate: 1.5, endingIn: .minutes(5)) + let rec = TempBasalRecommendation(unitsPerHour: 1.5, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, rec) + } + + func testAdjustForCurrentDelivery_ActiveTemp_DifferentRate_RecMatchesScheduleAndPump_ReturnsCancel() { + let active = activeTemp(rate: 1.5, endingIn: .minutes(25)) + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, .cancel) + } + + func testAdjustForCurrentDelivery_ActiveTemp_DifferentRate_RecMatchesSchedule_OverrideActive_ReturnsSelf() { + // Override changes the pump's schedule, so even though the rec equals neutralBasalRate, + // we can't trust that the pump's scheduled basal will deliver that rate — set a new temp. + let active = activeTemp(rate: 1.5, endingIn: .minutes(25)) + let rec = TempBasalRecommendation(unitsPerHour: 1.0, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: false + ) + XCTAssertEqual(result, rec) + } + + func testAdjustForCurrentDelivery_ActiveTemp_DifferentRate_RecDiffersFromSchedule_ReturnsSelf() { + let active = activeTemp(rate: 1.5, endingIn: .minutes(25)) + let rec = TempBasalRecommendation(unitsPerHour: 0.5, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, rec) + } + + func testAdjustForCurrentDelivery_ActiveTemp_RemainingTimeAtBoundary_DoesNotContinue() { + // continuationInterval is exclusive: remaining == continuationInterval should not continue. + let active = activeTemp(rate: 1.5, endingIn: continuationInterval) + let rec = TempBasalRecommendation(unitsPerHour: 1.5, duration: .minutes(30)) + let result = rec.adjustForCurrentDelivery( + at: now, + neutralBasalRate: 1.0, + currentTempBasal: active, + continuationInterval: continuationInterval, + neutralBasalRateMatchesPump: true + ) + XCTAssertEqual(result, rec) + } }