diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 8020869f..8c321273 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -4160,6 +4160,9 @@ } } } + }, + "App Notifications" : { + }, "App Settings" : { "localizations" : { @@ -8257,6 +8260,12 @@ } } } + }, + "Configure Location Permissions" : { + + }, + "Configure notification permissions" : { + }, "Confirm" : { "localizations" : { @@ -9209,6 +9218,9 @@ } } } + }, + "Critical Alerts" : { + }, "Current" : { "localizations" : { @@ -16022,6 +16034,9 @@ } } } + }, + "Get started" : { + }, "Get the latest alpha firmware" : { "localizations" : { @@ -20796,6 +20811,9 @@ } } } + }, + "Meshtastic" : { + }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { @@ -20830,6 +20848,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" : { @@ -26059,6 +26080,9 @@ } } } + }, + "Phone Location" : { + }, "Pin %lld" : { "localizations" : { @@ -32107,6 +32131,9 @@ } } } + }, + "Send Notifications" : { + }, "Send Reboot OTA" : { "localizations" : { @@ -41148,6 +41175,9 @@ } } } + }, + "Welcome to" : { + }, "What does the lock mean?" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 3dcdeb79..828dcafb 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -147,6 +147,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 */; }; @@ -202,7 +203,6 @@ DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */; }; DDC2E15F26CE248F0042C5E4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDC2E15E26CE248F0042C5E4 /* Preview Assets.xcassets */; }; DDC2E18F26CE25FE0042C5E4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC2E18E26CE25FE0042C5E4 /* ContentView.swift */; }; - DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */; }; DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC4C9FE2A8D982900CE201C /* DetectionSensorConfig.swift */; }; DDC4D568275499A500A4208E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC4D567275499A500A4208E /* Persistence.swift */; }; DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC94FC029CE063B0082EA6E /* BatteryLevel.swift */; }; @@ -447,6 +447,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 = ""; }; @@ -516,7 +517,6 @@ DDC2E15E26CE248F0042C5E4 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DDC2E16526CE248F0042C5E4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DDC2E18E26CE25FE0042C5E4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationHelper.swift; sourceTree = ""; }; DDC4C9FE2A8D982900CE201C /* DetectionSensorConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorConfig.swift; sourceTree = ""; }; DDC4CA012A8DAA3800CE201C /* MeshtasticDataModelV16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV16.xcdatamodel; sourceTree = ""; }; DDC4D567275499A500A4208E /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; @@ -875,6 +875,14 @@ path = Help; sourceTree = ""; }; + DD74ED0B2DC6A0900059AC10 /* Onboarding */ = { + isa = PBXGroup; + children = ( + DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; DD7709392AA1ABA1007A8BF0 /* Tips */ = { isa = PBXGroup; children = ( @@ -1014,11 +1022,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 */, ); @@ -1094,7 +1103,6 @@ DDD43FE12A78C86B0083A3E9 /* Mqtt */, DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, DD1BEF492E0292220090CE24 /* KeychainHelper.swift */, - DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */, DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */, DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */, DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */, @@ -1425,7 +1433,6 @@ 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */, - DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */, 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */, DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, @@ -1474,6 +1481,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.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index 68276229..4a0652bc 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0dabe052e9e56f8514254d01df9aa7245e16b28a649d59bac6781d4ac9a79efa", + "originHash" : "fd71b247ba909b0eb360db5530e1068363839c5e169dea6f6a9974b2d98276f4", "pins" : [ { "identity" : "cocoamqtt", diff --git a/Meshtastic/Enums/AppSettingsEnums.swift b/Meshtastic/Enums/AppSettingsEnums.swift index 8355c4b4..7be3a1ae 100644 --- a/Meshtastic/Enums/AppSettingsEnums.swift +++ b/Meshtastic/Enums/AppSettingsEnums.swift @@ -78,7 +78,7 @@ enum UserTrackingModes: Int, CaseIterable, Identifiable { var description: String { switch self { case .none: - return "None".localized + return "map.usertrackingmode.none".localized case .follow: return "Follow".localized case .followWithHeading: diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 0ca337e9..0b124ac5 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -74,6 +74,8 @@ extension UserDefaults { case environmentEnableWeatherKit case enableAdministration case mapReportingOptIn + case firstLaunch + case showDeviceOnboarding case usageDataAndCrashReporting case testIntEnum } @@ -159,6 +161,11 @@ extension UserDefaults { @UserDefault(.usageDataAndCrashReporting, defaultValue: true) static var usageDataAndCrashReporting: 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 d4df2b86..ee8bd26c 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -1103,6 +1103,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate isSubscribed = true allowDisconnect = 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/LocationHelper.swift b/Meshtastic/Helpers/LocationHelper.swift deleted file mode 100644 index 978ae5a8..00000000 --- a/Meshtastic/Helpers/LocationHelper.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import CoreLocation -import MapKit -import OSLog - -class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate { - static let shared = LocationHelper() - var locationManager = CLLocationManager() - - // @Published var region = MKCoordinateRegion() - @Published var authorizationStatus: CLAuthorizationStatus? - override init() { - super.init() - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters - locationManager.pausesLocationUpdatesAutomatically = true - locationManager.allowsBackgroundLocationUpdates = true - locationManager.activityType = .other - } - // Apple Park - static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090) - static var currentLocation: CLLocationCoordinate2D { - guard let location = shared.locationManager.location else { - return DefaultLocation - } - return location.coordinate - } - static var satsInView: Int { - // If we have a position we have a sat - var sats = 1 - if shared.locationManager.location?.verticalAccuracy ?? 0 > 0 { - sats = 4 - if 0...5 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 12 - } else if 6...15 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 10 - } else if 16...30 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 9 - } else if 31...45 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 7 - } else if 46...60 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 5 - } - } else if shared.locationManager.location?.verticalAccuracy ?? 0 < 0 && 60...300 ~= shared.locationManager.location?.horizontalAccuracy ?? 0 { - sats = 3 - } else if shared.locationManager.location?.verticalAccuracy ?? 0 < 0 && shared.locationManager.location?.horizontalAccuracy ?? 0 > 300 { - sats = 2 - } - return sats - } - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - switch manager.authorizationStatus { - case .authorizedAlways: - authorizationStatus = .authorizedAlways - case .authorizedWhenInUse: - authorizationStatus = .authorizedWhenInUse - locationManager.requestLocation() - case .restricted: - authorizationStatus = .restricted - case .denied: - authorizationStatus = .denied - case .notDetermined: - authorizationStatus = .notDetermined - locationManager.requestAlwaysAuthorization() - default: - break - } - } - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - - } - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - Logger.services.error("Location manager error: \(error.localizedDescription, privacy: .public)") - } -} diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 23493604..645959f3 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -10,14 +10,14 @@ import CoreLocation import OSLog // Shared state that manages the `CLLocationManager` and `CLBackgroundActivitySession`. -@MainActor class LocationsHandler: ObservableObject { +@MainActor class LocationsHandler: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { static let shared = LocationsHandler() // Create a single, shared instance of the object. - private let manager: CLLocationManager + public var manager = CLLocationManager() private var background: CLBackgroundActivitySession? var enableSmartPosition: Bool = UserDefaults.enableSmartPosition - @Published var locationsArray: [CLLocation] + @Published var locationsArray: [CLLocation] = [CLLocation]() @Published var isStationary = false @Published var count = 0 @Published var isRecording = false @@ -38,16 +38,29 @@ 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 { + return await withCheckedContinuation { continuation in + self.permissionContinuation = continuation + manager.requestAlwaysAuthorization() + } + } + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + // This is the line you need to add + permissionContinuation?.resume(returning: manager.authorizationStatus) + } - private init() { - self.manager = CLLocationManager() // Creating a location manager instance is safe to call here in `MainActor`. + override init() { + super.init() + self.manager.delegate = self self.manager.allowsBackgroundLocationUpdates = true - locationsArray = [CLLocation]() } func startLocationUpdates() { - 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 { diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 10a3d69b..24a577b2 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1027,7 +1027,7 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)", + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", messageId: newMessage.messageId, channel: newMessage.channel, userNum: Int64(packet.from), @@ -1058,7 +1058,7 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)", + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", messageId: newMessage.messageId, channel: newMessage.channel, userNum: Int64(newMessage.fromUser?.userId ?? "0"), diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 53e27ae6..284eba62 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 9eb1ce56..05f227b5 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -5,11 +5,10 @@ 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 +57,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..33e6e3fa --- /dev/null +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -0,0 +1,284 @@ +import CoreBluetooth +import OSLog +import SwiftUI +import Foundation +import MapKit + +struct DeviceOnboarding: View { + enum SetupGuide: Hashable { + case notifications + case location + } + + @EnvironmentObject var bleManager: BLEManager + @State var navigationPath: [SetupGuide] = [] + @State var locationStatus = LocationsHandler.shared.manager.authorizationStatus + + @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() + if bleManager.isSwitchedOn { + 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() + Button { + Task { + await requestLocationPermissions() + } + } label: { + Text("Configure Location Permissions") + .frame(maxWidth: .infinity) + } + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + + var body: some View { + NavigationStack(path: $navigationPath) { + welcomeView + .navigationDestination(for: SetupGuide.self) { guide in + switch guide { + case .notifications: + notificationView + case .location: + locationView + } + } + } + .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: + locationStatus = LocationsHandler.shared.manager.authorizationStatus + if locationStatus == .notDetermined || locationStatus == .restricted || locationStatus == .denied { + navigationPath.append(.location) + } else { + fallthrough + } + case .location: + let status = LocationsHandler.shared.manager.authorizationStatus + if status != .notDetermined && status != .restricted && status != .denied { + 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)") + } + } + + func requestLocationPermissions() async { + locationStatus = await LocationsHandler.shared.requestLocationAlwaysPermissions() + if locationStatus != .notDetermined { + Logger.services.info("Location permissions are enabled") + } else { + Logger.services.info("Location permissions denied") + } + dismiss() + } +}