Device onboarding initial commit

This commit is contained in:
Garth Vander Houwen 2025-05-03 20:44:12 -07:00
parent 2201bad314
commit dfeaf848e0
9 changed files with 490 additions and 104 deletions

View file

@ -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" : {

View file

@ -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 = "<group>"; };
DD6F657A2C6EC2900053C113 /* LockLegend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockLegend.swift; sourceTree = "<group>"; };
DD73FD1028750779000852D6 /* PositionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionLog.swift; sourceTree = "<group>"; };
DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOnboarding.swift; sourceTree = "<group>"; };
DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetricsLog.swift; sourceTree = "<group>"; };
DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTips.swift; sourceTree = "<group>"; };
DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = "<group>"; };
@ -837,6 +839,14 @@
path = Help;
sourceTree = "<group>";
};
DD74ED0B2DC6A0900059AC10 /* Onboarding */ = {
isa = PBXGroup;
children = (
DD74ED0C2DC6A0B80059AC10 /* DeviceOnboarding.swift */,
);
path = Onboarding;
sourceTree = "<group>";
};
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 */,

View file

@ -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

View file

@ -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 })

View file

@ -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<CLAuthorizationStatus, Never>?
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 {

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}
}

View file

@ -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)")
}
}
}