diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index 3b83aa11d8..dbec0093fc 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -88,6 +88,8 @@ extension UIAlertController { } )) } + + addCancelAction() } /// Initializes an action sheet-styled controller for selecting a CGMManager @@ -112,6 +114,8 @@ extension UIAlertController { } )) } + + addCancelAction() } internal convenience init(deleteCGMManagerHandler handler: @escaping (_ isDeleted: Bool) -> Void) { @@ -141,10 +145,12 @@ extension UIAlertController { /// - selectionHandler: A closure to execute when a service is selected. /// - identifier: The identifier of the selected service. internal convenience init(availableServices: [ServiceDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { + let preferredStyle: UIAlertController.Style = .alert + self.init( title: NSLocalizedString("Add Service", comment: "Action sheet title selecting service"), message: nil, - preferredStyle: .actionSheet + preferredStyle: preferredStyle ) for availableService in availableServices { @@ -156,11 +162,15 @@ extension UIAlertController { } )) } + + if #available(iOS 26.0, *) { + addCancelAction() + } } internal func addCancelAction(handler: ((UIAlertAction) -> Void)? = nil) { let cancel = NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet") - addAction(UIAlertAction(title: cancel, style: .cancel, handler: handler)) + addAction(UIAlertAction(title: cancel, style: .destructive, handler: handler)) } } diff --git a/Loop/Info.plist b/Loop/Info.plist index db76e6e846..d6ee292459 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -89,8 +89,6 @@ remote-notification audio - UIDesignRequiresCompatibility - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index bae4512e6a..fcd5edcbff 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -128,7 +128,7 @@ extension AlertPermissionsChecker { message: Self.unsafeNotificationPermissionsAlertContent.body, preferredStyle: .alert) let titleImageAttachment = NSTextAttachment() - titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical, renderingMode: .alwaysOriginal) titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 94df013244..37751e093b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -273,41 +273,76 @@ final class StatusTableViewController: LoopChartsTableViewController { deviceManager.pumpManagerHUDProvider?.visible = active && onscreen } + private lazy var carbEntryButton = makeToolbarButton(imageNamed: "carbs", tintColor: .carbTintColor, action: #selector(userTappedAddCarbs)) + private lazy var bolusButton = makeToolbarButton(imageNamed: "bolus", tintColor: .insulinTintColor, action: #selector(presentBolusScreen)) + private lazy var settingsButton = makeToolbarButton(imageNamed: "settings", tintColor: .secondaryLabel, action: #selector(onSettingsTapped)) + + private lazy var workoutButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = .glucoseTintColor + button.addTarget(self, action: #selector(toggleWorkoutMode(_:)), for: .touchUpInside) + button.constrainToToolbarIconSize() + return button + }() + + private func makeToolbarButton(imageNamed name: String, tintColor: UIColor, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setImage(UIImage(named: name), for: .normal) + button.tintColor = tintColor + button.addTarget(self, action: action, for: .touchUpInside) + button.constrainToToolbarIconSize() + return button + } + private func setupToolbarItems() { - let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) - let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) - let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) - let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) - + let carbs = UIBarButtonItem(customView: carbEntryButton) + let bolus = UIBarButtonItem(customView: bolusButton) + let settings = UIBarButtonItem(customView: settingsButton) + + carbs.title = "Add Meal" + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) - let workout = createWorkoutButtonItem(selected: false, isEnabled: true) - toolbarItems = [ - carbs, - space, - preMeal, - space, - bolus, - space, - workout, - space, - settings - ] - } + updateWorkoutButton(selected: false, isEnabled: true) + let workout = UIBarButtonItem(customView: workoutButton) + + func flexibleSpace() -> UIBarButtonItem { + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + } + if #available(iOS 26, *) { + toolbarItems = [carbs, preMeal, bolus, workout, settings] + } else { + toolbarItems = [ + carbs, + flexibleSpace(), + preMeal, + flexibleSpace(), + bolus, + flexibleSpace(), + workout, + flexibleSpace(), + settings + ] + } + } + private func updateToolbarItems() { let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true - toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") - toolbarItems![0].isEnabled = isPumpOnboarded - toolbarItems![0].tintColor = UIColor.carbTintColor - toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") - toolbarItems![4].isEnabled = isPumpOnboarded - toolbarItems![4].tintColor = UIColor.insulinTintColor - toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") - toolbarItems![8].tintColor = UIColor.secondaryLabel - - toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) - toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) + carbEntryButton.accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") + carbEntryButton.isEnabled = isPumpOnboarded + bolusButton.accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") + bolusButton.isEnabled = isPumpOnboarded + settingsButton.accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") + + let preMealIndex: Int + if #available(iOS 26, *) { + preMealIndex = 1 + } else { + preMealIndex = 2 + } + toolbarItems![preMealIndex] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) + updateWorkoutButton(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) } public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { @@ -1378,7 +1413,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) navigationWrapper = UINavigationController(rootViewController: hostingController) - hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: ""), style: .plain, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { let viewModel = CarbEntryViewModel(delegate: deviceManager) @@ -1437,7 +1472,7 @@ final class StatusTableViewController: LoopChartsTableViewController { ) let navigationWrapper = UINavigationController(rootViewController: hostingController) - hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: ""), style: .plain, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) deviceManager.analyticsServicesManager.didDisplayBolusScreen() } @@ -1459,21 +1494,20 @@ final class StatusTableViewController: LoopChartsTableViewController { return item } - private func createWorkoutButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.workoutImage(selected: selected), style: .plain, target: self, action: #selector(toggleWorkoutMode(_:))) - item.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") + private func updateWorkoutButton(selected: Bool, isEnabled: Bool) { + workoutButton.setImage(UIImage.workoutImage(selected: selected), for: .normal) + workoutButton.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") if selected { - item.accessibilityTraits.insert(.selected) - item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") + workoutButton.accessibilityTraits.insert(.selected) + workoutButton.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") } else { - item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") + workoutButton.accessibilityTraits.remove(.selected) + workoutButton.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") } - item.tintColor = UIColor.glucoseTintColor - item.isEnabled = isEnabled - - return item + workoutButton.isEnabled = isEnabled + workoutButton.sizeToFit() } @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { @@ -1544,7 +1578,13 @@ final class StatusTableViewController: LoopChartsTableViewController { } } else { if FeatureFlags.sensitivityOverridesEnabled { - performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) + let overridesIndex: Int + if #available(iOS 26, *) { + overridesIndex = 3 + } else { + overridesIndex = 6 + } + performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![overridesIndex]) } else { presentWorkoutModeAlertController() } @@ -1781,15 +1821,15 @@ final class StatusTableViewController: LoopChartsTableViewController { @objc private func pumpStatusTapped(_ sender: UIGestureRecognizer) { if let pumpStatusView = sender.view as? PumpStatusHUDView { - executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD)) + executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD), from: sender.view) } } @objc private func cgmStatusTapped( _ sender: UIGestureRecognizer) { - executeHUDTapAction(deviceManager.didTapOnCGMStatus()) + executeHUDTapAction(deviceManager.didTapOnCGMStatus(), from: sender.view) } - private func executeHUDTapAction(_ action: HUDTapAction?) { + private func executeHUDTapAction(_ action: HUDTapAction?, from sourceView: UIView?) { guard let action = action else { return } @@ -1802,15 +1842,15 @@ final class StatusTableViewController: LoopChartsTableViewController { case .openAppURL(let url): UIApplication.shared.open(url) case .setupNewCGM: - addNewCGMManager() + addNewCGMManager(from: sourceView) case .setupNewPump: - addNewPumpManager() + addNewPumpManager(from: sourceView) default: return } } - private func addNewPumpManager() { + private func addNewPumpManager(from sourceView: UIView?) { let availablePumpManagers = deviceManager.availablePumpManagers switch availablePumpManagers.count { @@ -1822,12 +1862,13 @@ final class StatusTableViewController: LoopChartsTableViewController { let alert = UIAlertController(availablePumpManagers: availablePumpManagers) { [weak self] (identifier) in self?.addPumpManager(withIdentifier: identifier) } - alert.addCancelAction { _ in } + alert.popoverPresentationController?.sourceView = sourceView ?? view + alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds present(alert, animated: true, completion: nil) } } - private func addNewCGMManager() { + private func addNewCGMManager(from sourceView: UIView?) { let availableCGMManagers = deviceManager.availableCGMManagers switch availableCGMManagers.count { @@ -1839,7 +1880,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let alert = UIAlertController(availableCGMManagers: availableCGMManagers) { [weak self] identifier in self?.addCGMManager(withIdentifier: identifier) } - alert.addCancelAction { _ in } + alert.popoverPresentationController?.sourceView = sourceView ?? view + alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds present(alert, animated: true, completion: nil) } } @@ -2042,6 +2084,15 @@ final class StatusTableViewController: LoopChartsTableViewController { } } +private extension UIButton { + func constrainToToolbarIconSize() { + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + } +} + extension UIAlertController { func addActivityIndicator() { let frame = CGRect(x: 0, y: 0, width: 40, height: 40) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index aa0da33134..4d65f5b2ca 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -107,25 +107,6 @@ public struct SettingsView: View { .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) - .actionSheet(item: $actionSheet) { actionSheet in - switch actionSheet { - case .cgmPicker: - return ActionSheet( - title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), - buttons: cgmChoices - ) - case .pumpPicker: - return ActionSheet( - title: Text("Add Pump", comment: "The title of the pump chooser in settings"), - buttons: pumpChoices - ) - case .servicePicker: - return ActionSheet( - title: Text("Add Service", comment: "The title of the add service action sheet in settings"), - buttons: serviceChoices - ) - } - } .alert(item: $alert) { alert in switch alert { case .deleteCGMData: @@ -337,17 +318,22 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add Pump", comment: "Title text for button to add pump device"), descriptiveText: NSLocalizedString("Tap here to set up a pump", comment: "Descriptive text for button to add pump device")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .pumpPicker), + title: NSLocalizedString("Add Pump", comment: "The title of the pump chooser in settings"), + actions: pumpChoices + ) + ) } } - - private var pumpChoices: [ActionSheet.Button] { - var result = viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in - ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + + private var pumpChoices: [PluginPopover.Action] { + viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in + .init(title: availableDevice.localizedTitle) { self.viewModel.pumpManagerSettingsViewModel.didTapAdd(availableDevice) } } - result.append(.cancel()) - return result } @ViewBuilder @@ -364,6 +350,13 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add CGM", comment: "Title text for button to add CGM device"), descriptiveText: NSLocalizedString("Tap here to set up a CGM", comment: "Descriptive text for button to add CGM device")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .cgmPicker), + title: NSLocalizedString("Add CGM", comment: "The title of the CGM chooser in settings"), + actions: cgmChoices + ) + ) } } @@ -377,16 +370,14 @@ extension SettingsView { } } - private var cgmChoices: [ActionSheet.Button] { - var result = viewModel.cgmManagerSettingsViewModel.availableDevices + private var cgmChoices: [PluginPopover.Action] { + viewModel.cgmManagerSettingsViewModel.availableDevices .sorted(by: {$0.localizedTitle < $1.localizedTitle}) .map { availableDevice in - ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + .init(title: availableDevice.localizedTitle) { self.viewModel.cgmManagerSettingsViewModel.didTapAdd(availableDevice) + } } - } - result.append(.cancel()) - return result } private var servicesSection: some View { @@ -404,20 +395,36 @@ extension SettingsView { imageView: plusImage, label: NSLocalizedString("Add Service", comment: "The title of the add service button in settings"), descriptiveText: NSLocalizedString("Tap here to set up a Service", comment: "The descriptive text of the add service button in settings")) + .background( + PluginPopover( + isPresented: actionSheetBinding(for: .servicePicker), + title: NSLocalizedString("Add Service", comment: "The title of the add service action sheet in settings"), + actions: serviceChoices + ) + ) } } } - - private var serviceChoices: [ActionSheet.Button] { - var result = viewModel.servicesViewModel.inactiveServices().map { availableService in - ActionSheet.Button.default(Text(availableService.localizedTitle)) { + + private var serviceChoices: [PluginPopover.Action] { + viewModel.servicesViewModel.inactiveServices().map { availableService in + .init(title: availableService.localizedTitle) { self.viewModel.servicesViewModel.didTapAddService(availableService) } } - result.append(.cancel()) - return result } + private func actionSheetBinding(for destination: Destination.ActionSheet) -> Binding { + Binding( + get: { actionSheet == destination }, + set: { isPresented in + if !isPresented && actionSheet == destination { + actionSheet = nil + } + } + ) + } + private var deleteDataSection: some View { Section { if viewModel.pumpManagerSettingsViewModel.isTestingDevice { @@ -615,6 +622,72 @@ fileprivate struct LargeButton: View { } } +struct PluginPopover: UIViewControllerRepresentable { + struct Action { + let title: String + let handler: () -> Void + } + + @Binding var isPresented: Bool + let title: String + let actions: [Action] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + let coordinator = context.coordinator + + if !isPresented { + coordinator.didPresent = false + return + } + + guard !coordinator.didPresent else { return } + guard uiViewController.presentedViewController == nil else { return } + + coordinator.didPresent = true + coordinator.onDismiss = { self.isPresented = false } + + let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + for action in actions { + alert.addAction(UIAlertAction(title: action.title, style: .default) { _ in + self.isPresented = false + action.handler() + }) + } + + alert.addAction(UIAlertAction( + title: NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet"), + style: .destructive + ) { _ in + self.isPresented = false + }) + + if let popover = alert.popoverPresentationController { + popover.sourceView = uiViewController.view + popover.sourceRect = uiViewController.view.bounds + popover.delegate = coordinator + } + + uiViewController.present(alert, animated: true) + } + + final class Coordinator: NSObject, UIPopoverPresentationControllerDelegate { + var didPresent = false + var onDismiss: (() -> Void)? + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + onDismiss?() + } + } +} + public struct SettingsView_Previews: PreviewProvider { public static var previews: some View {