From 1722a24bed23976b418aee896c0530188e46f258 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 11 May 2026 16:26:52 -0300 Subject: [PATCH 01/20] [LOOP-5903] only display 2 decimal places (#928) --- Loop/View Controllers/StatusTableViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7b0814786a..762b53f7fa 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 }() From 4861ed4b9f03e5b642134fded2f80b4c3644a1f5 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 12 May 2026 10:08:55 -0700 Subject: [PATCH 02/20] [LOOP-5874 & LOOP-5769] Remove Unused CorrectionRangeInformationView & Fix LoopStatusModal Time Ago (#929) * [LOOP-5874] Remove Unused CorrectionRangeInformationView * [LOOP-5769] Fix Loop Status Modal Time Ago --- Loop.xcodeproj/project.pbxproj | 4 - Loop/Views/LoopStatusModalView.swift | 8 +- .../CorrectionRangeInformationView.swift | 86 ------------------- 3 files changed, 6 insertions(+), 92 deletions(-) delete mode 100644 Loop/Views/Presets/CorrectionRangeInformationView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1df8e44c89..f0cc7e1747 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -417,7 +417,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 */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; @@ -1362,7 +1361,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 = ""; }; C11613482983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C116134B2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C116134D2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -2539,7 +2537,6 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( - C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */, 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */, @@ -3574,7 +3571,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 */, diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 57d0c7d497..66cddd242d 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -299,7 +299,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 ea20e70ec0..0000000000 --- a/Loop/Views/Presets/CorrectionRangeInformationView.swift +++ /dev/null @@ -1,86 +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 - - 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("Set the glucose value (or values) you want Tidepool Loop to aim for in adjusting your basal insulin.") - 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() - } -} From 86e14502bdeee2d0f68fde15dbc92be08df600d1 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 13 May 2026 13:19:44 -0700 Subject: [PATCH 03/20] [LOOP-5733] Update Watch Last Loop When Disconnected (#930) --- WatchApp Extension/Views/ChartPageView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index 188cedec38..eacc18c6fb 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -11,6 +11,7 @@ import LoopKit import LoopCore import LoopAlgorithm import SpriteKit +import WatchConnectivity struct ChartPageView: View { @Environment(\.sizeClass) private var sizeClass @@ -30,6 +31,10 @@ struct ChartPageView: View { return nil } + guard WCSession.default.isReachable else { + return "- - -" + } + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) guard let timeString = ago.truncatedTimeAgoString else { @@ -180,8 +185,7 @@ struct ChartPageView: View { if let lastSyncString { LabelValueRow("Last Loop") { // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once watchOS 10 is dropped as a supported platform. - Text(Image(systemName: "arrow.triangle.2.circlepath")) + - Text(" " + lastSyncString) + (WCSession.default.isReachable ? Text("\(Image(systemName: "arrow.triangle.2.circlepath")) ") : Text("")) + Text(lastSyncString) } Divider() } From c1d901238e94ad854bc64a7209f72806aafe2cbb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 1 Jun 2026 19:21:41 -0500 Subject: [PATCH 04/20] Bump minimum iOS version to 18.6 (#931) --- Loop.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f0cc7e1747..0cbc71362e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4894,6 +4894,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4922,6 +4923,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5298,6 +5300,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 3650aa7fc8b1542c9caced4b82a591fe2810fab3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 2 Jun 2026 09:13:08 -0500 Subject: [PATCH 05/20] LOOP-5926 Remove IPHONEOS_DEPLOYMENT_TARGET from project (#932) * Remove IPHONEOS_DEPLOYMENT_TARGET from project; will be set at LoopWorkspace scope via xcconfig * Set default IOS deployement target --- Loop.xcconfig | 2 +- Loop.xcodeproj/project.pbxproj | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Loop.xcconfig b/Loop.xcconfig index 1d00650196..f99752eb6c 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -32,7 +32,7 @@ LOOP_ENTITLEMENTS = Loop/Loop.entitlements LOOP_DEVELOPMENT_TEAM = // Min iOS Version [DEFAULT] -IPHONEOS_DEPLOYMENT_TARGET = 15.1 +IPHONEOS_DEPLOYMENT_TARGET = 18.6 // Base string for opening app via URL [DEFAULT] URL_SCHEME_NAME = $(MAIN_APP_DISPLAY_NAME) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0cbc71362e..a3936a53ef 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4748,7 +4748,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -4859,7 +4858,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -4894,7 +4892,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4923,7 +4920,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5265,7 +5261,6 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -5300,7 +5295,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From ada242489014c23ee40249c35fa71e56440febf8 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 2 Jun 2026 09:10:01 -0700 Subject: [PATCH 06/20] [LOOP-5769] Temporarily revert LOOP-5769 (#933) --- Loop/Views/LoopStatusModalView.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 66cddd242d..5b8927f558 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -300,10 +300,15 @@ class LoopStatusModalViewModel { let timeString = ago.truncatedTimeAgoString else { return nil } - 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) - } + + // CI (June 2, 2026): Replace the below line with commented code below it after 510k submission is complete and build hold is lifted. + + 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) +// } } } From 3dc800c8e85a3c544c1ffe791dca426aa651da9b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 2 Jun 2026 11:10:16 -0500 Subject: [PATCH 07/20] Add tests for basal reissue logic (#934) --- .../Models/TempBasalRecommendationTests.swift | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) 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) + } } From 35f8ad9c3d0d7a4b46233fc936130d7505397835 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jun 2026 10:05:13 -0700 Subject: [PATCH 08/20] [LOOP-5769] Re-apply bug fix for status modal sync (#935) --- Loop/Views/LoopStatusModalView.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 5b8927f558..66cddd242d 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -300,15 +300,10 @@ class LoopStatusModalViewModel { let timeString = ago.truncatedTimeAgoString else { return nil } - - // CI (June 2, 2026): Replace the below line with commented code below it after 510k submission is complete and build hold is lifted. - - 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) -// } + 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) + } } } From a7e7c02e7a920ea94b36817eb92179b802f7b39f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jun 2026 10:06:06 -0700 Subject: [PATCH 09/20] iOS 27 & Toolbar fix (#937) * Working commit * Updates/Fixes for iOS 27 & Liquid Glass Toolbar * Update Info.plist * Update Info.plist --- Loop.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/xcschemes/WatchApp.xcscheme | 2 - Loop/AppDelegate.swift | 65 +---- Loop/Extensions/Image+Optional.swift | 20 -- Loop/Info.plist | 23 +- Loop/Managers/LoopAppManager.swift | 4 + Loop/SceneDelegate.swift | 106 +++++++ .../StatusTableViewController.swift | 7 +- Loop/Views/IOSFocusModesView.swift | 4 +- Loop/Views/StatusTableView.swift | 258 ++++++++++-------- 10 files changed, 297 insertions(+), 200 deletions(-) delete mode 100644 Loop/Extensions/Image+Optional.swift create mode 100644 Loop/SceneDelegate.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index a3936a53ef..4b7c4bf96f 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -240,7 +240,6 @@ 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; - 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; }; @@ -260,6 +259,7 @@ 84DF48BF2F6A0AE500BEDB40 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */; }; 84DF48C12F6A0AED00BEDB40 /* Double+Closest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */; }; 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */; }; + 84E0EB0B2FD8063100D6FBB6 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E0EB0A2FD8063100D6FBB6 /* SceneDelegate.swift */; }; 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; @@ -1134,7 +1134,6 @@ 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsPerformanceHistoryViewModel.swift; sourceTree = ""; }; 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; - 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = ""; }; @@ -1154,6 +1153,7 @@ 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Closest.swift"; sourceTree = ""; }; 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Crop.swift"; sourceTree = ""; }; + 84E0EB0A2FD8063100D6FBB6 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; @@ -1963,6 +1963,7 @@ 43F5C2CE1B92A2A0003EB13D /* View Controllers */, 43F5C2CF1B92A2ED003EB13D /* Views */, 897A5A9724C22DCE00C4E71D /* View Models */, + 84E0EB0A2FD8063100D6FBB6 /* SceneDelegate.swift */, ); path = Loop; sourceTree = ""; @@ -2161,7 +2162,6 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */, 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */, A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */, - 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */, ); path = Extensions; sourceTree = ""; @@ -3700,6 +3700,7 @@ 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, + 84E0EB0B2FD8063100D6FBB6 /* SceneDelegate.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, 841306BB2F7F0D9C00AF0320 /* ReferencesView.swift in Sources */, @@ -3728,7 +3729,6 @@ 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, - 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme index 4d7e977e53..d36ffa7ea7 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme @@ -84,7 +84,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "43A943711B926B7B0051FA24" BuildableName = "WatchApp.app" - BlueprintName = "WatchApp" ReferencedContainer = "container:Loop.xcodeproj"> @@ -102,7 +101,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "43A943711B926B7B0051FA24" BuildableName = "WatchApp.app" - BlueprintName = "WatchApp" ReferencedContainer = "container:Loop.xcodeproj"> diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index a707eb1a61..bb2ba76f61 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -9,10 +9,17 @@ import UIKit import LoopKit -final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { - var window: UIWindow? +final class AppDelegate: UIResponder, UIApplicationDelegate { + + /// The shared app manager. Owned by the app delegate so that application-level + /// callbacks (remote notifications, protected data) can reach it, while the + /// window-tied lifecycle is driven by `SceneDelegate`. + let loopAppManager = LoopAppManager() + + /// Launch options captured at process launch, forwarded to `loopAppManager` + /// when the scene connects (the window does not yet exist at launch time). + private(set) var launchOptions: [UIApplication.LaunchOptionsKey: Any]? - private let loopAppManager = LoopAppManager() private let log = DiagnosticLog(category: "AppDelegate") // MARK: - UIApplicationDelegate - Initialization @@ -22,40 +29,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { setenv("CFNETWORK_DIAGNOSTICS", "3", 1) - // Avoid doing full initialization when running tests - if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { - loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) - loopAppManager.launch() - return loopAppManager.isLaunchComplete - } else { - return true - } - } - - // MARK: - UIApplicationDelegate - Life Cycle - - func applicationDidBecomeActive(_ application: UIApplication) { - log.default(#function) + // The window is created and owned by the scene, so initialization of the + // app manager is deferred to `SceneDelegate.scene(_:willConnectTo:options:)`. + // Stash the launch options so the scene can forward them. + self.launchOptions = launchOptions - loopAppManager.didBecomeActive() - } - - func applicationWillResignActive(_ application: UIApplication) { - log.default(#function) - } - - func applicationDidEnterBackground(_ application: UIApplication) { - log.default(#function) - } - - func applicationWillEnterForeground(_ application: UIApplication) { - log.default(#function) - - loopAppManager.askUserToConfirmLoopReset() - } - - func applicationWillTerminate(_ application: UIApplication) { - log.default(#function) + return true } // MARK: - UIApplicationDelegate - Environment @@ -86,20 +65,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { completionHandler(loopAppManager.handleRemoteNotification(userInfo as? [String: AnyObject]) ? .noData : .failed) } - - // MARK: - UIApplicationDelegate - Deeplinking - - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - loopAppManager.handle(url) - } - - // MARK: - UIApplicationDelegate - Continuity - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - log.default(#function) - - return loopAppManager.userActivity(userActivity, restorationHandler: restorationHandler) - } // MARK: - UIApplicationDelegate - Interface diff --git a/Loop/Extensions/Image+Optional.swift b/Loop/Extensions/Image+Optional.swift deleted file mode 100644 index 775196265e..0000000000 --- a/Loop/Extensions/Image+Optional.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Image+Optional.swift -// Loop -// -// Created by Cameron Ingham on 1/7/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -// Since this `Image` initializer provides a view even if the asset is not found in the bundle, it can double the spacing between adjacent elements in a `VStack`, `HStack`, etc. -extension Image { - init?(_ name: String, bundle: Bundle? = nil) { - if let _ = UIImage(named: name, in: bundle, with: nil) { - self = Image(name, bundle: bundle) - } else { - return nil - } - } -} diff --git a/Loop/Info.plist b/Loop/Info.plist index 41d479164f..4658639b98 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -78,18 +78,33 @@ NewCarbEntryIntent ViewLoopStatus + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIBackgroundModes bluetooth-central processing remote-notification - UIDesignRequiresCompatibility - UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index a2b8d94549..e07d9390a3 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -161,6 +161,10 @@ class LoopAppManager: NSObject { var isLaunchComplete: Bool { state == .launchComplete } + /// True until `initialize(windowProvider:launchOptions:)` has been called. Used by + /// `SceneDelegate` to ensure the app manager is initialized only once per process. + var isInInitialState: Bool { state == .initialize } + private func resumeLaunch() async { if state == .checkProtectedDataAvailable { checkProtectedDataAvailable() diff --git a/Loop/SceneDelegate.swift b/Loop/SceneDelegate.swift new file mode 100644 index 0000000000..5d8989848d --- /dev/null +++ b/Loop/SceneDelegate.swift @@ -0,0 +1,106 @@ +// +// SceneDelegate.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit + +/// Drives the window-tied lifecycle for the app under the UIScene life cycle. +/// +/// The window is created automatically by UIKit from `Main.storyboard` (declared +/// via `UISceneStoryboardFile` in the scene manifest) and assigned to `window` +/// before `scene(_:willConnectTo:options:)` is called. The shared `LoopAppManager` +/// continues to be owned by `AppDelegate` so that application-level callbacks +/// (remote notifications, protected data) can reach it. +final class SceneDelegate: UIResponder, UIWindowSceneDelegate, WindowProvider { + + var window: UIWindow? + + private let log = DiagnosticLog(category: "SceneDelegate") + + private var loopAppManager: LoopAppManager? { + (UIApplication.shared.delegate as? AppDelegate)?.loopAppManager + } + + // MARK: - UIWindowSceneDelegate - Connection + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + log.default(#function) + + // Avoid doing full initialization when running tests. + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return + } + + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + let loopAppManager = appDelegate.loopAppManager + + // The app manager only initializes once per process. Multiple scenes are not + // supported, so guard against a scene reconnecting after initialization. + guard loopAppManager.isInInitialState else { + return + } + + loopAppManager.initialize(windowProvider: self, launchOptions: appDelegate.launchOptions) + loopAppManager.launch() + + // Handle any URLs or user activities delivered at connection time. + if let url = connectionOptions.urlContexts.first?.url { + _ = loopAppManager.handle(url) + } + for userActivity in connectionOptions.userActivities { + _ = loopAppManager.userActivity(userActivity, restorationHandler: { _ in }) + } + } + + // MARK: - UIWindowSceneDelegate - Life Cycle + + func sceneDidBecomeActive(_ scene: UIScene) { + log.default(#function) + + loopAppManager?.didBecomeActive() + } + + func sceneWillResignActive(_ scene: UIScene) { + log.default(#function) + } + + func sceneWillEnterForeground(_ scene: UIScene) { + log.default(#function) + + // Unlike the legacy `applicationWillEnterForeground(_:)`, this is also called as + // part of cold launch, before the managers are initialized. `resumeLaunch()` + // performs this check itself once launch completes, so here it only applies to + // subsequent foreground transitions. + guard let loopAppManager, loopAppManager.isLaunchComplete else { + return + } + loopAppManager.askUserToConfirmLoopReset() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + log.default(#function) + } + + // MARK: - UIWindowSceneDelegate - Deeplinking + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let url = URLContexts.first?.url else { + return + } + _ = loopAppManager?.handle(url) + } + + // MARK: - UIWindowSceneDelegate - Continuity + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + log.default(#function) + + _ = loopAppManager?.userActivity(userActivity, restorationHandler: { _ in }) + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 762b53f7fa..2b52cb358f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -227,7 +227,7 @@ final class StatusTableViewController: LoopChartsTableViewController { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) - navigationController?.setToolbarHidden(false, animated: animated) + navigationController?.setToolbarHidden(true, animated: animated) alertPermissionsChecker.checkNow() @@ -643,7 +643,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let statusRowMode = self.determineStatusRowMode() updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: UIDevice.current.orientation.isLandscape ? 0 : 52, right: 0) + redrawCharts() reloading = false @@ -1208,7 +1209,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // Compute the height of the HUD, defaulting to 70 let hudHeight = ceil(hudView?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 74) var availableSize = max(tableView.bounds.width, tableView.bounds.height) - availableSize -= (tableView.safeAreaInsets.top + tableView.safeAreaInsets.bottom + hudHeight) + availableSize -= (tableView.safeAreaInsets.top + tableView.safeAreaInsets.bottom + tableView.contentInset.bottom + hudHeight) switch ChartRow(rawValue: indexPath.row)! { case .glucose: diff --git a/Loop/Views/IOSFocusModesView.swift b/Loop/Views/IOSFocusModesView.swift index 4188c19cb5..f4cb5b4474 100644 --- a/Loop/Views/IOSFocusModesView.swift +++ b/Loop/Views/IOSFocusModesView.swift @@ -47,7 +47,7 @@ struct IOSFocusModesView: View { // MARK: To be removed before next DIY Sync if appName.contains("Tidepool") { VStack(alignment: .leading, spacing: 8) { - Image("focus-mode-1") + Image.optional("focus-mode-1") Text( String( @@ -64,7 +64,7 @@ struct IOSFocusModesView: View { .fixedSize(horizontal: false, vertical: true) VStack(alignment: .leading, spacing: 8) { - Image("focus-mode-2") + Image.optional("focus-mode-2") Text( NSLocalizedString( diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 56c1425907..93ff622edc 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -132,136 +132,164 @@ struct StatusTableView: View { } var body: some View { - wrappedView - .ignoresSafeArea(edges: .bottom) - .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in - Task { - await viewController.reloadData(animated: true) + ActionTabView { + wrappedView + .ignoresSafeArea(edges: .bottom) + .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in + Task { + await viewController.reloadData(animated: true) + } + } + .sheet(item: $viewModel.pendingPreset) { preset in + // This is the active preset; edit disabled + PresetDetentView(preset: preset, roundBasalRate: viewModel.loopDataManager.deliveryDelegate?.roundBasalRate, didTapEdit: { }) + .accessibilityIdentifier("bar_Presets") } + } tabs: { + ActionTab( + title: "Add Carbs", + icon: "carbs", + tintColor: .carbTintColor + ) { + viewController.userTappedAddCarbs() } - .sheet(item: $viewModel.pendingPreset) { preset in - // This is the active preset; edit disabled - PresetDetentView(preset: preset, roundBasalRate: viewModel.loopDataManager.deliveryDelegate?.roundBasalRate, didTapEdit: { }) - .accessibilityIdentifier("bar_Presets") + + ActionTab( + title: "Bolus", + icon: "bolus", + tintColor: .insulinTintColor + ) { + viewController.presentBolusScreen() } - .toolbar { - if !isLandscape { - if #available(iOS 26, *) { - ToolbarItemGroup(placement: .bottomBar) { - carbTab - Spacer() - bolusTab - Spacer() - presetsTab - Spacer() - settingsTab - } - } else { - ToolbarItem(placement: .bottomBar) { - HStack { - carbTab - bolusTab - presetsTab - settingsTab - } - } - } - } + + ActionTab( + title: "Presets", + icon: viewModel.temporaryPresetsManager.activeOverride != nil + ? "presets-selected" + : "presets", + tintColor: .presets + ) { + viewController.presentPresets() + } + + ActionTab( + title: "Settings", + icon: "settings", + tintColor: .secondaryLabel + ) { + viewController.presentSettings() } - .toolbar(isLandscape ? .hidden : .visible, for: .bottomBar) - .toolbarBackground(.visible, for: .bottomBar) + } } - - var carbTab: some View { - Button { - viewController.userTappedAddCarbs() - } label: { - VStack(spacing: 0) { - Image("carbs") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(height: 32) - .frame(maxWidth: .infinity) - .foregroundStyle(Color.carbs) +} - Text("Add Carbs") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize() - } +struct ActionTab: Identifiable { + let id = UUID() + let title: String + let icon: String + let tintColor: UIColor + let action: () -> Void +} + +struct ActionTabBar: UIViewRepresentable { + + let items: [ActionTab] + + func makeUIView(context: Context) -> UITabBar { + let bar = UITabBar() + bar.delegate = context.coordinator + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + bar.standardAppearance = appearance + bar.scrollEdgeAppearance = appearance + bar.tintColor = .label + return bar + } + + func updateUIView(_ uiView: UITabBar, context: Context) { + context.coordinator.tabs = items + uiView.items = items.enumerated().map { idx, item in + UITabBarItem( + title: item.title, + image: UIImage(named: item.icon)?.withTintColor(item.tintColor, renderingMode: .alwaysOriginal), + tag: idx + ) } - .buttonStyle(.plain) - .accessibilityIdentifier("statusTableViewControllerCarbsButton") } - - var bolusTab: some View { - Button { - viewController.presentBolusScreen() - } label: { - VStack(spacing: 0) { - Image("bolus") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(height: 32) - .frame(maxWidth: .infinity) - .foregroundStyle(Color.insulin) - - Text("Bolus") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize() + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, UITabBarDelegate { + var tabs: [ActionTab] = [] + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + guard tabs.indices.contains(item.tag) else { return } + tabs[item.tag].action() + DispatchQueue.main.async { + tabBar.selectedItem = nil } } - .buttonStyle(.plain) - .accessibilityIdentifier("statusTableViewControllerBolusButton") } +} + +@resultBuilder +enum ActionTabBuilder { + static func buildBlock(_ components: ActionTab...) -> [ActionTab] { components } + static func buildOptional(_ component: [ActionTab]?) -> [ActionTab] { component ?? [] } + static func buildEither(first component: [ActionTab]) -> [ActionTab] { component } + static func buildEither(second component: [ActionTab]) -> [ActionTab] { component } + static func buildArray(_ components: [[ActionTab]]) -> [ActionTab] { components.flatMap { $0 } } +} + +struct LegacyTabBarBackground: ViewModifier { - var presetsTab: some View { - Button { - viewController.presentPresets() - } label: { - VStack(spacing: 0) { - Image(viewModel.temporaryPresetsManager.activeOverride != nil ? "presets-selected" : "presets") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(height: 32) - .frame(maxWidth: .infinity) - .foregroundStyle(Color.presets) - .animation(.default, value: viewModel.temporaryPresetsManager.activeOverride) - - Text("Presets") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize() - } + private let isCompatibilityModeActive = Bundle.main.object(forInfoDictionaryKey: "UIDesignRequiresCompatibility") as? Bool ?? false + + func body(content: Content) -> some View { + if #available(iOS 26.0, *), !isCompatibilityModeActive { + content + .frame(height: 0) + } else { + content + .frame(height: 49) + .background( + Color(UIColor.systemBackground) + .ignoresSafeArea(edges: .bottom) + ) } - .buttonStyle(.plain) - .accessibilityIdentifier("statusTableViewPresetsButton") + } +} + +struct ActionTabView: View { + + @State private var orientation: UIDeviceOrientation + + private let allowedOrientations: [UIDeviceOrientation] + private let content: Content + private let tabs: [ActionTab] + + init( + allowedOrientations: [UIDeviceOrientation] = [.portrait], + @ViewBuilder content: @escaping () -> Content, + @ActionTabBuilder tabs: @escaping () -> [ActionTab], + ) { + self.content = content() + self.tabs = tabs() + + self.allowedOrientations = allowedOrientations + self.orientation = UIDevice.current.orientation } - var settingsTab: some View { - Button { - viewController.presentSettings() - } label: { - VStack(spacing: 0) { - Image("settings") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(height: 32) - .frame(maxWidth: .infinity) - .foregroundStyle(Color.secondary) - - Text("Settings") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize() + var body: some View { + content + .safeAreaInset(edge: .bottom, spacing: 0) { + if orientation == .unknown || allowedOrientations.contains(orientation) { + ActionTabBar(items: tabs) + .modifier(LegacyTabBarBackground()) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + orientation = UIDevice.current.orientation } - } - .buttonStyle(.plain) - .accessibilityIdentifier("statusTableViewControllerSettingsButton") } } From cdb643e90300d8a4c50c06f89f82c08448171b85 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 8 Jul 2026 09:12:29 -0700 Subject: [PATCH 10/20] [LOOP-5958] Toolbar Orientation Fix (#938) --- Loop/Views/StatusTableView.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 93ff622edc..c544d2dd38 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -262,34 +262,38 @@ struct LegacyTabBarBackground: ViewModifier { struct ActionTabView: View { - @State private var orientation: UIDeviceOrientation + @State private var orientation: UIInterfaceOrientation - private let allowedOrientations: [UIDeviceOrientation] private let content: Content private let tabs: [ActionTab] init( - allowedOrientations: [UIDeviceOrientation] = [.portrait], @ViewBuilder content: @escaping () -> Content, @ActionTabBuilder tabs: @escaping () -> [ActionTab], ) { self.content = content() self.tabs = tabs() - self.allowedOrientations = allowedOrientations - self.orientation = UIDevice.current.orientation + self.orientation = Self.currentOrientation() } var body: some View { content .safeAreaInset(edge: .bottom, spacing: 0) { - if orientation == .unknown || allowedOrientations.contains(orientation) { + if orientation.isPortrait { ActionTabBar(items: tabs) .modifier(LegacyTabBarBackground()) } } .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - orientation = UIDevice.current.orientation + orientation = Self.currentOrientation() } } + + private static func currentOrientation() -> UIInterfaceOrientation { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first? + .interfaceOrientation ?? .portrait + } } From 5bc2672b189507807ef049eace7caa3c0aa280ff Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 23 Jul 2026 11:55:13 -0700 Subject: [PATCH 11/20] [LOOP-5957] Replace SimpleBolusView and BolusEntryView "X" dismiss with "Cancel" (#940) --- Loop/View Controllers/StatusTableViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 2b52cb358f..c6a22b2651 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1464,7 +1464,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) navigationWrapper = UINavigationController(rootViewController: hostingController) - hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: ""), style: .plain, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { let viewModel = CarbEntryViewModel(delegate: loopManager) @@ -1524,7 +1524,7 @@ final class StatusTableViewController: LoopChartsTableViewController { ) let navigationWrapper = UINavigationController(rootViewController: hostingController) - hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: ""), style: .plain, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) analyticsServicesManager?.didDisplayBolusScreen() } From 3b72534cdee8c9db5aa8727b7f92ebb4fad6016a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 23 Jul 2026 11:57:15 -0700 Subject: [PATCH 12/20] [LOOP-5955] Remove Top Divider When All App Sounds are Muted (#941) --- Loop/Views/AlertManagementView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 9165c406ce..101b4f0f16 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -109,7 +109,7 @@ struct AlertManagementView: View { muteAlertsButton } else { unmuteAlertsButton - .listRowSeparator(.visible, edges: .all) + .listRowSeparator(.visible, edges: .bottom) muteAlertsSummary } } From 93cbdcd3dd29b0d0567ffc302e494774f597be70 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 Jul 2026 10:35:33 -0700 Subject: [PATCH 13/20] =?UTF-8?q?[LOOP-5964]=20Migrate=20Pump/CGM/Service?= =?UTF-8?q?=20Pickers=20to=20Alert=20to=20Avert=20Broken=E2=80=A6=20(#943)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Extensions/UIAlertController.swift | 38 ++--- .../StatusTableViewController.swift | 20 +-- Loop/Views/SettingsView.swift | 148 +++++++++++++----- 3 files changed, 135 insertions(+), 71 deletions(-) diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index e483d7d2fd..c5d200146a 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -12,27 +12,7 @@ import LoopKitUI extension UIAlertController { - /** - Initializes an ActionSheet-styled controller for selecting a pre-meal preset duration - - - parameter handler: A closure to execute when the sheet is dismissed after selection. The closure has a single argument: - - duration: The duration for which the pre-meal preset is to be enabled - */ - internal convenience init(premealDurationSelectionHandler handler: @escaping (_ duration: TimeInterval) -> Void) { - self.init( - title: NSLocalizedString("Use Pre-Meal Preset", comment: "The title of the alert controller used to select a duration for pre-meal targets"), - message: nil, - preferredStyle: .actionSheet - ) - - let distantFuture = NSLocalizedString("Until I enter carbs", comment: "The title of a target alert action specifying pre-meal targets duration for 1 hour or until the user enters carbs (whichever comes first).") - addAction(UIAlertAction(title: distantFuture, style: .default) { _ in - handler(.hours(1)) - }) - - addCancelAction() - } - + /// Initializes an action sheet-styled controller for selecting a PumpManager /// /// - Parameters: @@ -55,6 +35,8 @@ extension UIAlertController { } )) } + + addCancelAction() } /// Initializes an action sheet-styled controller for selecting a CGMManager @@ -69,7 +51,7 @@ extension UIAlertController { message: nil, preferredStyle: .actionSheet ) - + for availableCGMManager in availableCGMManagers.sorted(by: {$0.localizedTitle < $1.localizedTitle}) { addAction(UIAlertAction( title: availableCGMManager.localizedTitle, @@ -79,6 +61,8 @@ extension UIAlertController { } )) } + + addCancelAction() } internal convenience init(deleteCGMManagerHandler handler: @escaping (_ isDeleted: Bool) -> Void) { @@ -108,10 +92,12 @@ extension UIAlertController { /// - selectionHandler: A closure to execute when a service is selected. /// - identifier: The identifier of the selected service. internal convenience init(availableServices: [ServiceDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { + let preferredStyle: UIAlertController.Style = .alert + self.init( title: NSLocalizedString("Add Service", comment: "Action sheet title selecting service"), message: nil, - preferredStyle: .actionSheet + preferredStyle: preferredStyle ) for availableService in availableServices { @@ -123,11 +109,15 @@ extension UIAlertController { } )) } + + if #available(iOS 26.0, *) { + addCancelAction() + } } internal func addCancelAction(handler: ((UIAlertAction) -> Void)? = nil) { let cancel = NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet") - addAction(UIAlertAction(title: cancel, style: .cancel, handler: handler)) + addAction(UIAlertAction(title: cancel, style: .destructive, handler: handler)) } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index c6a22b2651..e3660dda96 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1733,15 +1733,15 @@ final class StatusTableViewController: LoopChartsTableViewController { @objc private func pumpStatusTapped(_ sender: UIGestureRecognizer) { if let pumpStatusView = sender.view as? PumpStatusHUDView { - executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD)) + executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD), from: sender.view) } } @objc private func cgmStatusTapped( _ sender: UIGestureRecognizer) { - executeHUDTapAction(deviceManager.didTapOnCGMStatus()) + executeHUDTapAction(deviceManager.didTapOnCGMStatus(), from: sender.view) } - private func executeHUDTapAction(_ action: HUDTapAction?) { + private func executeHUDTapAction(_ action: HUDTapAction?, from sourceView: UIView?) { guard let action = action else { return } @@ -1754,15 +1754,15 @@ final class StatusTableViewController: LoopChartsTableViewController { case .openAppURL(let url): UIApplication.shared.open(url) case .setupNewCGM: - addNewCGMManager() + addNewCGMManager(from: sourceView) case .setupNewPump: - addNewPumpManager() + addNewPumpManager(from: sourceView) default: return } } - private func addNewPumpManager() { + private func addNewPumpManager(from sourceView: UIView?) { let availablePumpManagers = deviceManager.availablePumpManagers switch availablePumpManagers.count { @@ -1774,12 +1774,13 @@ final class StatusTableViewController: LoopChartsTableViewController { let alert = UIAlertController(availablePumpManagers: availablePumpManagers) { [weak self] (identifier) in self?.addPumpManager(withIdentifier: identifier) } - alert.addCancelAction { _ in } + alert.popoverPresentationController?.sourceView = sourceView ?? view + alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds present(alert, animated: true, completion: nil) } } - private func addNewCGMManager() { + private func addNewCGMManager(from sourceView: UIView?) { let availableCGMManagers = deviceManager.availableCGMManagers switch availableCGMManagers.count { @@ -1791,7 +1792,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let alert = UIAlertController(availableCGMManagers: availableCGMManagers) { [weak self] identifier in self?.addCGMManager(withIdentifier: identifier) } - alert.addCancelAction { _ in } + alert.popoverPresentationController?.sourceView = sourceView ?? view + alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds present(alert, animated: true, completion: nil) } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 72e50343fe..7cfed25448 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -111,25 +111,6 @@ struct SettingsView: View { .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) - .actionSheet(item: $actionSheet) { actionSheet in - switch actionSheet { - case .cgmPicker: - return ActionSheet( - title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), - buttons: cgmChoices - ) - case .pumpPicker: - return ActionSheet( - title: Text("Add Pump", comment: "The title of the pump chooser in settings"), - buttons: pumpChoices - ) - case .servicePicker: - return ActionSheet( - title: Text("Add Service", comment: "The title of the add service action sheet in settings"), - buttons: serviceChoices - ) - } - } .alert(item: $alert) { alert in switch alert { case .deleteCGMData: @@ -395,17 +376,22 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add Pump", comment: "Title text for button to add pump device"), descriptiveText: NSLocalizedString("Tap here to set up a pump", comment: "Descriptive text for button to add pump device")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .pumpPicker), + title: NSLocalizedString("Add Pump", comment: "The title of the pump chooser in settings"), + actions: pumpChoices + ) + ) } } - - private var pumpChoices: [ActionSheet.Button] { - var result = viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in - ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + + private var pumpChoices: [PluginPopover.Action] { + viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in + .init(title: availableDevice.localizedTitle) { self.viewModel.pumpManagerSettingsViewModel.didTapAdd(availableDevice) } } - result.append(.cancel()) - return result } @ViewBuilder @@ -422,6 +408,13 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add CGM", comment: "Title text for button to add CGM device"), descriptiveText: NSLocalizedString("Tap here to set up a CGM", comment: "Descriptive text for button to add CGM device")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .cgmPicker), + title: NSLocalizedString("Add CGM", comment: "The title of the CGM chooser in settings"), + actions: cgmChoices + ) + ) } } @@ -435,16 +428,14 @@ extension SettingsView { } } - private var cgmChoices: [ActionSheet.Button] { - var result = viewModel.cgmManagerSettingsViewModel.availableDevices + private var cgmChoices: [PluginPopover.Action] { + viewModel.cgmManagerSettingsViewModel.availableDevices .sorted(by: {$0.localizedTitle < $1.localizedTitle}) .map { availableDevice in - ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + .init(title: availableDevice.localizedTitle) { self.viewModel.cgmManagerSettingsViewModel.didTapAdd(availableDevice) + } } - } - result.append(.cancel()) - return result } private var servicesSection: some View { @@ -462,20 +453,36 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add Service", comment: "The title of the add service button in settings"), descriptiveText: NSLocalizedString("Tap here to set up a Service", comment: "The descriptive text of the add service button in settings")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .servicePicker), + title: NSLocalizedString("Add Service", comment: "The title of the add service action sheet in settings"), + actions: serviceChoices + ) + ) } } } - - private var serviceChoices: [ActionSheet.Button] { - var result = viewModel.servicesViewModel.inactiveServices().map { availableService in - ActionSheet.Button.default(Text(availableService.localizedTitle)) { + + private var serviceChoices: [PluginPopover.Action] { + viewModel.servicesViewModel.inactiveServices().map { availableService in + .init(title: availableService.localizedTitle) { self.viewModel.servicesViewModel.didTapAddService(availableService) } } - result.append(.cancel()) - return result } - + + private func actionSheetBinding(for destination: Destination.ActionSheet) -> Binding { + Binding( + get: { actionSheet == destination }, + set: { isPresented in + if !isPresented && actionSheet == destination { + actionSheet = nil + } + } + ) + } + private var deleteDataSection: some View { Section { if viewModel.pumpManagerSettingsViewModel.isTestingDevice { @@ -684,6 +691,71 @@ fileprivate struct LargeButton: View { } } +struct PluginPopover: UIViewControllerRepresentable { + struct Action { + let title: String + let handler: () -> Void + } + + @Binding var isPresented: Bool + let title: String + let actions: [Action] + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + context.coordinator.parent = self + + guard isPresented else { return } + guard uiViewController.presentedViewController == nil else { return } + + let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + for action in actions { + alert.addAction(UIAlertAction(title: action.title, style: .default) { _ in + context.coordinator.setPresented(false) + action.handler() + }) + } + + alert.addAction(UIAlertAction( + title: NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet"), + style: .destructive + ) { _ in + context.coordinator.setPresented(false) + }) + + if let popover = alert.popoverPresentationController { + popover.sourceView = uiViewController.view + popover.sourceRect = uiViewController.view.bounds + popover.delegate = context.coordinator + } + + uiViewController.present(alert, animated: true) + } + + final class Coordinator: NSObject, UIPopoverPresentationControllerDelegate { + var parent: PluginPopover + + init(_ parent: PluginPopover) { + self.parent = parent + } + + func setPresented(_ value: Bool) { + DispatchQueue.main.async { self.parent.isPresented = value } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + setPresented(false) + } + } +} + public struct SettingsView_Previews: PreviewProvider { public static var previews: some View { From e1a22750caabeef45cb4c426fbeecbf2d1efb161 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 Jul 2026 10:35:48 -0700 Subject: [PATCH 14/20] [LOOP-5951] Always Render the Critical Alerts Triangle in Color (#942) --- Loop/Managers/AlertPermissionsChecker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index fe9409b8d9..d691048196 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -259,7 +259,7 @@ extension AlertPermissionsChecker { message: alert.alertBody, preferredStyle: .alert) let titleImageAttachment = NSTextAttachment() - titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) + 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 titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) From d30b4a16ef3c0a0c32b1e232423150b1a6e48a43 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 Jul 2026 10:36:05 -0700 Subject: [PATCH 15/20] [LOOP-5961] Fix Toolbar on SE (#939) --- .../StatusTableViewController.swift | 2 +- Loop/Views/StatusTableView.swift | 90 +++++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index e3660dda96..b5adaad083 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -643,7 +643,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let statusRowMode = self.determineStatusRowMode() updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: UIDevice.current.orientation.isLandscape ? 0 : 52, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: ActionTabBarMetrics.tableContentInset, right: 0) redrawCharts() diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index c544d2dd38..fbed79e1d1 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -87,14 +87,7 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { } struct StatusTableView: View { - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.verticalSizeClass) private var verticalSizeClass - - private var isLandscape: Bool { - UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height - } - + private let wrapped: WrappedStatusTableViewController var viewController: StatusTableViewController { @@ -194,19 +187,26 @@ struct ActionTab: Identifiable { struct ActionTabBar: UIViewRepresentable { let items: [ActionTab] + var isHidden: Bool = false func makeUIView(context: Context) -> UITabBar { let bar = UITabBar() bar.delegate = context.coordinator let appearance = UITabBarAppearance() appearance.configureWithOpaqueBackground() + let titleAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.label] + for layout in [appearance.stackedLayoutAppearance, appearance.inlineLayoutAppearance, appearance.compactInlineLayoutAppearance] { + for state in [layout.normal, layout.selected] { + state.titleTextAttributes = titleAttributes + } + } bar.standardAppearance = appearance bar.scrollEdgeAppearance = appearance - bar.tintColor = .label return bar } func updateUIView(_ uiView: UITabBar, context: Context) { + uiView.isHidden = isHidden context.coordinator.tabs = items uiView.items = items.enumerated().map { idx, item in UITabBarItem( @@ -241,17 +241,56 @@ enum ActionTabBuilder { static func buildArray(_ components: [[ActionTab]]) -> [ActionTab] { components.flatMap { $0 } } } +enum ActionTabBarMetrics { + + static let barHeight: CGFloat = 49 + + static var bottomSafeAreaInset: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow }? + .safeAreaInsets.bottom ?? 0 + } + + static var interfaceOrientation: UIInterfaceOrientation { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let scene = scenes.first { $0.windows.contains { $0.isKeyWindow } } + ?? scenes.first { $0.activationState == .foregroundActive } + ?? scenes.first + return scene?.interfaceOrientation ?? .portrait + } + + static var tableContentInset: CGFloat { + guard !interfaceOrientation.isLandscape else { return 0 } + if #available(iOS 26.0, *), bottomSafeAreaInset == 0 { + return barHeight + 40 + } + return 52 + } +} + struct LegacyTabBarBackground: ViewModifier { - private let isCompatibilityModeActive = Bundle.main.object(forInfoDictionaryKey: "UIDesignRequiresCompatibility") as? Bool ?? false - + var isVisible: Bool = true + func body(content: Content) -> some View { - if #available(iOS 26.0, *), !isCompatibilityModeActive { + if !isVisible { content .frame(height: 0) + } else if #available(iOS 26.0, *) { + if ActionTabBarMetrics.bottomSafeAreaInset == 0 { + content + .frame(height: ActionTabBarMetrics.barHeight) + .padding(.bottom, 16) + } else { + content + .frame(height: 0) + .padding(.bottom, 6) + } } else { content - .frame(height: 49) + .frame(height: ActionTabBarMetrics.barHeight) .background( Color(UIColor.systemBackground) .ignoresSafeArea(edges: .bottom) @@ -273,27 +312,24 @@ struct ActionTabView: View { ) { self.content = content() self.tabs = tabs() - - self.orientation = Self.currentOrientation() + self.orientation = ActionTabBarMetrics.interfaceOrientation } var body: some View { content .safeAreaInset(edge: .bottom, spacing: 0) { - if orientation.isPortrait { - ActionTabBar(items: tabs) - .modifier(LegacyTabBarBackground()) - } + ActionTabBar(items: tabs, isHidden: !orientation.isPortrait) + .modifier(LegacyTabBarBackground(isVisible: orientation.isPortrait)) + } + .onAppear { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + orientation = ActionTabBarMetrics.interfaceOrientation + } + .onDisappear { + UIDevice.current.endGeneratingDeviceOrientationNotifications() } .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - orientation = Self.currentOrientation() + orientation = ActionTabBarMetrics.interfaceOrientation } } - - private static func currentOrientation() -> UIInterfaceOrientation { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first? - .interfaceOrientation ?? .portrait - } } From 1b0943b5f0090dec8e932c8ff926175799171c59 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 30 Jul 2026 11:11:16 -0700 Subject: [PATCH 16/20] [LOOP-5951] Allow Text Attachment Tinting with Liquid Glass (#944) --- Loop/Managers/AlertPermissionsChecker.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index d691048196..c5c24f27e7 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -259,8 +259,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)])) From 2867cebf70179ddb4b82dcc66f3fef40a5900cda Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 13 Aug 2026 14:50:49 -0500 Subject: [PATCH 17/20] Fix CarbMath preconditionFailure when a carb entry predates the ISF window (#945) CarbMath.map(to:) preconditionFailures if the insulin-sensitivity or carb-ratio timeline doesn't cover a carb entry's start date. The carb-ratio window and carb entries both extend back to carbsStart (baseTime - dateAdjustmentPast), but the ISF window came from timelineIntervalForSensitivity, which is derived from dose and glucose history only. When that history is more recent than carbsStart (e.g. a CGM gap, fresh setup, or a heavily backdated carb entry on the manual-bolus screen), an older carb entry has carb-ratio coverage but no ISF coverage -> closestPrior returns nil -> crash (seen via recommendManualBolus). Extend the ISF and override history back to min(neededSensitivityTimeline.start, carbsStart) so it covers the same carb window as carbRatio. --- Loop/Managers/LoopDataManager.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 613d3695b1..71ebfb1023 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -389,8 +389,15 @@ final class LoopDataManager: ObservableObject { recommendationEffectInterval: recommendationEffectInterval ) + // Carb entries (and a backdated manual-bolus entry) can extend back to carbsStart, and + // CarbMath.map(to:) preconditionFailures if the ISF/carb-ratio timelines don't cover every + // carb entry's start date. timelineIntervalForSensitivity derives its window from dose and + // glucose history only — which can be more recent than carbsStart (e.g. after a CGM gap) — + // so extend the ISF (and override) window back to cover the carb window, matching carbRatio. + let sensitivityStart = min(neededSensitivityTimeline.start, carbsStart) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( - startDate: neededSensitivityTimeline.start, + startDate: sensitivityStart, endDate: neededSensitivityTimeline.end ) @@ -404,7 +411,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.maximumBasalRatePerHour) } - var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) + var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: sensitivityStart, endDate: forecastEndTime) // For recommendation, we should consider preMeal override to be ending at time of dose if presumePresetEndingNow, From b2cf4b68ba1f99aff2c2e02d9032aa720ceb0a4c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 22 Sep 2026 06:13:59 -0300 Subject: [PATCH 18/20] [KELP-83] only redraw when the prediction line changes (#947) --- Loop/Managers/StatusChartsManager.swift | 9 +++++++++ 1 file changed, 9 insertions(+) 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) } From 1a6a1737ab08657859ea1601532989c354e98250 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 12 Oct 2026 20:41:13 -0500 Subject: [PATCH 19/20] Restore the Performance History entry point in Presets Reverts e7a1e210 so the preset history screens are reachable again. --- Loop/Views/Presets/PresetsView.swift | 53 +++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) 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 { From 35f752b8d721cb3a0a9866810006ab333e985d21 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 12 Oct 2026 20:43:36 -0500 Subject: [PATCH 20/20] Keep the watch Last Loop row when the phone is unreachable Drops the LOOP-5733 change from the Tidepool merge, which replaced the last loop time with "- - -" whenever the phone is out of reach. --- WatchApp Extension/Views/ChartPageView.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index eacc18c6fb..188cedec38 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -11,7 +11,6 @@ import LoopKit import LoopCore import LoopAlgorithm import SpriteKit -import WatchConnectivity struct ChartPageView: View { @Environment(\.sizeClass) private var sizeClass @@ -31,10 +30,6 @@ struct ChartPageView: View { return nil } - guard WCSession.default.isReachable else { - return "- - -" - } - let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) guard let timeString = ago.truncatedTimeAgoString else { @@ -185,7 +180,8 @@ struct ChartPageView: View { if let lastSyncString { LabelValueRow("Last Loop") { // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once watchOS 10 is dropped as a supported platform. - (WCSession.default.isReachable ? Text("\(Image(systemName: "arrow.triangle.2.circlepath")) ") : Text("")) + Text(lastSyncString) + Text(Image(systemName: "arrow.triangle.2.circlepath")) + + Text(" " + lastSyncString) } Divider() }