mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/449fe2d6-dec9-4509-920e-e6196ca11d65 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
531 lines
16 KiB
Swift
531 lines
16 KiB
Swift
import CoreBluetooth
|
|
import OSLog
|
|
import SwiftUI
|
|
import Foundation
|
|
import MapKit
|
|
|
|
struct DeviceOnboarding: View {
|
|
enum SetupGuide: Hashable {
|
|
case notifications
|
|
case location
|
|
case backgroundActivity
|
|
case localNetwork
|
|
case bluetooth
|
|
}
|
|
|
|
@EnvironmentObject var accessoryManager: AccessoryManager
|
|
@ObservedObject private var locationsHandler: LocationsHandler = .shared
|
|
@State var navigationPath: [SetupGuide] = []
|
|
@State var locationStatus = LocationsHandler.shared.manager.authorizationStatus
|
|
@AppStorage("provideLocation") private var provideLocation: Bool = false
|
|
@AppStorage("provideLocationInterval") private var provideLocationInterval: Int = 30
|
|
@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) {
|
|
VStack {
|
|
// Title
|
|
title
|
|
.padding(.top)
|
|
// Onboarding
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
makeRow(
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
title: String(localized: "Stay Connected Anywhere"),
|
|
subtitle: String(localized: "Communicate off-the-grid with your friends and community without cell service.")
|
|
)
|
|
makeRow(
|
|
icon: "point.3.connected.trianglepath.dotted",
|
|
title: String(localized: "Create Your Own Networks"),
|
|
subtitle: String(localized: "Easily set up private mesh networks for secure and reliable communication in remote areas.")
|
|
)
|
|
makeRow(
|
|
icon: "location",
|
|
title: String(localized: "Track and Share Locations"),
|
|
subtitle: String(localized: "Share your location in real-time and keep your group coordinated with integrated GPS features.")
|
|
)
|
|
makeRow(
|
|
icon: "person.2.shield",
|
|
title: String(localized: "User Privacy"),
|
|
subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings.")
|
|
)
|
|
}
|
|
.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 {
|
|
ScrollView(.vertical) {
|
|
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: String(localized: "Incoming Messages"),
|
|
subtitle: String(localized: "Notifications for channel and direct messages.")
|
|
)
|
|
makeRow(
|
|
icon: "flipphone",
|
|
title: String(localized: "New Nodes"),
|
|
subtitle: String(localized: "Notifications for newly discovered nodes.")
|
|
)
|
|
makeRow(
|
|
icon: "battery.25percent",
|
|
title: String(localized: "Low Battery"),
|
|
subtitle: String(localized: "Notifications for 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: String(localized: "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 {
|
|
ScrollView(.vertical) {
|
|
VStack {
|
|
Text("Phone Location")
|
|
.font(.largeTitle.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(createLocationString())
|
|
.font(.body.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
makeRow(
|
|
icon: "location",
|
|
title: String(localized: "Share Location"),
|
|
subtitle: String(localized: "Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node.")
|
|
)
|
|
Toggle(isOn: $provideLocation ) {
|
|
Label {
|
|
Text("Enable Location Sharing")
|
|
} icon: {
|
|
Image(systemName: "location.circle")
|
|
}
|
|
}
|
|
.fixedSize()
|
|
.scaleEffect(0.85)
|
|
.padding(.leading, 52)
|
|
.tint(.accentColor)
|
|
.onChange(of: provideLocation) {
|
|
UserDefaults.provideLocationInterval = 30
|
|
UserDefaults.enableSmartPosition = true
|
|
}
|
|
makeRow(
|
|
icon: "lines.measurement.horizontal",
|
|
title: String(localized: "Distance Measurements"),
|
|
subtitle: String(localized: "Display the distance between your phone and other Meshtastic nodes with positions.")
|
|
)
|
|
makeRow(
|
|
icon: "line.3.horizontal.decrease.circle",
|
|
title: String(localized: "Distance Filters"),
|
|
subtitle: String(localized: "Filter the node list and mesh map based on proximity to your phone.")
|
|
)
|
|
makeRow(
|
|
icon: "mappin",
|
|
title: String(localized: "Mesh Map Location"),
|
|
subtitle: String(localized: "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 backgroundActivityView: some View {
|
|
VStack {
|
|
ScrollView(.vertical) {
|
|
VStack {
|
|
Text("Background Activity")
|
|
.font(.largeTitle.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(createBackgroundActivityString())
|
|
.font(.body.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
makeRow(
|
|
icon: "location.fill",
|
|
title: String(localized: "Continuous Location Updates"),
|
|
subtitle: String(localized: "Keep the mesh map updated and send your position to the mesh even while using other apps.")
|
|
)
|
|
makeRow(
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
title: String(localized: "Background Mesh Tracking"),
|
|
subtitle: String(localized: "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.")
|
|
)
|
|
makeRow(
|
|
icon: "battery.100.bolt",
|
|
title: String(localized: "Battery Usage"),
|
|
subtitle: String(localized: "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.")
|
|
)
|
|
Toggle(isOn: $locationsHandler.backgroundActivity) {
|
|
Label {
|
|
Text("Enable Background Activity")
|
|
} icon: {
|
|
Image(systemName: "location.circle")
|
|
}
|
|
}
|
|
.fixedSize()
|
|
.scaleEffect(0.85)
|
|
.padding(.leading, 52)
|
|
.tint(.accentColor)
|
|
}
|
|
.padding()
|
|
}
|
|
Spacer()
|
|
Button {
|
|
Task {
|
|
await goToNextStep(after: .backgroundActivity)
|
|
}
|
|
} label: {
|
|
Text("Continue")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding()
|
|
.buttonBorderShape(.capsule)
|
|
.controlSize(.large)
|
|
.padding()
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
var localNetworkView: some View {
|
|
VStack {
|
|
ScrollView(.vertical) {
|
|
VStack {
|
|
Text("Local Network Access")
|
|
.font(.largeTitle.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(createLocalNetworkString())
|
|
.font(.body.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
makeRow(
|
|
icon: "network",
|
|
title: "Network-based Nodes".localized,
|
|
subtitle: "The Meshtastic App can connect to and manage network-enabled nodes.".localized
|
|
)
|
|
makeRow(
|
|
icon: "person.and.background.dotted",
|
|
title: "Background Connections".localized,
|
|
subtitle: "Background network connections are not supported and may disconnect when you leave the app.".localized
|
|
)
|
|
makeRow(
|
|
icon: "arrow.trianglehead.2.clockwise",
|
|
title: "Minimum Firmware Version".localized,
|
|
subtitle: "For the best connection experience, minimum firmware version 2.7.4 is required.".localized
|
|
)
|
|
}
|
|
.padding()
|
|
}
|
|
Spacer()
|
|
Button {
|
|
Task {
|
|
await requestLocalNetworkPermissions()
|
|
await goToNextStep(after: .localNetwork)
|
|
}
|
|
} label: {
|
|
Text("Configure Local Network Access")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding()
|
|
.buttonBorderShape(.capsule)
|
|
.controlSize(.large)
|
|
.padding()
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
var bluetoothView: some View {
|
|
VStack {
|
|
ScrollView(.vertical) {
|
|
VStack {
|
|
Text("Bluetooth Connectivity")
|
|
.font(.largeTitle.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(createBluetoothString())
|
|
.font(.body.bold())
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
makeRow(
|
|
icon: "custom.bluetooth",
|
|
title: "Bluetooth Connected Nodes".localized,
|
|
subtitle: "The most reliable messaging experience is with Bluetooth Low Energy connected nodes.".localized
|
|
)
|
|
makeRow(
|
|
icon: "person.and.background.dotted",
|
|
title: "Background Connections".localized,
|
|
subtitle: "Bluetooth Low Energy supports background connections. When possible, the application will remain connected to these accessories while the app is in the background.".localized
|
|
)
|
|
}
|
|
.padding()
|
|
}
|
|
Spacer()
|
|
Button {
|
|
Task {
|
|
await requestBluetoothPermissions()
|
|
await goToNextStep(after: .bluetooth)
|
|
}
|
|
} label: {
|
|
Text("Configure Bluetooth Connectivity")
|
|
.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
|
|
case .backgroundActivity:
|
|
backgroundActivityView
|
|
case .bluetooth:
|
|
bluetoothView
|
|
case .localNetwork:
|
|
localNetworkView
|
|
}
|
|
}
|
|
}
|
|
.toolbar(.hidden)
|
|
}
|
|
|
|
@ViewBuilder
|
|
func makeRow(
|
|
icon: String,
|
|
title: String = "",
|
|
subtitle: String
|
|
) -> some View {
|
|
HStack(alignment: .center) {
|
|
if icon.starts(with: "custom.") {
|
|
Image(icon)
|
|
.resizable()
|
|
.symbolRenderingMode(.multicolor)
|
|
.font(.subheadline)
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.frame(width: 72, height: 60)
|
|
} else {
|
|
Image(systemName: icon)
|
|
.resizable()
|
|
.symbolRenderingMode(.multicolor)
|
|
.font(.subheadline)
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.frame(width: 72, height: 60)
|
|
}
|
|
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:
|
|
locationStatus = LocationsHandler.shared.manager.authorizationStatus
|
|
if locationStatus != .notDetermined && locationStatus != .restricted {
|
|
navigationPath.append(.backgroundActivity)
|
|
}
|
|
case .backgroundActivity:
|
|
navigationPath.append(.localNetwork)
|
|
case .localNetwork:
|
|
navigationPath.append(.bluetooth)
|
|
|
|
case .bluetooth:
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: Formatting
|
|
func createBackgroundActivityString() -> AttributedString {
|
|
var fullText = AttributedString("Meshtastic can track your location in the background to keep the mesh map updated and send your position to the mesh even when the app is not in the foreground. You can update this setting at any time from settings.")
|
|
if let range = fullText.range(of: "settings") {
|
|
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
|
|
fullText[range].foregroundColor = .blue
|
|
}
|
|
return fullText
|
|
}
|
|
|
|
func createLocationString() -> AttributedString {
|
|
var fullText = AttributedString(localized: "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.")
|
|
if let range = fullText.range(of: String(localized: "settings")) {
|
|
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
|
|
fullText[range].foregroundColor = .blue
|
|
}
|
|
return fullText
|
|
}
|
|
|
|
func createLocalNetworkString() -> AttributedString {
|
|
var fullText = AttributedString("Meshtastic accesses your local network to connect to TCP-based accessories. You can update the local network permissions at any time from settings.")
|
|
if let range = fullText.range(of: "settings") {
|
|
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
|
|
fullText[range].foregroundColor = .blue
|
|
}
|
|
return fullText
|
|
}
|
|
|
|
func createBluetoothString() -> AttributedString {
|
|
var fullText = AttributedString("Meshtastic uses Bluetooth to connect to BLE-based accessories. You can update the permissions at any time from settings.")
|
|
if let range = fullText.range(of: "settings") {
|
|
fullText[range].link = URL(string: UIApplication.openSettingsURLString)!
|
|
fullText[range].foregroundColor = .blue
|
|
}
|
|
return fullText
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
await goToNextStep(after: .location)
|
|
}
|
|
|
|
func requestLocalNetworkPermissions() async {
|
|
_ = await TCPTransport.requestLocalNetworkAuthorization()
|
|
}
|
|
|
|
func requestBluetoothPermissions() async {
|
|
_ = await BluetoothAuthorizationHelper.requestBluetoothAuthorization()
|
|
}
|
|
|
|
}
|