From dfeaf848e090e2e9b6a5f54782153a42d86c8513 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 3 May 2025 20:44:12 -0700 Subject: [PATCH] Device onboarding initial commit --- Localizable.xcstrings | 192 ++++++----- Meshtastic.xcodeproj/project.pbxproj | 16 +- Meshtastic/Extensions/UserDefaults.swift | 8 + Meshtastic/Helpers/BLEManager.swift | 3 + Meshtastic/Helpers/LocationsHandler.swift | 16 +- Meshtastic/MeshtasticApp.swift | 6 +- Meshtastic/Views/Bluetooth/Connect.swift | 14 - Meshtastic/Views/ContentView.swift | 23 +- .../Views/Onboarding/DeviceOnboarding.swift | 316 ++++++++++++++++++ 9 files changed, 490 insertions(+), 104 deletions(-) create mode 100644 Meshtastic/Views/Onboarding/DeviceOnboarding.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 95f524d2..e6f2f5de 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3055,6 +3055,9 @@ } } } + }, + "App Notifications" : { + }, "App Settings" : { "localizations" : { @@ -6533,6 +6536,9 @@ } } } + }, + "Configure notification permissions" : { + }, "Confirm" : { "localizations" : { @@ -7027,6 +7033,9 @@ } } } + }, + "Continue to next step" : { + }, "Control Type" : { "localizations" : { @@ -7371,6 +7380,9 @@ } } } + }, + "Critical Alerts" : { + }, "Current" : { "localizations" : { @@ -10999,6 +11011,9 @@ } } } + }, + "Enable MQTT" : { + }, "Enable Notifications" : { "localizations" : { @@ -11428,6 +11443,34 @@ } } }, + "Environment Metrics" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metriche dei sensori" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрике сензора" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "传感器指标" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳感器數據" + } + } + } + }, "Environment Metrics Log" : { "localizations" : { "it" : { @@ -13113,6 +13156,9 @@ } } } + }, + "Get started" : { + }, "Get the latest alpha firmware" : { "localizations" : { @@ -14370,34 +14416,6 @@ } } }, - "How often power metrics are sent out over the mesh. Default is 30 minutes." : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Con quale frequenza vengono inviate le metriche di potenza attraverso la rete. L'impostazione predefinita è 30 minuti." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Колико често се метрике снаге шаљу преко мреже. Подразумевано је 30 минута." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通过网格发送功率指标的频率。默认为 30 分钟。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "電力指標透過網狀網路發送的頻率。預設為每 30 分鐘一次。" - } - } - } - }, "How often environment metrics are sent out over the mesh. Default is 30 minutes." : { "localizations" : { "it" : { @@ -14426,6 +14444,34 @@ } } }, + "How often power metrics are sent out over the mesh. Default is 30 minutes." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con quale frequenza vengono inviate le metriche di potenza attraverso la rete. L'impostazione predefinita è 30 minuti." + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колико често се метрике снаге шаљу преко мреже. Подразумевано је 30 минута." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过网格发送功率指标的频率。默认为 30 分钟。" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "電力指標透過網狀網路發送的頻率。預設為每 30 分鐘一次。" + } + } + } + }, "How often should we try to get a GPS position." : { "localizations" : { "it" : { @@ -18592,28 +18638,6 @@ } } }, - "Long Range - Fast" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "A lungo raggio - Veloce" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Дугачки домет - Брзо" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Long Range - Fast" - } - } - } - }, "Long Name" : { "localizations" : { "de" : { @@ -18710,6 +18734,28 @@ } } }, + "Long Range - Fast" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "A lungo raggio - Veloce" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дугачки домет - Брзо" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Fast" + } + } + } + }, "Long Range - Slow" : { "localizations" : { "it" : { @@ -22085,6 +22131,9 @@ } } } + }, + "Meshtastic" : { + }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { @@ -22113,6 +22162,9 @@ } } } + }, + "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from Settings > App Settings > Open Settings." : { + }, "Meshtastic® Copyright Meshtastic LLC" : { "localizations" : { @@ -25421,6 +25473,9 @@ } } } + }, + "Phone Location" : { + }, "phone.gps" : { "localizations" : { @@ -31018,6 +31073,9 @@ } } } + }, + "Send Notifications" : { + }, "Send Reboot OTA" : { "localizations" : { @@ -31075,34 +31133,6 @@ } } }, - "Environment Metrics" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metriche dei sensori" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Метрике сензора" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "传感器指标" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "傳感器數據" - } - } - } - }, "Sensor options" : { "localizations" : { "it" : { @@ -31802,6 +31832,9 @@ } } } + }, + "Set up later" : { + }, "set.region" : { "localizations" : { @@ -37850,6 +37883,9 @@ } } } + }, + "Welcome to" : { + }, "What does the lock mean?" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index bbdd3417..3e1a842c 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -131,6 +131,7 @@ DD6F65792C6EADE60053C113 /* DirectMessagesHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6F65782C6EADE60053C113 /* DirectMessagesHelp.swift */; }; DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6F657A2C6EC2900053C113 /* LockLegend.swift */; }; DD73FD1128750779000852D6 /* PositionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FD1028750779000852D6 /* PositionLog.swift */; }; + DD74ED0D2DC6A0C90059AC10 /* DeviceOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */; }; DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */; }; DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */; }; DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */; }; @@ -418,6 +419,7 @@ DD6F65782C6EADE60053C113 /* DirectMessagesHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesHelp.swift; sourceTree = ""; }; DD6F657A2C6EC2900053C113 /* LockLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockLegend.swift; sourceTree = ""; }; DD73FD1028750779000852D6 /* PositionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionLog.swift; sourceTree = ""; }; + DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOnboarding.swift; sourceTree = ""; }; DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetricsLog.swift; sourceTree = ""; }; DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTips.swift; sourceTree = ""; }; DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = ""; }; @@ -837,6 +839,14 @@ path = Help; sourceTree = ""; }; + DD74ED0B2DC6A0900059AC10 /* Onboarding */ = { + isa = PBXGroup; + children = ( + DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; DD7709392AA1ABA1007A8BF0 /* Tips */ = { isa = PBXGroup; children = ( @@ -974,11 +984,12 @@ DDC2E18726CE24E40042C5E4 /* Views */ = { isa = PBXGroup; children = ( - DD6D5A312CA1176A00ED3032 /* Layouts */, - DDC2E18D26CE25CB0042C5E4 /* Helpers */, DD47E3D726F2F21A00029299 /* Bluetooth */, + DDC2E18D26CE25CB0042C5E4 /* Helpers */, + DD6D5A312CA1176A00ED3032 /* Layouts */, DDC2E18B26CE25A70042C5E4 /* Messages */, DD47E3CA26F0E50300029299 /* Nodes */, + DD74ED0B2DC6A0900059AC10 /* Onboarding */, DD4A911C2708C57100501B7E /* Settings */, DDC2E18E26CE25FE0042C5E4 /* ContentView.swift */, ); @@ -1416,6 +1427,7 @@ DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */, DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */, + DD74ED0D2DC6A0C90059AC10 /* DeviceOnboarding.swift in Sources */, DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */, B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */, DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */, diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index e1ca67f9..c255cec2 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -73,6 +73,8 @@ extension UserDefaults { case environmentEnableWeatherKit case enableAdministration case mapReportingOptIn + case firstLaunch + case showDeviceOnboarding case testIntEnum } @@ -151,6 +153,12 @@ extension UserDefaults { @UserDefault(.mapReportingOptIn, defaultValue: false) static var mapReportingOptIn: Bool + + @UserDefault(.firstLaunch, defaultValue: true) + static var firstLaunch: Bool + + @UserDefault(.showDeviceOnboarding, defaultValue: false) + static var showDeviceOnboarding: Bool @UserDefault(.testIntEnum, defaultValue: .one) static var testIntEnum: TestIntEnum diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 63a4cc68..29e80735 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -982,6 +982,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate lastConnectionError = "" isSubscribed = true Logger.mesh.info("🤜 [BLE] Want Config Complete. ID:\(decodedInfo.configCompleteID, privacy: .public)") + if UserDefaults.firstLaunch { + UserDefaults.showDeviceOnboarding = true + } if sendTime() { } peripherals.removeAll(where: { $0.peripheral.state == CBPeripheralState.disconnected }) diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 75830805..62cbd841 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -13,7 +13,7 @@ import OSLog @MainActor class LocationsHandler: ObservableObject { static let shared = LocationsHandler() // Create a single, shared instance of the object. - private let manager: CLLocationManager + public let manager: CLLocationManager private var background: CLBackgroundActivitySession? var enableSmartPosition: Bool = UserDefaults.enableSmartPosition @@ -38,6 +38,16 @@ import OSLog UserDefaults.standard.set(backgroundActivity, forKey: "BGActivitySessionStarted") } } + + // The continuation we will use to asynchronously ask the user permission to track their location. + private var permissionContinuation: CheckedContinuation? + + func requestLocationAlwaysPermissions() async -> CLAuthorizationStatus { + self.manager.requestAlwaysAuthorization() + return await withCheckedContinuation { continuation in + permissionContinuation = continuation + } + } private init() { self.manager = CLLocationManager() // Creating a location manager instance is safe to call here in `MainActor`. @@ -49,6 +59,10 @@ import OSLog if self.manager.authorizationStatus == .notDetermined { self.manager.requestWhenInUseAuthorization() } + let status = self.manager.authorizationStatus + guard status == .authorizedAlways || status == .authorizedWhenInUse else { + return + } Logger.services.info("📍 [App] Starting location updates") Task { do { diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 5c82c257..0145a7a7 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -11,11 +11,7 @@ struct MeshtasticAppleApp: App { @UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) private var appDelegate - @ObservedObject - var appState: AppState - -// @ObservedObject -// private var bleManager: BLEManager + @ObservedObject var appState: AppState private let persistenceController: PersistenceController diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 8e04f857..20da78dc 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -27,20 +27,6 @@ struct Connect: View { @State var presentingSwitchPreferredPeripheral = false @State var selectedPeripherialId = "" - init () { - let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.getNotificationSettings(completionHandler: { (settings) in - if settings.authorizationStatus == .notDetermined { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .criticalAlert]) { success, error in - if success { - Logger.services.info("Notifications are all set!") - } else if let error = error { - Logger.services.error("\(error.localizedDescription, privacy: .public)") - } - } - } - }) - } var body: some View { NavigationStack { VStack { diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index 1c8a395d..94666c0c 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -5,11 +5,11 @@ import SwiftUI struct ContentView: View { - @ObservedObject - var appState: AppState + @ObservedObject var appState: AppState - @ObservedObject - var router: Router + @ObservedObject var router: Router + + @State var isShowingDeviceOnboardingFlow: Bool = false init(appState: AppState, router: Router) { self.appState = appState @@ -58,6 +58,21 @@ struct ContentView: View { .font(.title) } .tag(NavigationState.Tab.settings) + }.sheet( + isPresented: $isShowingDeviceOnboardingFlow, + onDismiss: { + //UserDefaults.firstLaunch = false + }, content: { + DeviceOnboarding() + } + ) + .onAppear { + if UserDefaults.firstLaunch { + isShowingDeviceOnboardingFlow = true + } + } + .onChange(of: UserDefaults.showDeviceOnboarding) { newValue in + isShowingDeviceOnboardingFlow = newValue } } } diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift new file mode 100644 index 00000000..7f2274aa --- /dev/null +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -0,0 +1,316 @@ +import CoreBluetooth +import OSLog +import SwiftUI +import Foundation +import MapKit + +struct DeviceOnboarding: View { + enum SetupGuide: Hashable { + case notifications + case location + case mqtt + } + + @State var navigationPath: [SetupGuide] = [] + @EnvironmentObject var bleManager: BLEManager + + @Environment(\.dismiss) var dismiss + + /// The Title View + var title: some View { + VStack { + Text("Welcome to") + .font(.title2.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text("Meshtastic") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + var welcomeView: some View { + VStack { + ScrollView(.vertical, showsIndicators: false) { + VStack { + // Title + title + .padding(.top) + // Onboarding + VStack(alignment: .leading, spacing: 16) { + makeRow( + icon: "antenna.radiowaves.left.and.right", + title: "Stay Connected Anywhere", + subtitle: "Communicate off-the-grid with your friends and community without cell service." + ) + + makeRow( + icon: "point.3.connected.trianglepath.dotted", + title: "Create Your Own Networks", + subtitle: "Easily set up private mesh networks for secure and reliable communication in remote areas." + ) + + makeRow( + icon: "location", + title: "Track and Share Locations", + subtitle: "Share your location in real-time and keep your group coordinated with integrated GPS features." + ) + } + .padding() + } + .interactiveDismissDisabled() + } + Spacer() + + Button { + Task { + await goToNextStep(after: nil) + } + } label: { + Text("Get started") + .frame(maxWidth: .infinity) + } + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + + var notificationView: some View { + VStack { + VStack { + Text("App Notifications") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + VStack(alignment: .leading, spacing: 16) { + Text("Send Notifications") + .font(.title2.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "message", + title: "Incoming Messages", + subtitle: "Meshtastic notifications for channel messages and direct messages" + ) + makeRow( + icon: "flipphone", + title: "New Nodes", + subtitle: "Allow Meshtastic to send notifications for messages, newly discovered nodes and low battery alerts for the connected device." + ) + makeRow( + icon: "battery.25percent", + title: "Low Battery", + subtitle: "Allow Meshtastic to send notifications for messages, newly discovered nodes and low battery alerts for the connected device." + ) + Text("Critical Alerts") + .font(.title2.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "exclamationmark.triangle.fill", + subtitle: "Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center." + ) + } + .padding() + Spacer() + Button { + Task { + await requestNotificationsPermissions() + await goToNextStep(after: .notifications) + } + } label: { + Text("Configure notification permissions") + .frame(maxWidth: .infinity) + } + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + + var locationView: some View { + VStack { + VStack { + Text("Phone Location") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text("Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from Settings > App Settings > Open Settings.") + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "location", + title: "Share Location", + subtitle: "Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node." + ) + makeRow( + icon: "lines.measurement.horizontal", + title: "Distance Measurements", + subtitle: "Used to display the distance between your phone and other Meshtastic nodes where positions are available." + ) + makeRow( + icon: "line.3.horizontal.decrease.circle", + title: "Distance Filters", + subtitle: "Filter the node list and mesh map based on proximity to your phone." + ) + makeRow( + icon: "mappin", + title: "Mesh Map Location", + subtitle: "Enables the blue location dot for your phone in the mesh map." + ) + } + .padding() + Spacer() + if LocationHelper.shared.locationManager.authorizationStatus != .notDetermined { + Button { + Task { + await goToNextStep(after: .location) + } + } label: { + Text("Continue to next step") + .frame(maxWidth: .infinity) + } + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + } + + var mqttView: some View { + VStack { + VStack { + Text("MQTT") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button { + Task { + + } + } label: { + Text("Enable MQTT") + .frame(maxWidth: .infinity) + } + .padding() + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + + Button { + dismiss() + } label: { + Text("Set up later") + .frame(maxWidth: .infinity) + } + } + } + + var body: some View { + NavigationStack(path: $navigationPath) { + welcomeView + .navigationDestination(for: SetupGuide.self) { guide in + switch guide { + case .notifications: + notificationView + case .location: + locationView + case .mqtt: + mqttView + } + } + } + .toolbar(.hidden) + } + + @ViewBuilder + func makeRow( + icon: String, + title: String = "", + subtitle: String + ) -> some View { + HStack(alignment: .center) { + Image(systemName: icon) + .resizable() + .symbolRenderingMode(.multicolor) + .font(.subheadline) + .aspectRatio(contentMode: .fit) + .padding() + .frame(width: 72, height: 72) + + VStack(alignment: .leading) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + }.multilineTextAlignment(.leading) + }.accessibilityElement(children: .combine) + } + + // MARK: Navigation + func goToNextStep(after step: SetupGuide?) async { + switch step { + case .none: + let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + let criticalAlert = await UNUserNotificationCenter.current().notificationSettings().criticalAlertSetting + if status == .notDetermined && criticalAlert == .notSupported { + navigationPath.append(.notifications) + } else { + fallthrough + } + case .notifications: + let status = LocationHelper.shared.locationManager.authorizationStatus + if status == .notDetermined { + navigationPath.append(.location) + let newStatus = await LocationsHandler.shared.manager.requestAlwaysAuthorization() + } else { + fallthrough + } + case .location: + + if true { + navigationPath.append(.mqtt) + } else { + fallthrough + } + case .mqtt: + dismiss() + } + } + + // MARK: Permission Checks + + func requestNotificationsPermissions() async { + let center = UNUserNotificationCenter.current() + do { + let success = try await center.requestAuthorization(options: [.alert, .badge, .sound, .criticalAlert]) + if success { + Logger.services.info("Notification permissions are enabled") + } else { + Logger.services.info("Notification permissions denied") + } + } catch { + Logger.services.error("Notification permissions error: \(error.localizedDescription)") + } + } +}