Meshtastic-Apple/Meshtastic/Views/Settings/UserConfig.swift
Copilot 894e9382d8
Add missing SwiftUI #Preview blocks across 65 views (#1649)
* Add SwiftUI previews for simple helper views

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Add previews for action buttons, ChannelForm, MetricsColumnDetail, and DeviceOnboarding

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Add previews for config views, log views, AppLog, Firmware, AppData, and UserConfig

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Add preview for PositionConfig

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/a2a43e8c-24fd-443a-8a98-13b678770edd

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Fix formatting bugs in #Preview blocks: restore missing .environmentObject/.environment modifiers and add proper tab indentation

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/7eeb7a54-7928-466f-8e39-b00d0012a09d

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Linting fixes

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
2026-04-04 18:02:32 -07:00

262 lines
8.7 KiB
Swift

//
// User.swift
// Meshtastic Apple
//
// Copyright (c) Garth Vander Houwen 6/27/22.
//
import CoreData
import MeshtasticProtobufs
import SwiftUI
struct UserConfig: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.dismiss) private var goBack
var node: NodeInfoEntity?
enum Field: Hashable {
case frequencyOverride
}
@State private var isPresentingFactoryResetConfirm: Bool = false
@State private var isPresentingSaveConfirm: Bool = false
@State var hasChanges = false
@State var shortName = ""
@State var longName: String = ""
@State var isUnmessagable: Bool = false
@State var isLicensed = false
@State var overrideDutyCycle = false
@State var overrideFrequency: Float = 0.0
@State var txPower = 0
@FocusState var focusedField: Field?
public var minimumVersion = "2.6.9"
let floatFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter
}()
var body: some View {
Form {
Section(header: Text("User Details")) {
VStack(alignment: .leading) {
HStack {
Label(isLicensed ? "Call Sign" : "Long Name", systemImage: "person.crop.rectangle.fill")
TextField("Long Name", text: $longName)
.onChange(of: longName) {
var newValue = longName.withoutVariationSelectors
var totalBytes = newValue.utf8.count
// Only mess with the value if it is too big
while totalBytes > (isLicensed ? 6 : 36) {
newValue = String(newValue.dropLast())
totalBytes = newValue.utf8.count
}
longName = newValue
if longName.contains("📵") {
isUnmessagable = true
}
}
}
.keyboardType(.default)
.disableAutocorrection(true)
if longName.isEmpty && isLicensed {
Label("Call Sign must not be empty", systemImage: "exclamationmark.square")
.foregroundColor(.red)
}
Text("\(String(isLicensed ? "Call Sign" : "Long Name")) can be up to \(isLicensed ? "8" : "36") bytes long.")
.foregroundColor(.gray)
.font(.callout)
}
VStack(alignment: .leading) {
HStack {
Label("Short Name", systemImage: "circlebadge.fill")
TextField("Short Name", text: $shortName)
.foregroundColor(.gray)
.onChange(of: shortName) {
let newValue = shortName.withoutVariationSelectors
let totalBytes = newValue.utf8.count
// Only mess with the value if it is too big
if totalBytes > 4 {
// If too long, drop the last thing entered
shortName = String(shortName.dropLast())
} else if shortName != newValue {
// If not too long, make sure the stripped
// variant is placed back in text field if necessary
shortName = newValue
}
}
.foregroundColor(.gray)
}
.keyboardType(.default)
.disableAutocorrection(true)
Text("The last 4 of the device MAC address will be appended to the short name to set the device's BLE Name. Short name can be up to 4 bytes long.")
.foregroundColor(.gray)
.font(.callout)
let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion)
Toggle(isOn: $isUnmessagable) {
Label("Unmessagable", systemImage: "iphone.slash")
Text("Used to identify unmonitored or infrastructure nodes so that messaging is not avaliable to nodes that will never respond.")
.font(.caption2)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.disabled(!supportedVersion)
}
// Only manage ham mode for the locally connected node
if node?.num ?? 0 > 0 && node?.num ?? 0 == accessoryManager.activeDeviceNum ?? 0 {
Toggle(isOn: $isLicensed) {
Label("Licensed Operator", systemImage: "person.text.rectangle")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if isLicensed {
Text("Onboarding for licensed operators requires firmware 2.0.20 or greater. Make sure to refer to your local regulations and contact the local amateur frequency coordinators with questions.")
.font(.caption2)
Text("What licensed operator mode does:\n* Sets the node name to your call sign \n* Broadcasts node info every 10 minutes \n* Overrides frequency, dutycycle and tx power \n* Disables encryption")
.font(.caption2)
HStack {
Label("Frequency", systemImage: "waveform.path.ecg")
Spacer()
TextField("Frequency Override", value: $overrideFrequency, formatter: floatFormatter)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button("Dismiss") {
focusedField = nil
}
.font(.subheadline)
}
}
.keyboardType(.decimalPad)
.scrollDismissesKeyboard(.immediately)
.focused($focusedField, equals: .frequencyOverride)
}
HStack {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.accentColor)
Stepper("\(txPower)db Transmit Power", value: $txPower, in: 1...30, step: 1)
.padding(5)
}
}
}
}
}
.disabled(!accessoryManager.isConnected)
.safeAreaInset(edge: .bottom, alignment: .center) {
HStack(spacing: 0) {
if accessoryManager.isConnected && hasChanges {
Button {
isPresentingSaveConfirm = true
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.padding(.bottom)
.controlSize(.large)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.confirmationDialog(
"Are you sure?",
isPresented: $isPresentingSaveConfirm,
titleVisibility: .visible
) {
Button("Save User Config to \(node?.user?.longName ?? "Unknown")?") {
if longName.isEmpty && isLicensed {
return
}
let connectedUser = getUser(id: accessoryManager.activeDeviceNum ?? -1, context: context)
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context)
if node != nil && connectedNode != nil {
if !isLicensed {
var u = User()
u.shortName = shortName
u.longName = longName
u.isUnmessagable = isUnmessagable
Task {
_ = try await accessoryManager.saveUser(config: u, fromUser: connectedUser, toUser: node!.user!)
Task { @MainActor in
hasChanges = false
goBack()
}
}
} else {
var ham = HamParameters()
ham.shortName = shortName
// ham.isUnmessagable = isUnmessagable
ham.callSign = longName
ham.txPower = Int32(txPower)
ham.frequency = overrideFrequency
Task {
_ = try await accessoryManager.saveLicensedUser(ham: ham, fromUser: connectedUser, toUser: node!.user!)
Task { @MainActor in
hasChanges = false
goBack()
}
}
}
}
}
} message: {
Text("After config values save the node will reboot.")
}
}
}
}
.navigationTitle("User Config")
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
})
.onAppear {
self.shortName = node?.user?.shortName ?? ""
self.longName = node?.user?.longName ?? ""
self.isUnmessagable = node?.user?.unmessagable ?? false
self.isLicensed = node?.user?.isLicensed ?? false
self.txPower = Int(node?.loRaConfig?.txPower ?? 0)
self.overrideFrequency = node?.loRaConfig?.overrideFrequency ?? 0.00
self.hasChanges = false
}
.onChange(of: shortName) { oldShort, newShort in
if oldShort != newShort && newShort != node?.user?.shortName ?? "Unknown" { hasChanges = true }
}
.onChange(of: longName) { oldLong, newLong in
if oldLong != newLong && newLong != node?.user?.longName ?? "Unknown" { hasChanges = true }
}
.onChange(of: isUnmessagable) { oldIsUnmessagable, newIsUnmessagable in
if oldIsUnmessagable != newIsUnmessagable && newIsUnmessagable != node?.user?.unmessagable ?? true { hasChanges = true }
}
.onChange(of: isLicensed) { _, newIsLicensed in
if node != nil && node!.user != nil {
if newIsLicensed != node?.user!.isLicensed {
hasChanges = true
if newIsLicensed {
if node?.user?.longName?.count ?? 0 > 8 {
longName = ""
}
}
}
}
}
.onChange(of: overrideFrequency) {
if isLicensed { hasChanges = true }
}
.onChange(of: txPower) {
if isLicensed { hasChanges = true }
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return UserConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}