Meshtastic-Apple/Meshtastic/Views/Onboarding/DeviceOnboarding.swift
Garth Vander Houwen 69c318a9e1
Message list performance fixes into 2.7.6 (#1475)
* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

* Use mDNS text records for node name

* TCP IP and port on the connection screen

* Hide app icon chooser on mac

* Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465)

* 2.7.5 Working Changes (#1460)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise

* UserList: replace 5 slow calls to user.messageList with new fast calls

* NodeList: always put the connected node at the top of list (if it matches the node filters)

* ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N)

* ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls

* Fix incorrect appState.unreadDirectMessages calculations

* MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages

* UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages

* Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension

* Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids)

* MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count

* Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild

* ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity

* ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification

* ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear

* ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active

---------

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* message-list-performance: revert scrolling changes (#1472)

* Revert e0f0b4a0f7 (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear)

* Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification"

This reverts commit ee1a7c4415.

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>
2025-10-17 18:16:00 -07:00

456 lines
14 KiB
Swift

import CoreBluetooth
import OSLog
import SwiftUI
import Foundation
import MapKit
struct DeviceOnboarding: View {
enum SetupGuide: Hashable {
case notifications
case location
case localNetwork
case bluetooth
}
@EnvironmentObject var accessoryManager: AccessoryManager
@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 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 .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(.localNetwork)
}
case .localNetwork:
navigationPath.append(.bluetooth)
case .bluetooth:
dismiss()
}
}
// MARK: Formatting
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()
}
}