From ce7c83faa54d68e0fd8879a271975f9c16f87280 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 12 Dec 2024 10:35:53 -0800 Subject: [PATCH] [LOOP-5056] Presets Homepage Updates - Part 1 (#733) --- Loop.xcodeproj/project.pbxproj | 12 + Loop/Managers/LoopAppManager.swift | 46 +-- Loop/Managers/TemporaryPresetsManager.swift | 17 +- .../StatusTableViewController.swift | 357 +++++++++++------- Loop/View Models/PresetsViewModel.swift | 61 ++- Loop/View Models/SettingsViewModel.swift | 118 +++--- .../Views/Presets/Components/PresetCard.swift | 110 +----- .../Presets/Components/PresetDetentView.swift | 175 +++++++++ .../Presets/Components/PresetStatsView.swift | 128 +++++++ Loop/Views/Presets/PresetsView.swift | 25 +- Loop/Views/SettingsView.swift | 19 +- Loop/Views/StatusTableView.swift | 315 ++++++++++++++++ Loop/Views/TitleSubtitleTableViewCell.swift | 2 +- LoopUI/Extensions/UIColor.swift | 2 +- 14 files changed, 1029 insertions(+), 358 deletions(-) create mode 100644 Loop/Views/Presets/Components/PresetDetentView.swift create mode 100644 Loop/Views/Presets/Components/PresetStatsView.swift create mode 100644 Loop/Views/StatusTableView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2eb821b885..8a75eb14b3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -253,6 +253,8 @@ 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; + 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; + 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */; }; 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */; }; @@ -269,6 +271,7 @@ 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; + 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; @@ -1130,6 +1133,8 @@ 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingViewModel.swift; sourceTree = ""; }; 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; @@ -1146,6 +1151,7 @@ 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.swift"; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; + 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; @@ -2214,6 +2220,7 @@ DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, ); path = Views; sourceTree = ""; @@ -2515,6 +2522,8 @@ 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */, 84C170EE2CCA37680098E52F /* PresetCard.swift */, + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, + 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, ); path = Components; sourceTree = ""; @@ -3516,6 +3525,7 @@ 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, + 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, @@ -3567,6 +3577,7 @@ B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, + 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, @@ -3613,6 +3624,7 @@ A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, + 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 317606c228..d6b71ba930 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -17,6 +17,7 @@ import HealthKit import WidgetKit import LoopCore import LoopAlgorithm +import SwiftUI #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -544,26 +545,27 @@ class LoopAppManager: NSObject { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) - let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) - let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController - statusTableViewController.alertPermissionsChecker = alertPermissionsChecker - statusTableViewController.alertMuter = alertManager.alertMuter - statusTableViewController.automaticDosingStatus = automaticDosingStatus - statusTableViewController.deviceManager = deviceDataManager - statusTableViewController.onboardingManager = onboardingManager - statusTableViewController.supportManager = supportManager - statusTableViewController.testingScenariosManager = testingScenariosManager - statusTableViewController.settingsManager = settingsManager - statusTableViewController.temporaryPresetsManager = temporaryPresetsManager - statusTableViewController.loopManager = loopDataManager - statusTableViewController.diagnosticReportGenerator = self - statusTableViewController.simulatedData = self - statusTableViewController.analyticsServicesManager = analyticsServicesManager - statusTableViewController.servicesManager = servicesManager - statusTableViewController.carbStore = carbStore - statusTableViewController.doseStore = doseStore - statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager - bluetoothStateManager.addBluetoothObserver(statusTableViewController) + let statusTableView = StatusTableView( + displayGlucosePreference: displayGlucosePreference, + alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.alertMuter, + automaticDosingStatus: automaticDosingStatus, + deviceDataManager: deviceDataManager, + onboardingManager: onboardingManager, + supportManager: supportManager, + testingScenariosManager: testingScenariosManager, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager, + loopDataManager: loopDataManager, + diagnosticReportGenerator: self, + simulatedData: self, + analyticsServicesManager: analyticsServicesManager, + servicesManager: servicesManager, + carbStore: carbStore, + doseStore: doseStore, + criticalEventLogExportManager: criticalEventLogExportManager, + bluetoothStateManager: bluetoothStateManager + ).edgesIgnoringSafeArea(.top) var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { @@ -571,7 +573,7 @@ class LoopAppManager: NSObject { rootViewController = rootNavigationController } - rootNavigationController?.setViewControllers([statusTableViewController], animated: true) + rootNavigationController?.setViewControllers([UIHostingController(rootView: statusTableView)], animated: true) await deviceDataManager.refreshDeviceData() @@ -837,7 +839,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo - if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? Alert.AlertIdentifier, + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 08fc198924..584b0f8366 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -16,17 +16,18 @@ protocol PresetActivationObserver: AnyObject { func presetDeactivated(context: TemporaryScheduleOverride.Context) } +@Observable class TemporaryPresetsManager { - private let log = OSLog(category: "TemporaryPresetsManager") + @ObservationIgnored private let log = OSLog(category: "TemporaryPresetsManager") - private var settingsProvider: SettingsProvider + @ObservationIgnored private var settingsProvider: SettingsProvider var overrideHistory: TemporaryScheduleOverrideHistory - private var presetActivationObservers: [PresetActivationObserver] = [] + @ObservationIgnored private var presetActivationObservers: [PresetActivationObserver] = [] - private var overrideIntentObserver: NSKeyValueObservation? = nil + @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil @MainActor init(settingsProvider: SettingsProvider) { @@ -87,7 +88,7 @@ class TemporaryPresetsManager { } if let newValue = scheduleOverride, newValue.context == .preMeal { - preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") +// preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") } if scheduleOverride != oldValue { @@ -198,12 +199,12 @@ class TemporaryPresetsManager { ) } - public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) { scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) preMealOverride = nil } - public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) -> TemporaryScheduleOverride? { guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { return nil } @@ -212,7 +213,7 @@ class TemporaryPresetsManager { context: .legacyWorkout, settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), + duration: duration, enactTrigger: .local, syncIdentifier: UUID() ) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3ea9ba1c59..7d8638153f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -69,8 +69,78 @@ final class StatusTableViewController: LoopChartsTableViewController { var criticalEventLogExportManager: CriticalEventLogExportManager! + lazy var settingsViewModel: SettingsViewModel = { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.pumpManager is TestingPumpManager) ? { + Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() + }} : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.cgmManager is TestingCGMManager) ? { + Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() + }} : nil + } + let pumpViewModel = PumpManagerViewModel( + image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, + name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, + isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, + availableDevices: deviceManager.availablePumpManagers, + deleteTestingDataFunc: deletePumpDataFunc, + onTapped: { [weak self] in + self?.onPumpTapped() + }, + didTapAddDevice: { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + }) + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc, + onTapped: { [weak self] in + self?.onCGMTapped() + }, + didTapAddDevice: { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + }) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, + delegate: self) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertMuter, + versionUpdateViewModel: versionUpdateViewModel, + pumpManagerSettingsViewModel: pumpViewModel, + cgmManagerSettingsViewModel: cgmViewModel, + servicesViewModel: servicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceManager, + presetHistory: temporaryPresetsManager.overrideHistory, + temporaryPresetsManager: temporaryPresetsManager, + delegate: self + ) + + viewModel.favoriteFoodInsightsDelegate = loopManager + + return viewModel + }() + lazy private var cancellables = Set() + + var statusBarBackgroundView: UIView? override func viewDidLoad() { @@ -199,8 +269,7 @@ final class StatusTableViewController: LoopChartsTableViewController { addScenarioStepGestureRecognizers() - tableView.backgroundColor = .secondarySystemBackground - + setupPresetsStatusBar() } override func didReceiveMemoryWarning() { @@ -312,6 +381,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } + + private func setupPresetsStatusBar() { + let backgroundContainerView = UIView() + backgroundContainerView.backgroundColor = .secondarySystemBackground + let statusBarBackgroundView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 0)) + self.statusBarBackgroundView = statusBarBackgroundView + backgroundContainerView.addSubview(statusBarBackgroundView) + tableView.backgroundView = backgroundContainerView + + updateStatusBar() + } private var bolusProgressReporter: DoseProgressReporter? @@ -389,6 +469,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private var refreshContext = RefreshContext.all + private var shouldShowPresets: Bool { + presetsRowMode.hasRow + } + private var shouldShowHUD: Bool { return !landscapeMode } @@ -649,6 +733,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private enum Section: Int, CaseIterable { + case presets case alertWarning case hud case status @@ -681,14 +766,31 @@ final class StatusTableViewController: LoopChartsTableViewController { private var currentCOBDescription: String? // MARK: - Loop Status Section Data + + private enum PresetsRow: Int, CaseIterable { + case presets = 0 + } + private enum PresetsRowMode { + case hidden + case scheduleOverrideEnabled(TemporaryScheduleOverride) + + var hasRow: Bool { + switch self { + case .hidden: + return false + default: + return true + } + } + } + private enum StatusRow: Int, CaseIterable { case status = 0 } private enum StatusRowMode { case hidden - case scheduleOverrideEnabled(TemporaryScheduleOverride) case enactingBolus case bolusing(dose: DoseEntry) case cancelingBolus @@ -707,10 +809,19 @@ final class StatusTableViewController: LoopChartsTableViewController { } } + private var presetsRowMode = PresetsRowMode.hidden private var statusRowMode = StatusRowMode.hidden private var canceledDose: DoseEntry? = nil + private func determinePresetsRowMode() -> PresetsRowMode { + if let preset = temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride, !preset.hasFinished() { + return .scheduleOverrideEnabled(preset) + } else { + return .hidden + } + } + private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode @@ -731,14 +842,6 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .onboardingSuspended } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { statusRowMode = .recommendManualGlucoseEntry - } else if let scheduleOverride = temporaryPresetsManager.scheduleOverride, - !scheduleOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = temporaryPresetsManager.preMealOverride, - !premealOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(premealOverride) } else { statusRowMode = .hidden } @@ -749,6 +852,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private var shouldShowBannerWarning: Bool { alertPermissionsChecker.showWarning || alertMuter.configuration.shouldMute } + + override func viewDidLayoutSubviews() { + updateStatusBar() + } private func updateBannerRow(animated: Bool) { let warningWasVisible = tableView.numberOfRows(inSection: Section.alertWarning.rawValue) != 0 @@ -760,19 +867,27 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.reloadRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: .none) } } + + private func updateStatusBar() { + statusBarBackgroundView?.backgroundColor = shouldShowPresets ? .presets : .secondarySystemBackground + statusBarBackgroundView?.frame.size.height = abs(tableView.contentOffset.y) + (shouldShowPresets ? tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)).contentView.frame.height + 8 : 0) + } private func updateBannerAndHUDandStatusRows(statusRowMode: StatusRowMode, newSize: CGSize?, animated: Bool) { + let presetsWasVisible = self.shouldShowPresets let hudWasVisible = self.shouldShowHUD let statusWasVisible = self.shouldShowStatus let oldStatusRowMode = self.statusRowMode + self.presetsRowMode = determinePresetsRowMode() self.statusRowMode = statusRowMode if let newSize = newSize { landscapeMode = newSize.width > newSize.height } + let presetsIsVisible = self.shouldShowPresets let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus @@ -782,7 +897,16 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.beginUpdates() updateBannerRow(animated: animated) - + + switch (presetsWasVisible, presetsIsVisible) { + case (false, true): + tableView.insertRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + case (true, false): + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + default: + break + } + switch (hudWasVisible, hudIsVisible) { case (false, true): tableView.insertRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) @@ -807,7 +931,7 @@ final class StatusTableViewController: LoopChartsTableViewController { if oldDose.syncIdentifier != newDose.syncIdentifier { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } - case (.cancelingBolus, .bolusing(let oldDose)): + case (.cancelingBolus, .bolusing): // this occurs when a cancel command fails tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) case (.canceledBolus(let oldDose), .canceledBolus(let newDose)): @@ -872,7 +996,7 @@ final class StatusTableViewController: LoopChartsTableViewController { updateToolbarItems() } - private var workoutMode: Bool? = nil { + private(set) var workoutMode: Bool? = nil { didSet { guard oldValue != workoutMode else { return @@ -893,6 +1017,8 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { + case .presets: + return shouldShowPresets ? PresetsRow.allCases.count : 0 case .alertWarning: return shouldShowBannerWarning ? 1 : 0 case .hud: @@ -994,6 +1120,64 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { + case .presets: + func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.titleLabel.text = nil + cell.titleLabel.textColor = .white + cell.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + cell.subtitleLabel.text = nil + cell.subtitleLabel.textColor = .white + cell.subtitleLabel.font = .systemFont(ofSize: 15) + cell.accessoryView = nil + cell.gradient.isHidden = true + return cell + } + + let cell = getTitleSubtitleCell() + + switch presetsRowMode { + case .hidden: + break + case .scheduleOverrideEnabled(let override): + switch override.context { + case .preMeal: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.white) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .legacyWorkout: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.white) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .preset(let preset): + cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) + case .custom: + cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") + } + + if override.isActive() { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText) + case .indefinite: + cell.subtitleLabel.text = nil + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) + } + } + + return cell case .alertWarning: if alertPermissionsChecker.showWarning { var cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell @@ -1062,43 +1246,6 @@ final class StatusTableViewController: LoopChartsTableViewController { switch statusRowMode { case .hidden: let cell = getTitleSubtitleCell() - return cell - case .scheduleOverrideEnabled(let override): - let cell = getTitleSubtitleCell() - switch override.context { - case .preMeal: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.carbTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .legacyWorkout: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.glucoseTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .preset(let preset): - cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) - case .custom: - cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") - } - - if override.isActive() { - switch override.duration { - case .finite: - let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("until %@", comment: "The format for the description of a custom preset end date"), endTimeText) - case .indefinite: - cell.subtitleLabel.text = nil - } - } else { - let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) - } - return cell case .enactingBolus: let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell @@ -1195,7 +1342,7 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setSubtitleLabel(label: nil) } } - case .hud, .status, .alertWarning: + case .presets, .hud, .status, .alertWarning: break } } @@ -1216,13 +1363,15 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob, .dose, .cob: return max(106, 0.21 * availableSize) } - case .hud, .status, .alertWarning: + case .presets, .hud, .status, .alertWarning: return UITableView.automaticDimension } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { + case .presets: + settingsViewModel.presetsViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1257,16 +1406,6 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } - case .scheduleOverrideEnabled(let override): - switch override.context { - case .preMeal, .legacyWorkout: - break - default: - let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) - vc.inputMode = .editOverride(override) - vc.delegate = self - show(vc, sender: tableView.cellForRow(at: indexPath)) - } case .bolusing(var dose): bolusState = .canceling updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) @@ -1513,7 +1652,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return item } - @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { + @IBAction func premealButtonTapped(_ sender: UIBarButtonItem? = nil) { togglePreMealMode() } @@ -1573,12 +1712,12 @@ final class StatusTableViewController: LoopChartsTableViewController { // allow cell animation when switching between presets self.temporaryPresetsManager.clearOverride(matching: .preMeal) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) } return } - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) }) present(vc, animated: true, completion: nil) @@ -1588,74 +1727,28 @@ final class StatusTableViewController: LoopChartsTableViewController { presentCustomPresets() } + private(set) var isShowingPresets: Bool = false + + func presentPresets() { + let hostingController = DismissibleHostingController( + rootView: PresetsView(viewModel: settingsViewModel.presetsViewModel) + .onAppear { self.isShowingPresets = true } + .onDisappear { self.isShowingPresets = false } + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus), + isModalInPresentation: false) + present(hostingController, animated: true) + } + @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { presentSettings() } - private func presentSettings() { - let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.pumpManager is TestingPumpManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() - }} : nil - } - let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.cgmManager is TestingCGMManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() - }} : nil - } - let pumpViewModel = PumpManagerViewModel( - image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, - name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, - isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, - availableDevices: deviceManager.availablePumpManagers, - deleteTestingDataFunc: deletePumpDataFunc, - onTapped: { [weak self] in - self?.onPumpTapped() - }, - didTapAddDevice: { [weak self] in - self?.addPumpManager(withIdentifier: $0.identifier) - }) - - let cgmViewModel = CGMManagerViewModel( - image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, - name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, - isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, - availableDevices: deviceManager.availableCGMManagers, - deleteTestingDataFunc: deleteCGMDataFunc, - onTapped: { [weak self] in - self?.onCGMTapped() - }, - didTapAddDevice: { [weak self] in - self?.addCGMManager(withIdentifier: $0.identifier) - }) - let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, - delegate: self) - let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, - alertMuter: alertMuter, - versionUpdateViewModel: versionUpdateViewModel, - pumpManagerSettingsViewModel: pumpViewModel, - cgmManagerSettingsViewModel: cgmViewModel, - servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), - therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, - automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, - lastLoopCompletion: loopManager.$lastLoopCompleted, - mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, - mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, - availableSupports: supportManager.availableSupports, - isOnboardingComplete: onboardingManager.isComplete, - therapySettingsViewModelDelegate: deviceManager, - presetHistory: temporaryPresetsManager.overrideHistory, - delegate: self - ) - viewModel.favoriteFoodInsightsDelegate = loopManager + func presentSettings() { let hostingController = DismissibleHostingController( - rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) + rootView: SettingsView(viewModel: settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) @@ -2301,3 +2394,9 @@ extension StatusTableViewController: ServicesViewModelDelegate { show(settingsViewController, sender: self) } } + +extension StatusTableViewController { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateStatusBar() + } +} diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 67d45e31b3..c48562e565 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -37,8 +37,8 @@ extension TemporaryScheduleOverride { var presetId: String { switch context { - case .preMeal: return "premeal" - case .legacyWorkout: return "legacyworkout" + case .preMeal: return "preMeal" + case .legacyWorkout: return "legacyWorkout" case .custom: return self.syncIdentifier.uuidString case .preset(let preset): return preset.id.uuidString } @@ -59,10 +59,10 @@ enum SelectablePreset: Hashable, Identifiable { case .custom(let preset): hasher.combine(preset) case .legacyWorkout(let range, _): - hasher.combine("legacyworkout") + hasher.combine("legacyWorkout") hasher.combine(range) case .preMeal(let range, _): - hasher.combine("premeal") + hasher.combine("preMeal") hasher.combine(range) } } @@ -161,23 +161,31 @@ enum SelectablePreset: Hashable, Identifiable { } } -class PresetsViewModel: ObservableObject { +@MainActor +@Observable +public class PresetsViewModel { // MARK: Training - @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false - @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name - @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true + @ObservationIgnored @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false + @ObservationIgnored @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name + @ObservationIgnored @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true - var correctionRangeOverrides: CorrectionRangeOverrides? + @ObservationIgnored var correctionRangeOverrides: CorrectionRangeOverrides? + + let temporaryPresetsManager: TemporaryPresetsManager - @Published var customPresets: [TemporaryScheduleOverridePreset] - @Published var activeOverride: TemporaryScheduleOverride? + var customPresets: [TemporaryScheduleOverridePreset] + var pendingPreset: SelectablePreset? - let preMealGuardrail: Guardrail? - let legacyWorkoutGuardrail: Guardrail? + public private(set) var preMealGuardrail: Guardrail? + public private(set) var legacyWorkoutGuardrail: Guardrail? private var presetHistory: TemporaryScheduleOverrideHistory + var activeOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride ?? temporaryPresetsManager.scheduleOverride + } + var activePreset: SelectablePreset? { return allPresets.first(where: { $0.id == activeOverride?.presetId }) } @@ -229,16 +237,33 @@ class PresetsViewModel: ObservableObject { correctionRangeOverrides: CorrectionRangeOverrides?, presetsHistory: TemporaryScheduleOverrideHistory, preMealGuardrail: Guardrail?, - legacyWorkoutGuardrail: Guardrail? + legacyWorkoutGuardrail: Guardrail?, + temporaryPresetsManager: TemporaryPresetsManager ) { self.customPresets = customPresets self.correctionRangeOverrides = correctionRangeOverrides self.presetHistory = presetsHistory self.preMealGuardrail = preMealGuardrail self.legacyWorkoutGuardrail = legacyWorkoutGuardrail - - // TODO: If active preset changes, data store should update us. - activeOverride = presetsHistory.activeOverride(at: Date()) + self.temporaryPresetsManager = temporaryPresetsManager + } + + func startPreset(_ preset: SelectablePreset) { + switch preset { + case .custom(let temporaryScheduleOverridePreset): + temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) + case .preMeal: + temporaryPresetsManager.enablePreMealOverride(for: .hours(2)) // FIX TIME + case .legacyWorkout: + temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) // FIX TIME + } + } + + func endPreset() { + if case .preMeal(_, _) = activePreset { + temporaryPresetsManager.preMealOverride = nil + } else { + temporaryPresetsManager.scheduleOverride = nil + } } - } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 5c82a62441..c785f2a7ab 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -56,7 +56,8 @@ public protocol SettingsViewModelDelegate: AnyObject { var closedLoopDescriptiveText: String? { get } } -public class SettingsViewModel: ObservableObject { +@Observable +class SettingsViewModel { let alertPermissionsChecker: AlertPermissionsChecker @@ -81,53 +82,36 @@ public class SettingsViewModel: ObservableObject { let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? let presetHistory: TemporaryScheduleOverrideHistory - @Published private(set) var automaticDosingStatus: AutomaticDosingStatus + private(set) var automaticDosingStatus: AutomaticDosingStatus - @Published private(set) var lastLoopCompletion: Date? - @Published private(set) var mostRecentGlucoseDataDate: Date? - @Published private(set) var mostRecentPumpDataDate: Date? + private(set) var lastLoopCompletion: Date? + private(set) var mostRecentGlucoseDataDate: Date? + private(set) var mostRecentPumpDataDate: Date? + + var presetsViewModel: PresetsViewModel var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText } - @Published var automaticDosingStrategy: AutomaticDosingStrategy { + var automaticDosingStrategy: AutomaticDosingStrategy { didSet { delegate?.dosingStrategyChanged(automaticDosingStrategy) } } - @Published var closedLoopPreference: Bool { + var closedLoopPreference: Bool { didSet { delegate?.dosingEnabledChanged(closedLoopPreference) } } - var preMealGuardrail: Guardrail? { - guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { - return nil - } - return Guardrail.correctionRangeOverride( - for: .preMeal, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: therapySettings().suspendThreshold - ) - } - - var legacyWorkoutPresetGuardrail: Guardrail? { - guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { - return nil - } - return Guardrail.correctionRangeOverride( - for: .workout, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: therapySettings().suspendThreshold - ) - } - + + var preMealGuardrail: Guardrail? + var legacyWorkoutPresetGuardrail: Guardrail? - weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? + @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) @@ -148,8 +132,9 @@ public class SettingsViewModel: ObservableObject { return LoopCompletionFreshness(age: age) } - lazy private var cancellables = Set() + @ObservationIgnored lazy private var cancellables = Set() + @MainActor public init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, versionUpdateViewModel: VersionUpdateViewModel, @@ -169,6 +154,7 @@ public class SettingsViewModel: ObservableObject { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, + temporaryPresetsManager: TemporaryPresetsManager, delegate: SettingsViewModelDelegate? ) { self.alertPermissionsChecker = alertPermissionsChecker @@ -191,28 +177,29 @@ public class SettingsViewModel: ObservableObject { self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory self.delegate = delegate + + var preMealGuardrail: Guardrail? + var legacyWorkoutPresetGuardrail: Guardrail? + if let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() { + preMealGuardrail = Guardrail.correctionRangeOverride( + for: .preMeal, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: therapySettings().suspendThreshold + ) + self.preMealGuardrail = preMealGuardrail + self.legacyWorkoutPresetGuardrail = legacyWorkoutPresetGuardrail + } + + self.presetsViewModel = PresetsViewModel( + customPresets: therapySettings().overridePresets ?? [], + correctionRangeOverrides: therapySettings().correctionRangeOverrides, + presetsHistory: presetHistory, + preMealGuardrail: preMealGuardrail, + legacyWorkoutGuardrail: legacyWorkoutPresetGuardrail, + temporaryPresetsManager: temporaryPresetsManager + ) // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) - alertPermissionsChecker.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - alertMuter.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - pumpManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - cgmManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - automaticDosingStatus.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) lastLoopCompletion .assign(to: \.lastLoopCompletion, on: self) .store(in: &cancellables) @@ -231,6 +218,34 @@ extension SettingsViewModel { fileprivate class FakeLastLoopCompletionPublisher { @Published var mockLastLoopCompletion: Date? = nil } + + fileprivate class FakeSettingsProvider: SettingsProvider { + let settings = StoredSettings() + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + [] + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + DosingLimits() + } + + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) {} + + + } static var preview: SettingsViewModel { return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), @@ -252,6 +267,7 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), + temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()), delegate: nil ) } diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index eae6393c6d..2836be779a 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -23,12 +23,6 @@ struct PresetCard: View { let correctionRange: ClosedRange? let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? - - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - return formatter - } var presetTitle: some View { HStack(spacing: 6) { @@ -54,90 +48,6 @@ struct PresetCard: View { .foregroundColor(.secondary) .accessibilityLabel(Text(duration.accessibilityLabel)) } - - var overallInsulinView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! - Group { Text(percent).bold() + Text(" of scheduled") } - .font(.subheadline) - .accessibilitySortPriority(1) - } - .accessibilityElement(children: .contain) - } - - func guidanceColor(for classification: SafetyClassification?) -> Color? { - guard let classification else { return nil } - - switch classification { - case .outsideRecommendedRange(let threshold): - switch threshold { - case .aboveRecommended, .belowRecommended: - return guidanceColors.warning - case .maximum, .minimum: - return guidanceColors.critical - } - case .withinRecommendedRange: - return nil - } - } - - func annotatedRangeText(target: ClosedRange) -> some View { - - let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary - let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary - - let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") - .foregroundStyle(upperColor) - let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) - .foregroundStyle(lowerColor) - .bold() - let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) - .foregroundStyle(upperColor) - .bold() - let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") - - let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange - let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange - - return Group { - switch (lowerClassification, upperClassification) { - case (.withinRecommendedRange, .withinRecommendedRange): - lower + Text(" - ") + upper + units - case (.withinRecommendedRange, .outsideRecommendedRange): - lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units - case (.outsideRecommendedRange, .outsideRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units - case (.outsideRecommendedRange, .withinRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units - } - } - } - - var correctionRangeView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - Group { - if let target = correctionRange { - annotatedRangeText(target: target) - } else { - Text("Scheduled Range") - .bold() - } - } - .font(.subheadline) - .accessibilitySortPriority(1) - } - .accessibilityElement(children: .contain) - } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -184,21 +94,11 @@ struct PresetCard: View { Divider() .padding(.horizontal, -10) - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - overallInsulinView - - Spacer() - - correctionRangeView - } - - VStack(alignment: .leading, spacing: 16) { - overallInsulinView - - correctionRangeView - } - } + PresetStatsView( + insulinSensitivityMultiplier: insulinSensitivityMultiplier, + correctionRange: correctionRange, + guardrail: guardrail + ) } .padding(10) .background(RoundedRectangle(cornerRadius: 8) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift new file mode 100644 index 0000000000..2d65e81590 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -0,0 +1,175 @@ +// +// PresetDetentView.swift +// Loop +// +// Created by Cameron Ingham on 12/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetDetentView: View { + + enum Operation { + case start + case end + } + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismiss) private var dismiss + + let preset: SelectablePreset + let viewModel: PresetsViewModel + + let activeOverride: TemporaryScheduleOverride? + + init(viewModel: PresetsViewModel, preset: SelectablePreset) { + self.viewModel = viewModel + self.preset = preset + + self.activeOverride = viewModel.temporaryPresetsManager.preMealOverride ?? viewModel.temporaryPresetsManager.scheduleOverride + } + + var operation: Operation { + if activeOverride?.presetId == preset.id { + return .end + } else { + return .start + } + } + + private func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + switch preset.icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) + } + + Text(preset.name) + .font(font) + .fontWeight(.semibold) + } + } + + @ViewBuilder + private var subtitle: some View { + Group { + switch operation { + case .start: + Text("Duration: \(preset.duration.localizedTitle)") + case .end: + if let activeOverride { + if activeOverride.presetId == preset.id { + switch activeOverride.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: activeOverride.activeInterval.end, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText)) + case .indefinite: + EmptyView() + } + } else { + let startTimeText = DateFormatter.localizedString(from: activeOverride.startDate, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + } + } + .font(.subheadline) + } + + @ViewBuilder + var actionArea: some View { + VStack(spacing: 12) { + switch operation { + case .start: + Button("Start Preset") { + dismiss() + viewModel.startPreset(preset) + } + .buttonStyle(ActionButtonStyle()) + case .end: + Button("End Preset") { + dismiss() + viewModel.endPreset() + } + .buttonStyle(ActionButtonStyle(.destructive)) + + NavigationLink("Adjust Preset Duration") { + ZStack { + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 24) { + title(font: .largeTitle, iconSize: 36) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + DatePicker("On until", selection: .constant(Date()), displayedComponents: .hourAndMinute) + .padding(6) + .padding(.leading, 10) + .background(Color.white.cornerRadius(10)) + + Spacer() + } + .padding(.horizontal) + } + } + .buttonStyle(ActionButtonStyle(.tertiary)) + } + + Button("Close") { + dismiss() + } + .tint(.accentColor) + .fontWeight(.semibold) + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + VStack(spacing: 16) { + VStack(spacing: 4) { + title(font: .title2, iconSize: 20) + subtitle + } + + if operation == .start { + Button { + print("Edit \(preset.name)") + } label: { + Group { + Text(Image(systemName: "pencil")) + Text(" ") + Text("Edit Preset") + } + .font(.subheadline) + } + .tint(.accentColor) + .padding(.bottom, -8) + } + } + + Divider() + + PresetStatsView( + insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, + correctionRange: preset.correctionRange, + guardrail: preset.guardrail + ) + + actionArea + } + .toolbar(.hidden) + .padding(.top) + .padding(16) + .presentationHuggingDetent() + } + } +} diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift new file mode 100644 index 0000000000..6cb5558e64 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -0,0 +1,128 @@ +// +// PresetStatsView.swift +// Loop +// +// Created by Cameron Ingham on 12/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetStatsView: View { + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let insulinSensitivityMultiplier: Double? + let correctionRange: ClosedRange? + let guardrail: Guardrail? + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + var overallInsulinView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! + Group { Text(percent).bold() + Text(" of scheduled") } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + func guidanceColor(for classification: SafetyClassification?) -> Color? { + guard let classification else { return nil } + + switch classification { + case .outsideRecommendedRange(let threshold): + switch threshold { + case .aboveRecommended, .belowRecommended: + return guidanceColors.warning + case .maximum, .minimum: + return guidanceColors.critical + } + case .withinRecommendedRange: + return nil + } + } + + func annotatedRangeText(target: ClosedRange) -> some View { + let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary + let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary + + let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") + .foregroundStyle(upperColor) + let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) + .foregroundStyle(lowerColor) + .bold() + let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) + .foregroundStyle(upperColor) + .bold() + let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") + + let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange + let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange + + return Group { + switch (lowerClassification, upperClassification) { + case (.withinRecommendedRange, .withinRecommendedRange): + lower + Text(" - ") + upper + units + case (.withinRecommendedRange, .outsideRecommendedRange): + lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units + case (.outsideRecommendedRange, .outsideRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units + case (.outsideRecommendedRange, .withinRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units + } + } + } + + var correctionRangeView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + Group { + if let target = correctionRange { + annotatedRangeText(target: target) + } else { + Text("Scheduled Range") + .bold() + } + } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + overallInsulinView + + Spacer() + + correctionRangeView + } + + VStack(alignment: .leading, spacing: 16) { + overallInsulinView + + correctionRangeView + } + } + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 8c6564b96a..3347bb358b 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -6,8 +6,9 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import Foundation +import LoopAlgorithm import LoopKit +import LoopKitUI import SwiftUI enum PresetSortOption: Int, CaseIterable { @@ -29,19 +30,19 @@ enum PresetSortOption: Int, CaseIterable { struct PresetsView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel: PresetsViewModel + @State private var viewModel: PresetsViewModel @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State var showTraining: Bool = false - var isDescending: Bool { !viewModel.presetsSortAscending } init(viewModel: PresetsViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) + self.viewModel = viewModel } var presetsSorted: [SelectablePreset] { @@ -73,6 +74,9 @@ struct PresetsView: View { activePreset, expectedEndTime: viewModel.activeOverride?.expectedEndTime ) +// .onTapGesture { +// viewModel.pendingPreset = activePreset +// } } // All Presets Section @@ -99,6 +103,9 @@ struct PresetsView: View { PresetCard(preset) .background(Color.white) .cornerRadius(12) +// .onTapGesture { +// viewModel.pendingPreset = preset +// } } } } @@ -154,15 +161,17 @@ struct PresetsView: View { .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) } - + .sheet(item: $viewModel.pendingPreset) { preset in + PresetDetentView( + viewModel: viewModel, + preset: preset + ) + } .sheet(isPresented: $showTraining) { PresetsTrainingView { viewModel.hasCompletedTraining = true } } - .onAppear { // TODO: Remove this - viewModel.hasCompletedTraining = false - } } private var sortMenu: some View { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 557472a10e..c2d9e4a407 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -12,8 +12,7 @@ import MockKit import SwiftUI import LoopUI - -public struct SettingsView: View { +struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) private var dismiss @Environment(\.appName) private var appName @@ -23,7 +22,7 @@ public struct SettingsView: View { @Environment(\.insulinTintColor) private var insulinTintColor @Environment(\.isInvestigationalDevice) private var isInvestigationalDevice - @ObservedObject var viewModel: SettingsViewModel + @State var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel enum Destination { @@ -63,7 +62,7 @@ public struct SettingsView: View { var localizedAppNameAndVersion: String - public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { + init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel self.versionUpdateViewModel = viewModel.versionUpdateViewModel self.localizedAppNameAndVersion = localizedAppNameAndVersion @@ -172,19 +171,9 @@ public struct SettingsView: View { } public var presetsView: some View { - PresetsView( - viewModel: PresetsViewModel( - customPresets: viewModel.therapySettings().overridePresets ?? [], - correctionRangeOverrides: viewModel.therapySettings().correctionRangeOverrides, - presetsHistory: viewModel.presetHistory, - preMealGuardrail: viewModel.preMealGuardrail, - legacyWorkoutGuardrail: viewModel.legacyWorkoutPresetGuardrail - ) - ) + PresetsView(viewModel: viewModel.presetsViewModel) } - - private func menuItemsForSection(name: String) -> some View { Section(header: SectionHeader(label: name)) { ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift new file mode 100644 index 0000000000..2dc0305523 --- /dev/null +++ b/Loop/Views/StatusTableView.swift @@ -0,0 +1,315 @@ +// +// StatusTableView.swift +// Loop +// +// Created by Cameron Ingham on 12/10/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI +import UIKit + +private struct WrappedStatusTableViewController: UIViewControllerRepresentable { + + private let alertPermissionsChecker: AlertPermissionsChecker + private let alertMuter: AlertMuter + private let automaticDosingStatus: AutomaticDosingStatus + private let deviceDataManager: DeviceDataManager + private let onboardingManager: OnboardingManager + private let supportManager: SupportManager + private let testingScenariosManager: TestingScenariosManager? + private let settingsManager: SettingsManager + private let temporaryPresetsManager: TemporaryPresetsManager + private let loopDataManager: LoopDataManager + private let diagnosticReportGenerator: DiagnosticReportGenerator + private let simulatedData: SimulatedData + private let analyticsServicesManager: AnalyticsServicesManager + private let servicesManager: ServicesManager + private let carbStore: CarbStore + private let doseStore: DoseStore + private let criticalEventLogExportManager: CriticalEventLogExportManager + private let bluetoothStateManager: BluetoothStateManager + + let viewController: StatusTableViewController + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.automaticDosingStatus = automaticDosingStatus + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) + let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + statusTableViewController.alertPermissionsChecker = alertPermissionsChecker + statusTableViewController.alertMuter = alertMuter + statusTableViewController.automaticDosingStatus = automaticDosingStatus + statusTableViewController.deviceManager = deviceDataManager + statusTableViewController.onboardingManager = onboardingManager + statusTableViewController.supportManager = supportManager + statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = diagnosticReportGenerator + statusTableViewController.simulatedData = simulatedData + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager + bluetoothStateManager.addBluetoothObserver(statusTableViewController) + + self.viewController = statusTableViewController + } + + func makeUIViewController(context: Context) -> some UIViewController { + viewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} +} + +struct StatusTableView: View { + + private let alertPermissionsChecker: AlertPermissionsChecker + private let alertMuter: AlertMuter + private let automaticDosingStatus: AutomaticDosingStatus + private let deviceDataManager: DeviceDataManager + private let displayGlucosePreference: DisplayGlucosePreference + private let onboardingManager: OnboardingManager + private let supportManager: SupportManager + private let testingScenariosManager: TestingScenariosManager? + private let settingsManager: SettingsManager + private let loopDataManager: LoopDataManager + private let diagnosticReportGenerator: DiagnosticReportGenerator + private let simulatedData: SimulatedData + private let analyticsServicesManager: AnalyticsServicesManager + private let servicesManager: ServicesManager + private let carbStore: CarbStore + private let doseStore: DoseStore + private let criticalEventLogExportManager: CriticalEventLogExportManager + private let bluetoothStateManager: BluetoothStateManager + + @Bindable var settingsViewModel: SettingsViewModel + + private let wrapped: WrappedStatusTableViewController + + var viewController: StatusTableViewController { + wrapped.viewController + } + + init(displayGlucosePreference: DisplayGlucosePreference, alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { + self.displayGlucosePreference = displayGlucosePreference + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.automaticDosingStatus = automaticDosingStatus + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + + self.wrapped = WrappedStatusTableViewController(alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertMuter, automaticDosingStatus: automaticDosingStatus, deviceDataManager: deviceDataManager, onboardingManager: onboardingManager, supportManager: supportManager, testingScenariosManager: testingScenariosManager, settingsManager: settingsManager, temporaryPresetsManager: temporaryPresetsManager, loopDataManager: loopDataManager, diagnosticReportGenerator: diagnosticReportGenerator, simulatedData: simulatedData, analyticsServicesManager: analyticsServicesManager, servicesManager: servicesManager, carbStore: carbStore, doseStore: doseStore, criticalEventLogExportManager: criticalEventLogExportManager, bluetoothStateManager: bluetoothStateManager) + + self.settingsViewModel = wrapped.viewController.settingsViewModel + } + + func isActive(action: ToolbarAction) -> Bool { + switch action { + case .addCarbs, .bolus, .settings: // No active states for these actions + return false + case .preMealPreset: + return settingsViewModel.presetsViewModel.temporaryPresetsManager.preMealTargetEnabled() + case .workoutPreset: + return settingsViewModel.presetsViewModel.temporaryPresetsManager.nonPreMealOverrideEnabled() + case .presets: + return settingsViewModel.presetsViewModel.activeOverride != nil + } + } + + func isDisabled(action: ToolbarAction) -> Bool { + switch action { + case .addCarbs, .bolus, .presets, .settings: + false + case .preMealPreset: + !(onboardingManager.isComplete && + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil) + case .workoutPreset: + viewController.workoutMode != nil && onboardingManager.isComplete + } + } + + var body: some View { + wrapped + .sheet(item: $settingsViewModel.presetsViewModel.pendingPreset) { preset in + PresetDetentView( + viewModel: settingsViewModel.presetsViewModel, + preset: preset + ) + } + .toolbar { + ToolbarItem(placement: .bottomBar) { + HStack(alignment: .bottom) { + ForEach(ToolbarAction.new) { action in + action.button( + showTitle: true, + isActive: isActive(action: action), + disabled: isDisabled(action: action) + ) { + switch action { + case .addCarbs: + viewController.userTappedAddCarbs() + case .preMealPreset: + viewController.togglePreMealMode() + case .bolus: + viewController.presentBolusScreen() + case .workoutPreset: + viewController.presentCustomPresets() + case .presets: + viewController.presentPresets() + case .settings: + viewController.presentSettings() + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 16) + .padding(.bottom, -8) + } + } + } +} + +enum ToolbarAction: String, Identifiable, CaseIterable { + case addCarbs + case preMealPreset + case bolus + case workoutPreset + case presets + case settings + + static var legacy: [ToolbarAction] = [ + .addCarbs, + .preMealPreset, + .bolus, + .workoutPreset, + .settings + ] + + static var new: [ToolbarAction] = [ + .addCarbs, + .bolus, + .presets, + .settings + ] + + var id: String { self.rawValue } + + @ViewBuilder + func icon(isActive: Bool) -> some View { + Group { + switch self { + case .addCarbs: + Image("carbs") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.carbs) + case .preMealPreset: + Image(isActive ? "Pre-Meal Selected" : "Pre-Meal") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.carbs) + case .bolus: + Image("bolus") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.insulin) + case .workoutPreset: + Image(isActive ? "workout-selected" : "workout") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.glucose) + case .presets: + Image(isActive ? "presets-selected" : "presets") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.presets) + case .settings: + Image("settings") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color(UIColor.secondaryLabel)) + } + } + .frame(width: 32, height: 32) + .aspectRatio(contentMode: .fit) + } + + @ViewBuilder + var title: some View { + Group { + switch self { + case .addCarbs: + Text("Add Carbs", comment: "The label of the carb entry button") + case .preMealPreset: + Text("Pre-Meal Preset", comment: "The label of the pre-meal mode toggle button") + case .bolus: + Text("Bolus", comment: "The label of the bolus entry button") + case .workoutPreset: + Text("Workout Preset", comment: "The label of the workout mode toggle button") + case .presets: + Text("Presets", comment: "The label of the presets button") + case .settings: + Text("Settings", comment: "The label of the settings button") + } + } + .foregroundStyle(.secondary) + .font(.footnote) + } + + @ViewBuilder + func button(showTitle: Bool, isActive: Bool, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 4) { + icon(isActive: isActive) + + if showTitle { + title + } + } + .animation(.default, value: isActive) + .padding(.vertical) + } + .buttonStyle(.plain) + .disabled(disabled) + .contentShape(Rectangle()) + } +} diff --git a/Loop/Views/TitleSubtitleTableViewCell.swift b/Loop/Views/TitleSubtitleTableViewCell.swift index d9e24e7185..ce1dbe815a 100644 --- a/Loop/Views/TitleSubtitleTableViewCell.swift +++ b/Loop/Views/TitleSubtitleTableViewCell.swift @@ -25,7 +25,7 @@ class TitleSubtitleTableViewCell: UITableViewCell { gradient.frame = bounds } - private lazy var gradient = CAGradientLayer() + private(set) lazy var gradient = CAGradientLayer() override func awakeFromNib() { super.awakeFromNib() diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index 9b474b42c5..9f6885a536 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -18,7 +18,7 @@ extension UIColor { @nonobjc static let insulin = UIColor(named: "insulin") ?? systemOrange - @nonobjc static let presets = UIColor(named: "presets") ?? systemTeal + @nonobjc public static let presets = UIColor(named: "presets") ?? systemTeal // The loopAccent color is intended to be use as the app accent color. @nonobjc public static let loopAccent = UIColor(named: "accent") ?? systemBlue