diff --git a/Common/Models/PumpManager.swift b/Common/Models/PumpManager.swift index d1a82fa2f4..fbe9ebc593 100644 --- a/Common/Models/PumpManager.swift +++ b/Common/Models/PumpManager.swift @@ -18,7 +18,12 @@ let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [ var availableStaticPumpManagers: [PumpManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - PumpManagerDescriptor(identifier: MockPumpManager.managerIdentifier, localizedTitle: MockPumpManager.localizedTitle) + PumpManagerDescriptor( + identifier: MockPumpManager.managerIdentifier, + localizedTitle: MockPumpManager.localizedTitle, + manufacturer: "Simulator", + image: MockPumpManager.pickerImage + ) ] } else { return [] diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8254ab53fc..48f5c9915e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; + 5021CA8C264146C9DFF33E9D /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; 824A01CBE97A4F7A9D32F7D6 /* Loop/Resources/Sounds/clear_chimes.caf in Resources */ = {isa = PBXBuildFile; fileRef = 4A645E0E2DDA534A635C9201 /* Loop/Resources/Sounds/clear_chimes.caf */; }; @@ -1398,6 +1399,7 @@ C9A47B0475B13A9543390BC8 /* Loop/Resources/Sounds/critical.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/critical.caf; sourceTree = SOURCE_ROOT; }; C9EA3F48338B49BD9C6BD0F9 /* Loop/Resources/Sounds/urgent_low.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/urgent_low.caf; sourceTree = SOURCE_ROOT; }; C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; }; DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationFactorStrategy.swift; sourceTree = ""; }; DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorStrategy.swift; sourceTree = ""; }; @@ -2057,6 +2059,7 @@ 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */, + CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */, ); path = Views; sourceTree = ""; @@ -3648,6 +3651,7 @@ B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */, + 5021CA8C264146C9DFF33E9D /* PluginPickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index 70e22f3046..41132180a4 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -12,58 +12,6 @@ import LoopKitUI extension UIAlertController { - /// Initializes an action sheet-styled controller for selecting a PumpManager - /// - /// - Parameters: - /// - availablePumpManagers: An array of available PumpManagers - /// - selectionHandler: A closure to execute when a manager is selected - /// - identifier: Identifier of the selected PumpManager - internal convenience init(availablePumpManagers: [PumpManagerDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { - self.init( - title: NSLocalizedString("Add Pump", comment: "Action sheet title selecting Pump"), - message: nil, - preferredStyle: .actionSheet - ) - - for availablePumpManager in availablePumpManagers { - addAction(UIAlertAction( - title: availablePumpManager.localizedTitle, - style: .default, - handler: { (_) in - selectionHandler(availablePumpManager.identifier) - } - )) - } - - addCancelAction() - } - - /// Initializes an action sheet-styled controller for selecting a CGMManager - /// - /// - Parameters: - /// - availableCGMManagers: An array of available CGMManagers - /// - selectionHandler: A closure to execute when either a new CGMManager or the current PumpManager is selected - /// - identifier: Identifier of the selected CGMManager - internal convenience init(availableCGMManagers: [CGMManagerDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { - self.init( - title: NSLocalizedString("Add CGM", comment: "Action sheet title selecting CGM"), - message: nil, - preferredStyle: .actionSheet - ) - - for availableCGMManager in availableCGMManagers.sorted(by: {$0.localizedTitle < $1.localizedTitle}) { - addAction(UIAlertAction( - title: availableCGMManager.localizedTitle, - style: .default, - handler: { (_) in - selectionHandler(availableCGMManager.identifier) - } - )) - } - - addCancelAction() - } - internal convenience init(deleteCGMManagerHandler handler: @escaping (_ isDeleted: Bool) -> Void) { self.init( title: nil, @@ -84,36 +32,6 @@ extension UIAlertController { } } - /// Initializes an action sheet-styled controller for selecting a service. - /// - /// - Parameters: - /// - availableServices: An array of available services. - /// - 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: preferredStyle - ) - - for availableService in availableServices { - addAction(UIAlertAction( - title: availableService.localizedTitle, - style: .default, - handler: { (_) in - selectionHandler(availableService.identifier) - } - )) - } - - 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: .destructive, handler: handler)) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 713c6d896b..e40ee60217 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -5450,7 +5450,7 @@ } }, "Add CGM" : { - "comment" : "Action sheet title selecting CGM\nThe title of the CGM chooser in settings\nTitle text for button to add CGM device\nTitle text for button to set up a CGM", + "comment" : "The title of the CGM chooser in settings\nTitle text for button to add CGM device\nTitle text for button to set up a CGM", "localizations" : { "ar" : { "stringUnit" : { @@ -5760,7 +5760,7 @@ } }, "Add Pump" : { - "comment" : "Action sheet title selecting Pump\nThe title of the pump chooser in settings\nTitle text for button to add pump device\nTitle text for button to set up a Pump", + "comment" : "The title of the pump chooser in settings\nTitle text for button to add pump device\nTitle text for button to set up a Pump", "localizations" : { "ar" : { "stringUnit" : { @@ -5885,7 +5885,7 @@ } }, "Add Service" : { - "comment" : "Action sheet title selecting service\nThe title of the add service action sheet in settings\nThe title of the add service button in settings", + "comment" : "The title of the add service button in settings\nThe title of the add service picker in settings", "localizations" : { "da" : { "stringUnit" : { @@ -10401,7 +10401,7 @@ "comment" : "Focus modes section title (1: app name)" }, "Cancel" : { - "comment" : "Button label for cancel\nButton text to cancel\nCancel button for reset loop alert\nCancel export button title\nThe title of the cancel action in an action sheet", + "comment" : "Button label for cancel\nButton text to cancel\nCancel button for reset loop alert\nCancel export button title\nThe title of the cancel action in an action sheet\nThe title of the cancel button in the plugin picker", "localizations" : { "ar" : { "stringUnit" : { @@ -30171,6 +30171,9 @@ "comment" : "A button that opens the iOS settings app.", "isCommentAutoGenerated" : true }, + "Other" : { + "comment" : "The group name for plugins that do not declare a manufacturer or category" + }, "Override Presets" : { "comment" : "The title text for the override presets", "extractionState" : "manual", @@ -40331,6 +40334,7 @@ }, "Until I enter carbs" : { "comment" : "The title of a target alert action specifying pre-meal targets duration for 1 hour or until the user enters carbs (whichever comes first).", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40607,6 +40611,7 @@ }, "Use Pre-Meal Preset" : { "comment" : "The title of the alert controller used to select a duration for pre-meal targets", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { diff --git a/Loop/Managers/CGMManager.swift b/Loop/Managers/CGMManager.swift index 6f261c4308..70671f4e93 100644 --- a/Loop/Managers/CGMManager.swift +++ b/Loop/Managers/CGMManager.swift @@ -8,6 +8,7 @@ import LoopKit import LoopKitUI import MockKit +import MockKitUI let staticCGMManagersByIdentifier: [String: CGMManager.Type] = [ MockCGMManager.managerIdentifier: MockCGMManager.self @@ -16,7 +17,12 @@ let staticCGMManagersByIdentifier: [String: CGMManager.Type] = [ var availableStaticCGMManagers: [CGMManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - CGMManagerDescriptor(identifier: MockCGMManager.managerIdentifier, localizedTitle: MockCGMManager.localizedTitle) + CGMManagerDescriptor( + identifier: MockCGMManager.managerIdentifier, + localizedTitle: MockCGMManager.localizedTitle, + manufacturer: "Simulator", + image: MockCGMManager.pickerImage + ) ] } else { return [] diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 4f2d6dc641..3ea9a2a0a0 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -494,7 +494,16 @@ final class DeviceDataManager { var availableCGMManagers: [CGMManagerDescriptor] { var availableCGMManagers = pluginManager.availableCGMManagers + availableStaticCGMManagers if let pumpManagerAsCGMManager = pumpManager as? CGMManager { - availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.pluginIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) + let identifier = pumpManagerAsCGMManager.pluginIdentifier + let manufacturer = pluginManager.pluginBundle(forIdentifier: identifier)?.object(forInfoDictionaryKey: LoopPluginBundleKey.deviceManufacturer.rawValue) as? String + availableCGMManagers.append( + CGMManagerDescriptor( + identifier: identifier, + localizedTitle: pumpManagerAsCGMManager.localizedTitle, + manufacturer: manufacturer, + image: (type(of: pumpManagerAsCGMManager) as? CGMManagerUI.Type)?.pickerImage + ) + ) } availableCGMManagers = availableCGMManagers.filter({ cgmManager in diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index 6a6cc25764..9cb9359262 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -9,6 +9,7 @@ import LoopKit import LoopKitUI import MockKit +import MockKitUI let staticServices: [Service.Type] = [MockService.self] @@ -19,7 +20,12 @@ let staticServicesByIdentifier: [String: Service.Type] = [ var availableStaticServices: [ServiceDescriptor] { if FeatureFlags.allowSimulators { return [ - ServiceDescriptor(identifier: MockService.serviceIdentifier, localizedTitle: MockService.localizedTitle) + ServiceDescriptor( + identifier: MockService.serviceIdentifier, + localizedTitle: MockService.localizedTitle, + category: "Simulator", + image: MockService.image + ) ] } else { return [] diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index 7064bda19f..5caab3050c 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -71,11 +71,18 @@ class PluginManager { var availablePumpManagers: [PumpManagerDescriptor] { pluginBundles.compactMap({ (bundle) -> PumpManagerDescriptor? in guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerDisplayName.rawValue) as? String, - let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String else { - return nil + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String else { + return nil } - - return PumpManagerDescriptor(identifier: identifier, localizedTitle: title) + + return PumpManagerDescriptor( + identifier: identifier, + localizedTitle: title, + manufacturer: bundle.object( + forInfoDictionaryKey: LoopPluginBundleKey.deviceManufacturer.rawValue + ) as? String, + image: getPumpManagerTypeByIdentifier(identifier)?.pickerImage + ) }) } @@ -107,11 +114,16 @@ class PluginManager { var availableCGMManagers: [CGMManagerDescriptor] { pluginBundles.compactMap({ (bundle) -> CGMManagerDescriptor? in guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerDisplayName.rawValue) as? String, - let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String else { - return nil + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String else { + return nil } - - return CGMManagerDescriptor(identifier: identifier, localizedTitle: title) + + return CGMManagerDescriptor( + identifier: identifier, + localizedTitle: title, + manufacturer: bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.deviceManufacturer.rawValue) as? String, + image: getCGMManagerTypeByIdentifier(identifier)?.pickerImage + ) }) } @@ -141,16 +153,30 @@ class PluginManager { } var availableServices: [ServiceDescriptor] { - return pluginBundles.compactMap({ (bundle) -> ServiceDescriptor? in + pluginBundles.compactMap({ (bundle) -> ServiceDescriptor? in guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceDisplayName.rawValue) as? String, - let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String else { - return nil + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String else { + return nil } - - return ServiceDescriptor(identifier: identifier, localizedTitle: title) + + return ServiceDescriptor( + identifier: identifier, + localizedTitle: title, + category: bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceCategory.rawValue) as? String, + image: getServiceTypeByIdentifier(identifier)?.image + ) }) } + func pluginBundle(forIdentifier identifier: String) -> Bundle? { + let identifierKeys: [LoopPluginBundleKey] = [.pumpManagerIdentifier, .cgmManagerIdentifier, .serviceIdentifier] + return pluginBundles.first { bundle in + identifierKeys.contains { key in + (bundle.object(forInfoDictionaryKey: key.rawValue) as? String) == identifier + } + } + } + func getStatefulPluginTypeByIdentifier(_ identifier: String) -> StatefulPluggable.Type? { for bundle in pluginBundles { if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String, name == identifier { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9144a76907..131d6d64b9 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1800,12 +1800,7 @@ final class StatusTableViewController: LoopChartsTableViewController { addPumpManager(withIdentifier: availablePumpManager.identifier) } default: - let alert = UIAlertController(availablePumpManagers: availablePumpManagers) { [weak self] (identifier) in - self?.addPumpManager(withIdentifier: identifier) - } - alert.popoverPresentationController?.sourceView = sourceView ?? view - alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds - present(alert, animated: true, completion: nil) + presentPluginPicker(for: .pump) } } @@ -1818,13 +1813,49 @@ final class StatusTableViewController: LoopChartsTableViewController { addCGMManager(withIdentifier: availableCGMManager.identifier) } default: - let alert = UIAlertController(availableCGMManagers: availableCGMManagers) { [weak self] identifier in + presentPluginPicker(for: .cgm) + } + } + + private func presentPluginPicker(for pluginType: PluginPickerView.PluginType) { + let items: [PluginPickerView.Item] = { + switch pluginType { + case .cgm: + deviceManager.availableCGMManagers.map { PluginPickerView.Item($0) } + case .pump: + deviceManager.availablePumpManagers.map { PluginPickerView.Item($0) } + case .service: + servicesManager.availableServices.map { PluginPickerView.Item($0) } + } + }() + + let selectionHandler: ((_ identifier: String) -> Void) = { [weak self] identifier in + switch pluginType { + case .cgm: self?.addCGMManager(withIdentifier: identifier) + case .pump: + self?.addPumpManager(withIdentifier: identifier) + case .service: + self?.addService(withIdentifier: identifier) } - alert.popoverPresentationController?.sourceView = sourceView ?? view - alert.popoverPresentationController?.sourceRect = sourceView?.bounds ?? view.bounds - present(alert, animated: true, completion: nil) } + + let picker = PluginPickerView( + pluginType: pluginType, + items: items, + onSelect: { [weak self] item in + self?.dismiss(animated: true) { + selectionHandler(item.id) + } + }, + onCancel: { [weak self] in + self?.dismiss(animated: true, completion: nil) + } + ) + + let hostingController = UIHostingController(rootView: picker) + hostingController.modalPresentationStyle = .fullScreen + present(hostingController, animated: true) } // MARK: - Debug Scenarios and Simulated Core Data diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index 6878a5fa5f..bc2192a29e 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -59,7 +59,7 @@ extension ServicesViewModel { required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle, category: nil, image: nil) } } fileprivate class FakeService2: Service { static var localizedTitle: String = "Service 2" @@ -70,7 +70,7 @@ extension ServicesViewModel { required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle, category: nil, image: nil) } } static var preview: ServicesViewModel { diff --git a/Loop/Views/PluginPickerView.swift b/Loop/Views/PluginPickerView.swift new file mode 100644 index 0000000000..569671726a --- /dev/null +++ b/Loop/Views/PluginPickerView.swift @@ -0,0 +1,181 @@ +// +// PluginPickerView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI + +/// A full-screen chooser for setting up a new CGM, pump, or service plugin, +/// showing an icon for each choice and grouping devices by manufacturer and +/// services by category. +/// +/// The view never dismisses itself: the presenter reacts to `onSelect`/`onCancel` +/// so that setup UI can be presented only after the picker is fully dismissed. +struct PluginPickerView: View { + enum PluginType: Hashable { + case cgm + case pump + case service + + var localizedTitle: String { + switch self { + case .cgm: + NSLocalizedString("Add CGM", comment: "The title of the CGM chooser in settings") + case .pump: + NSLocalizedString("Add Pump", comment: "The title of the pump chooser in settings") + case .service: + NSLocalizedString("Add Service", comment: "The title of the add service picker in settings") + } + } + + var fallbackSystemImage: String { + switch self { + case .cgm: + "sensor.tag.radiowaves.forward" + case .pump: + "ivfluid.bag" + case .service: + "network" + } + } + } + + struct Item: Identifiable { + let id: String + let title: String + + /// The section this item is listed under: the manufacturer for devices, the category for services + let group: String? + + let image: UIImage? + } + + let pluginType: PluginType + let items: [Item] + let onSelect: (Item) -> Void + let onCancel: () -> Void + + private static let otherGroup = NSLocalizedString("Other", comment: "The group name for plugins that do not declare a manufacturer or category") + + /// Matches the group used by the static simulator descriptors (see availableStaticPumpManagers et al.) + private static let simulatorGroup = "Simulator" + + /// Matches the group used by the remote device descriptors + private static let remoteGroup = "Remote" + + private struct ItemGroup: Identifiable { + let id: String + let items: [Item] + } + + private var groups: [ItemGroup] { + Dictionary(grouping: items) { $0.group ?? Self.otherGroup } + .map { name, items in + ItemGroup(id: name, items: items.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }) + } + .sorted { (rank(of: $0.id), $0.id) < (rank(of: $1.id), $1.id) } + } + + /// Named groups sort alphabetically; Physical devices first, then remote devices, then simulators + private func rank(of groupName: String) -> Int { + switch groupName { + case Self.otherGroup: + return 3 + case Self.simulatorGroup: + return 2 + case Self.remoteGroup: + return 1 + default: + return 0 + } + } + + var body: some View { + NavigationView { + List { + ForEach(groups) { group in + Section(header: SectionHeader(label: group.id)) { + rows(for: group) + } + } + } + .insetGroupedListStyle() + .navigationBarTitle(Text(pluginType.localizedTitle)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("Cancel", comment: "The title of the cancel button in the plugin picker"), action: onCancel) + } + } + } + .navigationViewStyle(.stack) + } + + private func rows(for group: ItemGroup) -> some View { + ForEach(group.items) { item in + Button(action: { onSelect(item) }) { + HStack(spacing: 12) { + PluginIconView(image: item.image, fallbackSystemImage: pluginType.fallbackSystemImage) + Text(item.title) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("pluginPickerItem-\(item.id)") + } + } +} + +private struct PluginIconView: View { + @Environment(\.colorScheme) private var colorScheme + + let image: UIImage? + let fallbackSystemImage: String + + var body: some View { + Group { + if let image = resolvedImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(6) + } else { + Image(systemName: fallbackSystemImage) + .font(.system(size: 21)) + .foregroundColor(.accentColor) + } + } + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(UIColor.tertiarySystemFill)) + ) + } + + private var resolvedImage: UIImage? { + guard let image else { return nil } + let traits = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) + return image.imageAsset?.image(with: traits) ?? image + } +} + +extension PluginPickerView.Item { + init(_ descriptor: PumpManagerDescriptor) { + self.init(id: descriptor.identifier, title: descriptor.localizedTitle, group: descriptor.manufacturer, image: descriptor.image) + } + + init(_ descriptor: CGMManagerDescriptor) { + self.init(id: descriptor.identifier, title: descriptor.localizedTitle, group: descriptor.manufacturer, image: descriptor.image) + } + + init(_ descriptor: ServiceDescriptor) { + self.init(id: descriptor.identifier, title: descriptor.localizedTitle, group: descriptor.category, image: descriptor.image) + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 687624f696..b81cd6f889 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -38,7 +38,7 @@ struct SettingsView: View { case deleteAllTestingData } - enum ActionSheet: String, Identifiable { + enum PluginPicker: String, Identifiable { var id: String { rawValue } @@ -58,9 +58,10 @@ struct SettingsView: View { } } - @State private var actionSheet: Destination.ActionSheet? + @State private var pluginPicker: Destination.PluginPicker? @State private var alert: Destination.Alert? @State private var sheet: Destination.Sheet? + @State private var pluginSelection: (() -> Void)? var localizedAppNameAndVersion: String @@ -119,6 +120,12 @@ struct SettingsView: View { .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) + .fullScreenCover(item: $pluginPicker, onDismiss: { + pluginSelection?() + pluginSelection = nil + }) { picker in + pluginPickerView(for: picker) + } .alert(item: $alert) { alert in switch alert { case .deleteCGMData: @@ -454,37 +461,11 @@ extension SettingsView { label: viewModel.pumpManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Insulin Pump", comment: "Descriptive text for Insulin Pump")) } else if viewModel.isOnboardingComplete { - LargeButton(action: { actionSheet = .pumpPicker }, + LargeButton(action: { pluginPicker = .pumpPicker }, includeArrow: false, 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: pickerBinding(.pumpPicker), - title: NSLocalizedString("Add Pump", comment: "The title of the pump chooser in settings"), - actions: pumpChoices - ) - ) - } - } - - private func pickerBinding(_ destination: Destination.ActionSheet) -> Binding { - Binding( - get: { actionSheet == destination }, - set: { isPresented in - if !isPresented, actionSheet == destination { - actionSheet = nil - } - } - ) - } - - private var pumpChoices: [PluginPopover.Action] { - viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in - .init(title: availableDevice.localizedTitle) { - self.viewModel.pumpManagerSettingsViewModel.didTapAdd(availableDevice) - } } } @@ -497,18 +478,11 @@ extension SettingsView { label: viewModel.cgmManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Continuous Glucose Monitor", comment: "Descriptive text for Continuous Glucose Monitor")) } else { - LargeButton(action: { actionSheet = .cgmPicker }, + LargeButton(action: { pluginPicker = .cgmPicker }, includeArrow: false, 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: pickerBinding(.cgmPicker), - title: NSLocalizedString("Add CGM", comment: "The title of the CGM chooser in settings"), - actions: cgmChoices - ) - ) } } @@ -522,14 +496,46 @@ extension SettingsView { } } - private var cgmChoices: [PluginPopover.Action] { - viewModel.cgmManagerSettingsViewModel.availableDevices - .sorted(by: {$0.localizedTitle < $1.localizedTitle}) - .map { availableDevice in - .init(title: availableDevice.localizedTitle) { - self.viewModel.cgmManagerSettingsViewModel.didTapAdd(availableDevice) - } - } + @ViewBuilder + private func pluginPickerView(for picker: Destination.PluginPicker) -> some View { + switch picker { + case .pumpPicker: + PluginPickerView( + pluginType: .pump, + items: viewModel.pumpManagerSettingsViewModel.availableDevices.map { PluginPickerView.Item($0) }, + onSelect: { item in + if let device = viewModel.pumpManagerSettingsViewModel.availableDevices.first(where: { $0.identifier == item.id }) { + pluginSelection = { viewModel.pumpManagerSettingsViewModel.didTapAdd(device) } + } + pluginPicker = nil + }, + onCancel: { pluginPicker = nil } + ) + case .cgmPicker: + PluginPickerView( + pluginType: .cgm, + items: viewModel.cgmManagerSettingsViewModel.availableDevices.map { PluginPickerView.Item($0) }, + onSelect: { item in + if let device = viewModel.cgmManagerSettingsViewModel.availableDevices.first(where: { $0.identifier == item.id }) { + pluginSelection = { viewModel.cgmManagerSettingsViewModel.didTapAdd(device) } + } + pluginPicker = nil + }, + onCancel: { pluginPicker = nil } + ) + case .servicePicker: + PluginPickerView( + pluginType: .service, + items: viewModel.servicesViewModel.inactiveServices().map { PluginPickerView.Item($0) }, + onSelect: { item in + if let service = viewModel.servicesViewModel.inactiveServices().first(where: { $0.identifier == item.id }) { + pluginSelection = { viewModel.servicesViewModel.didTapAddService(service) } + } + pluginPicker = nil + }, + onCancel: { pluginPicker = nil } + ) + } } private var servicesSection: some View { @@ -542,26 +548,11 @@ extension SettingsView { descriptiveText: "") } if viewModel.servicesViewModel.inactiveServices().count > 0 { - LargeButton(action: { actionSheet = .servicePicker }, + LargeButton(action: { pluginPicker = .servicePicker }, includeArrow: false, 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: pickerBinding(.servicePicker), - title: NSLocalizedString("Add Service", comment: "The title of the add service action sheet in settings"), - actions: serviceChoices - ) - ) - } - } - } - - private var serviceChoices: [PluginPopover.Action] { - viewModel.servicesViewModel.inactiveServices().map { availableService in - .init(title: availableService.localizedTitle) { - self.viewModel.servicesViewModel.didTapAddService(availableService) } } } @@ -783,77 +774,6 @@ fileprivate struct LargeButton: View { } } -/// Presents the plugin chooser as a UIKit action sheet anchored to the tapped row. -/// -/// A SwiftUI `.actionSheet`/`.confirmationDialog` presented from a `List` row breaks -/// under the Liquid Glass design, so the sheet is presented from a hosted -/// `UIViewController` positioned behind the row instead. -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 {