From 4552200a40d308b0a07cb6492d04e3cedc49d649 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 9 Jun 2026 18:42:40 -0700 Subject: [PATCH 1/4] Working commit --- Loop.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/WatchApp.xcscheme | 2 - Loop/AppDelegate.swift | 65 +++-------- Loop/Info.plist | 21 +++- Loop/Managers/LoopAppManager.swift | 4 + Loop/SceneDelegate.swift | 106 ++++++++++++++++++ 6 files changed, 148 insertions(+), 54 deletions(-) create mode 100644 Loop/SceneDelegate.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index a3936a53ef..039dff94d5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -260,6 +260,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 */; }; @@ -1154,6 +1155,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 +1965,7 @@ 43F5C2CE1B92A2A0003EB13D /* View Controllers */, 43F5C2CF1B92A2ED003EB13D /* Views */, 897A5A9724C22DCE00C4E71D /* View Models */, + 84E0EB0A2FD8063100D6FBB6 /* SceneDelegate.swift */, ); path = Loop; sourceTree = ""; @@ -3700,6 +3703,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 */, 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/Info.plist b/Loop/Info.plist index 41d479164f..43bacb496c 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -78,6 +78,25 @@ NewCarbEntryIntent ViewLoopStatus + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIBackgroundModes bluetooth-central @@ -88,8 +107,6 @@ 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 }) + } +} From 1e4eac5b898c484d6aa7e8c44a028db874315ff6 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 16 Jun 2026 13:30:05 -0700 Subject: [PATCH 2/4] Updates/Fixes for iOS 27 & Liquid Glass Toolbar --- Loop.xcodeproj/project.pbxproj | 4 - Loop/Extensions/Image+Optional.swift | 20 -- Loop/Info.plist | 2 - .../StatusTableViewController.swift | 7 +- Loop/Views/IOSFocusModesView.swift | 4 +- Loop/Views/StatusTableView.swift | 258 ++++++++++-------- 6 files changed, 149 insertions(+), 146 deletions(-) delete mode 100644 Loop/Extensions/Image+Optional.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 039dff94d5..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 */; }; @@ -1135,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 = ""; }; @@ -2164,7 +2162,6 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */, 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */, A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */, - 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */, ); path = Extensions; sourceTree = ""; @@ -3732,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/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 43bacb496c..4658639b98 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -103,8 +103,6 @@ processing remote-notification - UIDesignRequiresCompatibility - UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities 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 d750d56d9d9486ce5e06e5b2360adacab810f792 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 24 Jun 2026 11:48:46 -0700 Subject: [PATCH 3/4] Update Info.plist --- Loop/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Info.plist b/Loop/Info.plist index 4658639b98..814e0da030 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -103,6 +103,8 @@ processing remote-notification + UIDesignRequiresCompatibility + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities From a5ff6c7682e4c76534f786d6f9924f4071100648 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 24 Jun 2026 12:01:06 -0700 Subject: [PATCH 4/4] Update Info.plist --- Loop/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/Loop/Info.plist b/Loop/Info.plist index 814e0da030..4658639b98 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -103,8 +103,6 @@ processing remote-notification - UIDesignRequiresCompatibility - UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities