From 6003381385c1b5d2381f789dc9e7552a352befea Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 16 Dec 2024 12:06:07 -0800 Subject: [PATCH] [LOOP-5056] Presets Homepage Updates - Part 2 (#734) --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/LoopAppManager.swift | 75 ++++- .../StatusTableViewController.swift | 262 ++++++------------ Loop/View Models/PresetsViewModel.swift | 23 +- Loop/View Models/ServicesViewModel.swift | 4 +- Loop/View Models/SettingsViewModel.swift | 13 +- .../Components/EditOverrideDurationView.swift | 60 ++++ .../Presets/Components/PresetDetentView.swift | 61 ++-- Loop/Views/Presets/PresetsHistoryView.swift | 98 +++++-- Loop/Views/Presets/PresetsView.swift | 20 +- Loop/Views/StatusTableView.swift | 182 ++++++------ 11 files changed, 458 insertions(+), 344 deletions(-) create mode 100644 Loop/Views/Presets/Components/EditOverrideDurationView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8a75eb14b3..64152d89d5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -272,6 +272,7 @@ 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 */; }; + 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.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 */; }; @@ -1152,6 +1153,7 @@ 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 = ""; }; + 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideDurationView.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; }; @@ -2524,6 +2526,7 @@ 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, + 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */, ); path = Components; sourceTree = ""; @@ -3530,6 +3533,7 @@ 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, + 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index d6b71ba930..1087ee8930 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -10,6 +10,7 @@ import UIKit import Intents import BackgroundTasks import Combine +import LoopTestingKit import LoopKit import LoopKitUI import MockKit @@ -540,13 +541,69 @@ class LoopAppManager: NSObject { } } } + + private lazy var settingsViewModel: SettingsViewModel = { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.pumpManager is TestingPumpManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingPumpData() + }} : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.cgmManager is TestingCGMManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingCGMData() + }} : nil + } + let pumpViewModel = PumpManagerViewModel( + image: { [weak self] in (self?.deviceDataManager.pumpManager as? PumpManagerUI)?.smallImage }, + name: { [weak self] in self?.deviceDataManager.pumpManager?.localizedTitle ?? "" }, + isSetUp: { [weak self] in self?.deviceDataManager.pumpManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availablePumpManagers, + deleteTestingDataFunc: deletePumpDataFunc + ) + + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceDataManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceDataManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceDataManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc + ) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.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: loopDataManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopDataManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopDataManager.$publishedMostRecentPumpDataDate, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceDataManager, + presetHistory: temporaryPresetsManager.overrideHistory, + temporaryPresetsManager: temporaryPresetsManager + ) + + viewModel.favoriteFoodInsightsDelegate = loopDataManager + + return viewModel + }() private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) - - let statusTableView = StatusTableView( - displayGlucosePreference: displayGlucosePreference, + + let viewModel = StatusTableViewModel( alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertManager.alertMuter, automaticDosingStatus: automaticDosingStatus, @@ -564,8 +621,16 @@ class LoopAppManager: NSObject { carbStore: carbStore, doseStore: doseStore, criticalEventLogExportManager: criticalEventLogExportManager, - bluetoothStateManager: bluetoothStateManager - ).edgesIgnoringSafeArea(.top) + bluetoothStateManager: bluetoothStateManager, + settingsViewModel: settingsViewModel + ) + + let statusTableView = StatusTableView(viewModel: viewModel) + .environmentObject(deviceDataManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus) + .edgesIgnoringSafeArea(.top) var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ea4611e955..014f942e3d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -68,75 +68,9 @@ final class StatusTableViewController: LoopChartsTableViewController { var doseStore: DoseStore! 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 - }() + + var settingsViewModel: SettingsViewModel! + var statusTableViewModel: StatusTableViewModel! lazy private var cancellables = Set() @@ -147,10 +81,21 @@ final class StatusTableViewController: LoopChartsTableViewController { super.viewDidLoad() setupToolbarItems() - + statusTableViewModel.settingsViewModel.delegate = self + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTap = { [weak self] in + self?.onPumpTapped() + } + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTap = { [weak self] in + self?.onCGMTapped() + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + } + tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) - tableView.register(AlertPermissionsDisabledWarningCell.self, forCellReuseIdentifier: AlertPermissionsDisabledWarningCell.className) - tableView.register(MuteAlertsWarningCell.self, forCellReuseIdentifier: MuteAlertsWarningCell.className) if FeatureFlags.predictedGlucoseChartClampEnabled { statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped @@ -1029,93 +974,9 @@ final class StatusTableViewController: LoopChartsTableViewController { return shouldShowStatus ? StatusRow.allCases.count : 0 } } - - private class AlertPermissionsDisabledWarningCell: UITableViewCell { - - var alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert? - - override func updateConfiguration(using state: UICellConfigurationState) { - guard let alert else { - return - } - - super.updateConfiguration(using: state) - - let adjustViewForNarrowDisplay = bounds.width < 350 - - var contentConfig = defaultContentConfiguration().updated(for: state) - let titleImageAttachment = NSTextAttachment() - titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) - let title = NSMutableAttributedString(string: alert.bannerTitle) - let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) - titleWithImage.append(title) - contentConfig.attributedText = titleWithImage - contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .bold) - contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = alert.bannerBody - contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.backgroundColor = .critical - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) - backgroundConfiguration?.cornerRadius = 10 - - let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) - let imageView = UIImageView(image: disclosureIndicator) - imageView.tintColor = .white - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } - } - - private class MuteAlertsWarningCell: UITableViewCell { - var formattedAlertMuteEndTime: String = NSLocalizedString("Unknown", comment: "label for when the alert mute end time is unknown") - - fileprivate class GradientView: UIView { - override static var layerClass: AnyClass { CAGradientLayer.self } - } - - override func updateConfiguration(using state: UICellConfigurationState) { - super.updateConfiguration(using: state) - - let adjustViewForNarrowDisplay = bounds.width < 350 - - var contentConfig = defaultContentConfiguration().updated(for: state) - let title = NSMutableAttributedString(string: NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) - let image = UIImage(systemName: "speaker.slash.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 25, weight: .thin, scale: .large)) - contentConfig.image = image - contentConfig.imageProperties.tintColor = .white - contentConfig.attributedText = title - contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .semibold) - contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), formattedAlertMuteEndTime) - contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - let backgroundGradient = GradientView() - (backgroundGradient.layer as? CAGradientLayer)?.colors = [UIColor.warning.cgColor, UIColor.warning.withAlphaComponent(0.9).cgColor] - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.customView = backgroundGradient - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 5, trailing: 5) - backgroundConfiguration?.cornerRadius = 10 - - let unmuteIndicator = UIImage(systemName: "stop.circle")?.withTintColor(.white) - let imageView = UIImageView(image: unmuteIndicator) - imageView.tintColor = .white - imageView.frame.size = CGSize(width: 30, height: 30) - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } + + private class GradientView: UIView { + override static var layerClass: AnyClass { CAGradientLayer.self } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -1164,12 +1025,16 @@ final class StatusTableViewController: LoopChartsTableViewController { } 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 + if let preset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == override.presetId }), case .preMeal(_, _) = preset { + cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") + } else { + 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) @@ -1179,16 +1044,59 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .alertWarning: - if alertPermissionsChecker.showWarning { - var cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell - cell.alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell - cell.formattedAlertMuteEndTime = alertMuter.formattedEndTime - cell.selectionStyle = .none - return cell + let cell = UITableViewCell() + let alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) + + cell.contentConfiguration = UIHostingConfiguration { + if alertPermissionsChecker.showWarning { + if let alert { + HStack { + VStack(alignment: .leading) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(" ") + Text(alert.bannerTitle) + .font(.headline.bold()) + + Text(alert.bannerBody) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "chevron.right")) + .font(.headline) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.critical.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } + } else { + HStack { + VStack(alignment: .leading) { + Text(Image(systemName: "speaker.slash.fill")) + Text(" ") + Text(NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) + .font(.headline.bold()) + + Text(String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), alertMuter.formattedEndTime)) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "stop.circle")) + .font(.title) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.warning.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } } + .margins(.all, 0) + + cell.backgroundColor = .secondarySystemBackground + + return cell case .hud: let cell = tableView.dequeueReusableCell(withIdentifier: HUDViewTableViewCell.className, for: indexPath) as! HUDViewTableViewCell hudView = cell.hudView @@ -1363,7 +1271,9 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob, .dose, .cob: return max(106, 0.21 * availableSize) } - case .presets, .hud, .status, .alertWarning: + case .alertWarning: + return UITableView.automaticDimension + case .presets, .hud, .status: return UITableView.automaticDimension } } @@ -1371,7 +1281,7 @@ final class StatusTableViewController: LoopChartsTableViewController { 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 }) + statusTableViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index c48562e565..3822ff139c 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -159,6 +159,25 @@ enum SelectablePreset: Hashable, Identifiable { return .distantPast } } + + func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + switch 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(name) + .font(font) + .fontWeight(.semibold) + } + } } @MainActor @@ -253,9 +272,9 @@ public class PresetsViewModel { case .custom(let temporaryScheduleOverridePreset): temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) case .preMeal: - temporaryPresetsManager.enablePreMealOverride(for: .hours(2)) // FIX TIME + temporaryPresetsManager.enablePreMealOverride(for: .hours(1)) case .legacyWorkout: - temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) // FIX TIME + temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) } } diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index 3021247b2c..6878a5fa5f 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -33,12 +33,10 @@ public class ServicesViewModel: ObservableObject { init(showServices: Bool, availableServices: @escaping () -> [ServiceDescriptor], - activeServices: @escaping () -> [Service], - delegate: ServicesViewModelDelegate? = nil) { + activeServices: @escaping () -> [Service]) { self.showServices = showServices self.activeServices = activeServices self.availableServices = availableServices - self.delegate = delegate } func didTapService(_ index: Int) { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index c785f2a7ab..0521909082 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -20,8 +20,8 @@ public class DeviceViewModel: ObservableObject { let image: () -> UIImage? let name: () -> String let deleteTestingDataFunc: () -> DeleteTestingDataFunc? - let didTap: () -> Void - let didTapAdd: (_ device: T) -> Void + var didTap: () -> Void + var didTapAdd: (_ device: T) -> Void var isTestingDevice: Bool { return deleteTestingDataFunc() != nil } @@ -65,7 +65,7 @@ class SettingsViewModel { let versionUpdateViewModel: VersionUpdateViewModel - private weak var delegate: SettingsViewModelDelegate? + weak var delegate: SettingsViewModelDelegate? func didTapIssueReport() { delegate?.didTapIssueReport() @@ -154,8 +154,7 @@ class SettingsViewModel { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, - temporaryPresetsManager: TemporaryPresetsManager, - delegate: SettingsViewModelDelegate? + temporaryPresetsManager: TemporaryPresetsManager ) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter @@ -176,7 +175,6 @@ class SettingsViewModel { self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory - self.delegate = delegate var preMealGuardrail: Guardrail? var legacyWorkoutPresetGuardrail: Guardrail? @@ -267,8 +265,7 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), - temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()), - delegate: nil + temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()) ) } } diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditOverrideDurationView.swift new file mode 100644 index 0000000000..5b871d1627 --- /dev/null +++ b/Loop/Views/Presets/Components/EditOverrideDurationView.swift @@ -0,0 +1,60 @@ +// +// EditPresetDurationView.swift +// Loop +// +// Created by Cameron Ingham on 12/12/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +struct EditOverrideDurationView: View { + + let viewModel: PresetsViewModel + let override: TemporaryScheduleOverride + @State var dateSelection: Date + + init(override: TemporaryScheduleOverride, viewModel: PresetsViewModel) { + self.override = override + self.viewModel = viewModel + dateSelection = override.actualEndDate + } + + var preset: SelectablePreset? { + viewModel.allPresets.first(where: { $0.id == override.presetId }) + } + + var body: some View { + ZStack { + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + VStack(spacing: 24) { + preset?.title(font: .largeTitle, iconSize: 36) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + DatePicker("On until", selection: $dateSelection, displayedComponents: .hourAndMinute) + .padding(6) + .padding(.leading, 10) + .background(Color.white.cornerRadius(10)) + + Spacer() + } + .padding(.horizontal) + + Button("Save") { + // + } + .buttonStyle(ActionButtonStyle()) + .padding([.top, .horizontal]) + .background(Color.white) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: -4) + .disabled(dateSelection == override.actualEndDate) + } + } + } +} diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 2d65e81590..a4b6640515 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -32,6 +32,11 @@ struct PresetDetentView: View { self.activeOverride = viewModel.temporaryPresetsManager.preMealOverride ?? viewModel.temporaryPresetsManager.scheduleOverride } + init?(viewModel: PresetsViewModel) { + guard let preset = viewModel.pendingPreset else { return nil } + self.init(viewModel: viewModel, preset: preset) + } + var operation: Operation { if activeOverride?.presetId == preset.id { return .end @@ -40,25 +45,6 @@ struct PresetDetentView: View { } } - 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 { @@ -95,6 +81,7 @@ struct PresetDetentView: View { viewModel.startPreset(preset) } .buttonStyle(ActionButtonStyle()) + .disabled(viewModel.activePreset != nil && preset != viewModel.activePreset) case .end: Button("End Preset") { dismiss() @@ -102,27 +89,25 @@ struct PresetDetentView: View { } .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() + + switch preset { + case .custom: + NavigationLink("Adjust Preset Duration") { + if let activeOverride { + EditOverrideDurationView(override: activeOverride, viewModel: viewModel) + } + } + .buttonStyle(ActionButtonStyle(.tertiary)) + case .preMeal: + EmptyView() + case .legacyWorkout: + NavigationLink("Adjust Preset Duration") { + if let activeOverride { + EditOverrideDurationView(override: activeOverride, viewModel: viewModel) } - .padding(.horizontal) } + .buttonStyle(ActionButtonStyle(.tertiary)) } - .buttonStyle(ActionButtonStyle(.tertiary)) } Button("Close") { @@ -138,7 +123,7 @@ struct PresetDetentView: View { VStack(spacing: 24) { VStack(spacing: 16) { VStack(spacing: 4) { - title(font: .title2, iconSize: 20) + preset.title(font: .title2, iconSize: 20) subtitle } diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 265f0f6f40..53c5f21985 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -11,42 +11,100 @@ import SwiftUI struct PresetsHistoryView: View { + let viewModel: PresetsViewModel @State var history: TemporaryScheduleOverrideHistory - init () { + init (viewModel: PresetsViewModel) { + self.viewModel = viewModel self.history = TemporaryScheduleOverrideHistoryContainer.shared.fetch() } let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .short + formatter.unitsStyle = .abbreviated return formatter }() + var overridesByDate: Dictionary { + Dictionary( + grouping: history.recentEvents + .map(\.override) + .filter({ !$0.isActive() }) + .sorted(by: { $0.actualEndDate > $1.actualEndDate }) + ) { override in + override.startDate.formatted(date: .abbreviated, time: .omitted) + } + } + var body: some View { List { - Section("Recent Events") { - ForEach(history.recentEvents.sorted(by: { $0.override.actualEndDate > $1.override.actualEndDate }), id: \.self) { recentEvent in - - let scheduledDuration = recentEvent.override.duration.timeInterval - let actualDuration = recentEvent.override.actualDuration.timeInterval - - let value = scheduledDuration == actualDuration ? "\(formatter.string(from: scheduledDuration) ?? "")" : "\(formatter.string(from: actualDuration) ?? "") / \(formatter.string(from: scheduledDuration) ?? "")" - - LabeledContent { - Text(value) - } label: { - Text(recentEvent.override.presetId) - - Text(recentEvent.override.startDate.formatted(date: .abbreviated, time: .shortened)) + ForEach(Array(overridesByDate.keys)) { date in + Section(date) { + ForEach(overridesByDate[date] ?? [], id: \.self) { override in + LabeledContent { + VStack(alignment: .trailing, spacing: 8) { + Text("Duration") + .font(.footnote) + .foregroundStyle(.secondary) + + durationText(for: override) + } + } label: { + VStack(alignment: .leading, spacing: 8) { + Text(override.startDate.formatted(date: .omitted, time: .shortened)) + .font(.footnote) + .foregroundStyle(.secondary) + + if let preset = viewModel.allPresets.first(where: { $0.id == override.presetId }) { + HStack(spacing: 4) { + 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: 22), height: UIFontMetrics.default.scaledValue(for: 22)) + } + + Text(preset.name) + .fontWeight(.semibold) + } + } + } + } } } } } + .navigationTitle("Recent Events") + } + + @ViewBuilder + func durationText(for override: TemporaryScheduleOverride) -> some View { + switch override.duration { + case let .finite(scheduledDuration): + let actualDuration = override.actualDuration.timeInterval + if let scheduledDurationString = formatter.string(from: scheduledDuration), let actualDurationString = formatter.string(from: actualDuration) { + if scheduledDuration <= actualDuration { + Text(actualDurationString) + .foregroundStyle(.primary) + } else { + Text(actualDurationString) + .foregroundStyle(.primary) + .fontWeight(.semibold) + + Text(" / ") + + Text(scheduledDurationString) + } + } + case .indefinite: + if let durationString = formatter.string(from: override.actualDuration.timeInterval) { + Text(durationString) + .foregroundStyle(.primary) + .fontWeight(.semibold) + } + } } -} - -#Preview { - PresetsHistoryView() } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 3347bb358b..4b8ac975bf 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -74,9 +74,9 @@ struct PresetsView: View { activePreset, expectedEndTime: viewModel.activeOverride?.expectedEndTime ) -// .onTapGesture { -// viewModel.pendingPreset = activePreset -// } + .onTapGesture { + viewModel.pendingPreset = activePreset + } } // All Presets Section @@ -103,9 +103,9 @@ struct PresetsView: View { PresetCard(preset) .background(Color.white) .cornerRadius(12) -// .onTapGesture { -// viewModel.pendingPreset = preset -// } + .onTapGesture { + viewModel.pendingPreset = preset + } } } } @@ -115,7 +115,7 @@ struct PresetsView: View { Text("Support") .font(.title2.bold()) - NavigationLink(destination: PresetsHistoryView()) { + NavigationLink(destination: PresetsHistoryView(viewModel: viewModel)) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) @@ -137,7 +137,9 @@ struct PresetsView: View { .frame(maxWidth: .infinity)) if viewModel.hasCompletedTraining { - NavigationLink(destination: PresetsTrainingView { viewModel.hasCompletedTraining = true }) { + Button { + showTraining = true + } label: { HStack { Text("Review Presets Training") Spacer() @@ -156,6 +158,8 @@ struct PresetsView: View { } } .padding() + .animation(.default, value: viewModel.hasCompletedTraining) + .animation(.default, value: viewModel.activeOverride) } .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 2dc0305523..e2da6fae75 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -32,10 +32,12 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { private let doseStore: DoseStore private let criticalEventLogExportManager: CriticalEventLogExportManager private let bluetoothStateManager: BluetoothStateManager + private let settingsViewModel: SettingsViewModel + private let statusTableViewModel: StatusTableViewModel 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) { + 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, settingsViewModel: SettingsViewModel, statusTableViewModel: StatusTableViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -54,6 +56,8 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { self.doseStore = doseStore self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + self.statusTableViewModel = statusTableViewModel let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController @@ -74,6 +78,8 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { statusTableViewController.carbStore = carbStore statusTableViewController.doseStore = doseStore statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager + statusTableViewController.settingsViewModel = settingsViewModel + statusTableViewController.statusTableViewModel = statusTableViewModel bluetoothStateManager.addBluetoothObserver(statusTableViewController) self.viewController = statusTableViewController @@ -86,37 +92,36 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} } -struct StatusTableView: View { +@MainActor +@Observable +class StatusTableViewModel { + let alertPermissionsChecker: AlertPermissionsChecker + let alertMuter: AlertMuter + let deviceDataManager: DeviceDataManager + let supportManager: SupportManager + let testingScenariosManager: TestingScenariosManager? + let loopDataManager: LoopDataManager + let diagnosticReportGenerator: DiagnosticReportGenerator + let simulatedData: SimulatedData + let analyticsServicesManager: AnalyticsServicesManager + let servicesManager: ServicesManager + let carbStore: CarbStore + let doseStore: DoseStore + let criticalEventLogExportManager: CriticalEventLogExportManager + let bluetoothStateManager: BluetoothStateManager + let settingsManager: SettingsManager + let automaticDosingStatus: AutomaticDosingStatus + let onboardingManager: OnboardingManager + let temporaryPresetsManager: TemporaryPresetsManager + let settingsViewModel: SettingsViewModel - 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 + var pendingPreset: SelectablePreset? { + didSet { + settingsViewModel.presetsViewModel.pendingPreset = pendingPreset + } } - 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 + 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, settingsViewModel: SettingsViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -124,6 +129,7 @@ struct StatusTableView: View { self.onboardingManager = onboardingManager self.supportManager = supportManager self.testingScenariosManager = testingScenariosManager + self.temporaryPresetsManager = temporaryPresetsManager self.settingsManager = settingsManager self.loopDataManager = loopDataManager self.diagnosticReportGenerator = diagnosticReportGenerator @@ -134,50 +140,79 @@ struct StatusTableView: View { self.doseStore = doseStore self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + } +} + +struct StatusTableView: View { + + private let wrapped: WrappedStatusTableViewController + + var viewController: StatusTableViewController { + wrapped.viewController + } + + @ViewBuilder + var wrappedView: some View { wrapped } + + @Bindable var viewModel: StatusTableViewModel + + init(viewModel: StatusTableViewModel) { + self.viewModel = viewModel - 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 + self.wrapped = WrappedStatusTableViewController( + alertPermissionsChecker: viewModel.alertPermissionsChecker, + alertMuter: viewModel.alertMuter, + automaticDosingStatus: viewModel.automaticDosingStatus, + deviceDataManager: viewModel.deviceDataManager, + onboardingManager: viewModel.onboardingManager, + supportManager: viewModel.supportManager, + testingScenariosManager: viewModel.testingScenariosManager, + settingsManager: viewModel.settingsManager, + temporaryPresetsManager: viewModel.temporaryPresetsManager, + loopDataManager: viewModel.loopDataManager, + diagnosticReportGenerator: viewModel.diagnosticReportGenerator, + simulatedData: viewModel.simulatedData, + analyticsServicesManager: viewModel.analyticsServicesManager, + servicesManager: viewModel.servicesManager, + carbStore: viewModel.carbStore, + doseStore: viewModel.doseStore, + criticalEventLogExportManager: viewModel.criticalEventLogExportManager, + bluetoothStateManager: viewModel.bluetoothStateManager, + settingsViewModel: viewModel.settingsViewModel, + statusTableViewModel: viewModel + ) } 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 + return viewModel.settingsViewModel.presetsViewModel.activeOverride != nil } } func isDisabled(action: ToolbarAction) -> Bool { switch action { - case .addCarbs, .bolus, .presets, .settings: + case .addCarbs, .bolus, .settings: false - case .preMealPreset: - !(onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && settingsManager.settings.preMealTargetRange != nil) - case .workoutPreset: - viewController.workoutMode != nil && onboardingManager.isComplete + case .presets: + !viewModel.onboardingManager.isComplete } } var body: some View { - wrapped - .sheet(item: $settingsViewModel.presetsViewModel.pendingPreset) { preset in + wrappedView + .sheet(item: $viewModel.pendingPreset) { _ in PresetDetentView( - viewModel: settingsViewModel.presetsViewModel, - preset: preset + viewModel: viewModel.settingsViewModel.presetsViewModel ) } .toolbar { ToolbarItem(placement: .bottomBar) { HStack(alignment: .bottom) { - ForEach(ToolbarAction.new) { action in + ForEach(ToolbarAction.allCases) { action in action.button( showTitle: true, isActive: isActive(action: action), @@ -186,12 +221,8 @@ struct StatusTableView: View { switch action { case .addCarbs: viewController.userTappedAddCarbs() - case .preMealPreset: - viewController.togglePreMealMode() case .bolus: viewController.presentBolusScreen() - case .workoutPreset: - viewController.presentCustomPresets() case .presets: viewController.presentPresets() case .settings: @@ -210,29 +241,25 @@ struct StatusTableView: View { 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 } + var accessibilityIdentifier: String { + switch self { + case .addCarbs: + "statusTableViewControllerCarbsButton" + case .bolus: + "statusTableViewControllerBolusButton" + case .presets: + "statusTableViewPresetsButton" + case .settings: + "statusTableViewControllerSettingsButton" + } + } + @ViewBuilder func icon(isActive: Bool) -> some View { Group { @@ -242,21 +269,11 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .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() @@ -279,12 +296,8 @@ enum ToolbarAction: String, Identifiable, CaseIterable { 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: @@ -311,5 +324,6 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .buttonStyle(.plain) .disabled(disabled) .contentShape(Rectangle()) + .accessibilityIdentifier(accessibilityIdentifier) } }