Merge branch '2.7.10' into copilot/improve-routing-speed

Resolve merge conflicts in:
- Localizable.xcstrings (new translation keys from 2.7.10)
- MeshtasticTests/RouterTests.swift (trailing comma style)
- MeshtasticTests/ConnectViewTests.swift (trailing comma style)

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-05 01:47:50 +00:00 committed by GitHub
commit eb9b0ff2c9
82 changed files with 1095 additions and 388 deletions

View file

@ -2,7 +2,6 @@
"sourceLanguage" : "en",
"strings" : {
"" : {
"shouldTranslate" : false,
"localizations" : {
"da" : {
"stringUnit" : {
@ -10,7 +9,8 @@
"value" : ""
}
}
}
},
"shouldTranslate" : false
},
"\t%@" : {
"localizations" : {
@ -225,12 +225,12 @@
},
"shouldTranslate" : false
},
" : %@" : {
": %@" : {
"localizations" : {
"da" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
},
"es" : {
@ -242,42 +242,42 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %@"
"value" : ": %@"
}
}
},
"shouldTranslate" : false
},
" : %d" : {
": %d" : {
"localizations" : {
"da" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
},
"es" : {
@ -289,31 +289,31 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
},
"sr" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
},
"zh-Hant-TW" : {
"stringUnit" : {
"state" : "translated",
"value" : " : %d"
"value" : ": %d"
}
}
},
@ -3018,7 +3018,9 @@
}
}
},
"A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : {},
"A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : {
},
"A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : {
"localizations" : {
"es" : {
@ -3863,7 +3865,9 @@
}
}
},
"Add CA" : {},
"Add CA" : {
},
"Add Channel" : {
"localizations" : {
"da" : {
@ -11484,8 +11488,12 @@
}
}
},
"Client CA Certificate" : {},
"Client Configuration" : {},
"Client CA Certificate" : {
},
"Client Configuration" : {
},
"Client Hidden" : {
"extractionState" : "stale",
"localizations" : {
@ -12186,7 +12194,9 @@
}
}
},
"Configuration" : {},
"Configuration" : {
},
"Configuration for: %@" : {
"localizations" : {
"da" : {
@ -13804,6 +13814,9 @@
}
}
}
},
"Created by:" : {
},
"Created: %@" : {
"localizations" : {
@ -14570,7 +14583,9 @@
}
}
},
"Delete All" : {},
"Delete All" : {
},
"Delete all config, keys and BLE bonds? " : {
"localizations" : {
"es" : {
@ -18174,7 +18189,9 @@
}
}
},
"Download TAK Server Data Package" : {},
"Download TAK Server Data Package" : {
},
"Drag & Drop Firmware Update" : {
"localizations" : {
"da" : {
@ -18961,7 +18978,9 @@
}
}
},
"Enable TAK Server" : {},
"Enable TAK Server" : {
},
"Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : {
"localizations" : {
"da" : {
@ -19728,8 +19747,12 @@
}
}
},
"Enter P12 Password" : {},
"Enter the password for the PKCS#12 file" : {},
"Enter P12 Password" : {
},
"Enter the password for the PKCS#12 file" : {
},
"environment" : {
"extractionState" : "stale",
"localizations" : {
@ -23771,7 +23794,9 @@
}
}
},
"Generate a data package (.zip) to configure TAK clients to connect to this server." : {},
"Generate a data package (.zip) to configure TAK clients to connect to this server." : {
},
"Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key." : {
"localizations" : {
"es" : {
@ -27266,10 +27291,18 @@
}
}
},
"Import" : {},
"Import .pem" : {},
"Import Custom .p12" : {},
"Import Error" : {},
"Import" : {
},
"Import .pem" : {
},
"Import Custom .p12" : {
},
"Import Error" : {
},
"Import Route" : {
"localizations" : {
"da" : {
@ -28388,6 +28421,9 @@
}
}
}
},
"Last updated by:" : {
},
"Later" : {
"comment" : "A button that dismisses an alert without taking any action.",
@ -31354,6 +31390,10 @@
"comment" : "A description of the read-only mode feature in TAK Server.",
"isCommentAutoGenerated" : true
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
"comment" : "Privacy policy text for Meshtastic.",
"isCommentAutoGenerated" : true
},
"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." : {
"localizations" : {
"es" : {
@ -32997,7 +33037,9 @@
}
}
},
"mTLS" : {},
"mTLS" : {
},
"Multiplier" : {
"localizations" : {
"da" : {
@ -39159,7 +39201,9 @@
}
}
},
"Port" : {},
"Port" : {
},
"Position" : {
"localizations" : {
"da" : {
@ -42816,7 +42860,9 @@
}
}
},
"Reload Bundled Certificates" : {},
"Reload Bundled Certificates" : {
},
"Remote administration for: %@" : {
"localizations" : {
"da" : {
@ -43623,7 +43669,9 @@
}
}
},
"Reset to Default" : {},
"Reset to Default" : {
},
"Restart" : {
"localizations" : {
"da" : {
@ -43676,7 +43724,9 @@
}
}
},
"Restart Server" : {},
"Restart Server" : {
},
"Restart to the node you are connected to" : {
"localizations" : {
"da" : {
@ -46448,8 +46498,6 @@
}
}
},
"Secure mTLS connection on port 8089. Both server and client certificates are required." : {},
"Secure mTLS connection on port 8089. Both server and client certificates are required. TAK Channel Index selects the channel index where TAK messages will be sent." : {
"comment" : "A footer for the TAK Server configuration section.",
"isCommentAutoGenerated" : true
@ -49143,7 +49191,9 @@
}
}
},
"Server Certificate" : {},
"Server Certificate" : {
},
"Server Option" : {
"localizations" : {
"da" : {
@ -49190,7 +49240,9 @@
}
}
},
"Server Status" : {},
"Server Status" : {
},
"Set" : {
"localizations" : {
"da" : {
@ -49237,6 +49289,10 @@
}
}
},
"Set a channel name" : {
"comment" : "A label for a button that sets a channel name.",
"isCommentAutoGenerated" : true
},
"Set LoRa Region" : {
"localizations" : {
"da" : {
@ -49856,6 +49912,10 @@
}
}
},
"Share with TAK Buddies" : {
"comment" : "A button that shares the QR code with TAK buddies.",
"isCommentAutoGenerated" : true
},
"Share your location in real-time and keep your group coordinated with integrated GPS features." : {
"localizations" : {
"de" : {
@ -52018,7 +52078,9 @@
}
}
},
"Status" : {},
"Status" : {
},
"Stay Connected Anywhere" : {
"localizations" : {
"de" : {
@ -52658,7 +52720,17 @@
}
},
"TAK Server" : {},
"TAK Cannot Be Used on Public Channel" : {
"comment" : "A warning displayed when the user's primary channel is public.",
"isCommentAutoGenerated" : true
},
"TAK Channel Index" : {
"comment" : "A label for the TAK channel index.",
"isCommentAutoGenerated" : true
},
"TAK Server" : {
},
"TAK Tracker" : {
"extractionState" : "stale",
"localizations" : {
@ -55989,7 +56061,9 @@
}
}
},
"TLS Certificates" : {},
"TLS Certificates" : {
},
"TLS Enabled" : {
"localizations" : {
"da" : {

View file

@ -103,6 +103,7 @@
8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; };
9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; };
A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; };
AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; };
ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; };
ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; };
B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; };
@ -434,6 +435,7 @@
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = "<group>"; };
8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = "<group>"; };
9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = "<group>"; };
AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = "<group>"; };
ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = "<group>"; };
ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = "<group>"; };
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
@ -1988,6 +1990,9 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
@ -2010,6 +2015,9 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2174,7 +2182,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.9;
MARKETING_VERSION = 2.7.10;
OTHER_LDFLAGS = (
"-weak_framework",
SwiftUI,
@ -2213,7 +2221,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.9;
MARKETING_VERSION = 2.7.10;
OTHER_LDFLAGS = (
"-weak_framework",
SwiftUI,
@ -2249,12 +2257,14 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.9;
MARKETING_VERSION = 2.7.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -2282,12 +2292,14 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.9;
MARKETING_VERSION = 2.7.10;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -59,8 +59,7 @@ extension MessageEntity {
let users = try context.fetch(request)
// If exactly one match is found, return its name
if users.count == 1, let name = users.first?.longName, !name.isEmpty
{
if users.count == 1, let name = users.first?.longName, !name.isEmpty {
return "\(name)"
}

View file

@ -1175,7 +1175,7 @@ actor MeshPackets {
// Fetch waypoint by waypointMessage.id, not packet.id
let fetchWaypointRequest = WaypointEntity.fetchRequest()
fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(waypointMessage.id))
let fetchedWaypoint = try context.fetch(fetchWaypointRequest)
// Fetch the node info to get the short name
var nodeShortName: String = "?"
@ -1199,6 +1199,7 @@ actor MeshPackets {
waypoint.longitudeI = waypointMessage.longitudeI
waypoint.icon = Int64(waypointMessage.icon)
waypoint.locked = Int64(waypointMessage.lockedTo)
waypoint.createdBy = Int64(packet.from)
if waypointMessage.expire >= 1 {
waypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire)))
} else {
@ -1254,6 +1255,7 @@ actor MeshPackets {
existingWaypoint.longitudeI = waypointMessage.longitudeI
existingWaypoint.icon = Int64(waypointMessage.icon)
existingWaypoint.locked = Int64(waypointMessage.lockedTo)
existingWaypoint.lastUpdatedBy = Int64(packet.from)
if waypointMessage.expire >= 1 {
existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire)))
} else {
@ -1278,4 +1280,3 @@ actor MeshPackets {
}
}
}

View file

@ -184,4 +184,3 @@ extension MqttClientProxyManager: CocoaMQTTDelegate {
Logger.mqtt.debug("📲 [MQTT Client Proxy] pong")
}
}

View file

@ -71,10 +71,7 @@ final class CoTXMLParser: NSObject, XMLParserDelegate {
}
// MARK: - XMLParserDelegate
func parser(_ parser: XMLParser, didStartElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]) {
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
elementStack.append(elementName)
currentElement = elementName
currentText = ""
@ -138,8 +135,7 @@ final class CoTXMLParser: NSObject, XMLParserDelegate {
}
}
func parser(_ parser: XMLParser, didEndElement elementName: String,
namespaceURI: String?, qualifiedName qName: String?) {
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
if elementName == "remarks" {
remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
}

View file

@ -494,4 +494,3 @@ enum TAKConnectionError: LocalizedError {
}
}
}

View file

@ -490,10 +490,12 @@
</entity>
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdBy" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="expire" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastUpdatedBy" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="locked" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>

View file

@ -565,18 +565,18 @@ struct DeviceConnectRow: View {
}
// Show transport type
#if !targetEnvironment(macCatalyst)
HStack(alignment: .center){
HStack(alignment: .center) {
TransportIcon(transportType: device.transportType)
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
VStack (alignment: .leading) {
VStack(alignment: .leading) {
Text("Last seen device:")
Text("\(String(describing: device))")
}
}
}.padding(.top, 3.0)
#else
//Different alignment for Mac
HStack(alignment: .firstTextBaseline){
// Different alignment for Mac
HStack(alignment: .firstTextBaseline) {
TransportIcon(transportType: device.transportType)
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
Text("Last seen device: \(String(describing: device))")
@ -609,4 +609,3 @@ struct DeviceConnectRow: View {
}
}
}

View file

@ -62,3 +62,7 @@ struct InvalidVersion: View {
}
}
}
#Preview {
InvalidVersion(minimumVersion: "2.5.4", version: "2.3.0")
}

View file

@ -94,3 +94,11 @@ enum BLESignalStrength: Int {
case normal = 1
case strong = 2
}
#Preview {
HStack(spacing: 16) {
SignalStrengthIndicator(signalStrength: .weak)
SignalStrengthIndicator(signalStrength: .normal)
SignalStrengthIndicator(signalStrength: .strong)
}
}

View file

@ -111,3 +111,15 @@ struct BatteryCompact: View {
} ?? "Unknown")
}
}
#Preview {
VStack(spacing: 12) {
BatteryCompact(batteryLevel: 75, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: 50, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: 25, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: 10, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: 100, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: 101, font: .caption, iconFont: .caption, color: .gray)
BatteryCompact(batteryLevel: nil, font: .caption, iconFont: .caption, color: .gray)
}
}

View file

@ -32,3 +32,15 @@ struct ChannelLock: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let encryptedChannel = ChannelEntity(context: context)
encryptedChannel.psk = Data([0x01, 0x02, 0x03, 0x04])
let unencryptedChannel = ChannelEntity(context: context)
unencryptedChannel.psk = Data()
return HStack(spacing: 16) {
ChannelLock(channel: encryptedChannel)
ChannelLock(channel: unencryptedChannel)
}
}

View file

@ -38,7 +38,7 @@ struct CompassView: View {
}
// Trigger a vibration if aligned with waypoint
private func checkAlignment(bearing: Double,heading: Double) {
private func checkAlignment(bearing: Double, heading: Double) {
// Compute minimal angular difference between heading and bearing in [0, 180]
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
let diff = min(rawDiff, 360 - rawDiff)
@ -53,7 +53,6 @@ struct CompassView: View {
inAlignment = false
}
}
private func distanceToWaypoint() -> CLLocationDistance? {
guard
@ -76,7 +75,6 @@ struct CompassView: View {
return formatter.string(from: measurement)
}
var body: some View {
NavigationStack {
VStack(spacing: 15) {
@ -88,14 +86,14 @@ struct CompassView: View {
.foregroundColor(color)
if let wp = waypointLocation {
HStack{
HStack {
Image(systemName: "mappin.and.ellipse")
Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))")
.font(.subheadline)
}
if let distance = distanceToWaypoint() {
HStack{
HStack {
Image(systemName: "lines.measurement.horizontal")
Text("Distance: \(formatDistance(distance))")
.font(.subheadline)
@ -137,7 +135,7 @@ struct CompassView: View {
)
// Move waypoint marker outside compass
.onChange(of: locationsHandler.heading) { _, _ in
checkAlignment(bearing: bearing,heading:locationsHandler.heading)
checkAlignment(bearing: bearing, heading:locationsHandler.heading)
}
}
@ -159,9 +157,7 @@ struct CompassView: View {
}
}
// MARK: - Waypoint Marker View
struct WaypointMarkerView: View {
let bearing: Double
let compassDegrees: Double
@ -177,9 +173,7 @@ struct WaypointMarkerView: View {
}
// MARK: - Bearing Calculator
struct BearingCalculator {
static func bearingBetween(
@ -205,9 +199,7 @@ struct BearingCalculator {
}
}
// MARK: - Marker Model
struct Marker: Hashable {
let degrees: Double
let label: String
@ -239,9 +231,7 @@ struct Marker: Hashable {
}
}
// MARK: - Compass Marker View
struct CompassMarkerView: View {
let marker: Marker
let compassDegrees: Double
@ -281,9 +271,7 @@ struct CompassMarkerView: View {
}
}
// MARK: - Preview
struct CompassView_Previews: PreviewProvider {
static var previews: some View {
CompassView(

View file

@ -28,3 +28,11 @@ struct DateTimeText: View {
}
}
}
#Preview {
VStack {
DateTimeText(dateTime: Date())
DateTimeText(dateTime: Calendar.current.date(byAdding: .day, value: -1, to: Date()))
DateTimeText(dateTime: nil)
}
}

View file

@ -51,3 +51,8 @@ struct MeshtasticLogo: View {
#endif
}
}
#Preview {
MeshtasticLogo()
.frame(width: 200, height: 44)
}

View file

@ -36,3 +36,15 @@ struct MessageTemplate: View {
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let user = UserEntity(context: context)
user.longName = "Test User"
user.shortName = "TU"
let message = MessageEntity(context: context)
message.messagePayload = "Hello, World!"
message.messageTimestamp = Int32(Date().timeIntervalSince1970)
message.replyID = 0
return MessageTemplate(user: user, message: message)
}

View file

@ -94,3 +94,15 @@ struct PowerMetricCompactWidget: View {
.background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
#Preview {
let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2)
Form {
LazyVGrid(columns: gridItemLayout) {
PowerMetricCompactWidget(type: .voltage, value: 3.72, title: "Channel 1 Voltage")
PowerMetricCompactWidget(type: .current, value: 125.3, title: "Channel 1 Current")
PowerMetricCompactWidget(type: .voltage, value: 5.01, title: "Channel 2 Voltage")
PowerMetricCompactWidget(type: .current, value: 42.7, title: "Channel 2 Current")
}
}
}

View file

@ -119,3 +119,12 @@ struct LEDIndicator: View {
}
}
}
#Preview {
HStack(spacing: 12) {
LEDIndicator(flash: .constant(1), color: .green)
.frame(width: 10, height: 10)
LEDIndicator(flash: .constant(0), color: .red)
.frame(width: 10, height: 10)
}
}

View file

@ -45,6 +45,16 @@ public struct RateLimitedButton<Content: View>: View {
}
}
#Preview {
RateLimitedButton(key: "preview", rateLimit: 30, action: { }) { rateLimitInfo in
if let info = rateLimitInfo {
Label("\(Int(info.secondsRemaining))s", systemImage: "clock")
} else {
Label("Send", systemImage: "paperplane")
}
}
}
// To store the time an action occured (name by a key) and the time limit
// Does not persist across app launches
class RateLimitStorage: ObservableObject {

View file

@ -69,3 +69,11 @@ struct SecureInput: View {
}
}
}
#Preview {
List {
SecureInput("Password", text: .constant("s3cretP@ss"), isValid: .constant(true))
SecureInput("Invalid Key", text: .constant("short"), isValid: .constant(false))
SecureInput("Empty", text: .constant(""), isValid: .constant(true))
}
}

View file

@ -118,3 +118,7 @@ func calculateDewPoint(temp: Float, relativeHumidity: Float, convertToLocale: Bo
}
return dewPointUnit.converted(to: format).value
}
#Preview {
LocalWeatherConditions(location: CLLocation(latitude: 47.6062, longitude: -122.3321))
}

View file

@ -22,6 +22,13 @@ struct TraceRouteComponent<V: View>: View {
}
}
#Preview {
TraceRouteComponent {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.title)
}
}
struct TraceRoute: Layout {
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {

View file

@ -56,8 +56,6 @@ struct MessageContextMenuItems: View {
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
// Compute a relay display string if relayNode is present
VStack {
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))")
.foregroundColor(.gray)

View file

@ -79,6 +79,12 @@ struct TapbackInputView: View {
}
}
#Preview {
TapbackInputView(text: .constant(""), isPresented: .constant(true)) { emoji in
print("Selected: \(emoji)")
}
}
extension UIView {
var firstResponder: UIView? {
guard !isFirstResponder else { return self }
@ -90,4 +96,3 @@ extension UIView {
return nil
}
}

View file

@ -141,3 +141,16 @@ struct DetectionSensorLog: View {
)
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return DetectionSensorLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -254,3 +254,16 @@ struct DeviceMetricsLog: View {
)
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return DeviceMetricsLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -186,3 +186,16 @@ struct EnvironmentMetricsLog: View {
return lower...upper
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return EnvironmentMetricsLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -40,3 +40,13 @@ struct ClientHistoryButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
connectedNode.num = 987654321
return ClientHistoryButton(connectedNode: connectedNode, node: node)
.environmentObject(AccessoryManager.shared)
}

View file

@ -64,3 +64,14 @@ struct DeleteNodeButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let connectedNode = NodeInfoEntity(context: context)
connectedNode.num = 987654321
let node = NodeInfoEntity(context: context)
node.num = 123456789
return DeleteNodeButton(connectedNode: connectedNode, node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -61,3 +61,13 @@ struct ExchangePositionsButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
connectedNode.num = 987654321
return ExchangePositionsButton(node: node, connectedNode: connectedNode)
.environmentObject(AccessoryManager.shared)
}

View file

@ -59,3 +59,13 @@ struct ExchangeUserInfoButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let connectedNode = NodeInfoEntity(context: context)
connectedNode.num = 987654321
return ExchangeUserInfoButton(node: node, connectedNode: connectedNode)
.environmentObject(AccessoryManager.shared)
}

View file

@ -79,3 +79,16 @@ struct FavoriteNodeButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return FavoriteNodeButton(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -51,3 +51,12 @@ struct IgnoreNodeButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
return IgnoreNodeButton(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -54,3 +54,15 @@ struct NavigateToButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
user.num = 123456789
node.user = user
return NavigateToButton(node: node)
}

View file

@ -31,3 +31,14 @@ struct NodeAlertsButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return NodeAlertsButton(context: context, node: node, user: user)
}

View file

@ -43,3 +43,15 @@ struct TraceRouteButton: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return TraceRouteButton(node: node)
.environmentObject(AccessoryManager.shared)
}

View file

@ -51,6 +51,14 @@ struct AnimatedNodePin: View, Equatable {
}
}
#Preview {
VStack(spacing: 20) {
AnimatedNodePin(nodeColor: .systemBlue, shortName: "TN", hasDetectionSensorMetrics: false, isOnline: true, calculatedDelay: 0.0)
AnimatedNodePin(nodeColor: .systemGreen, shortName: "AB", hasDetectionSensorMetrics: true, isOnline: true, calculatedDelay: 0.2)
AnimatedNodePin(nodeColor: .systemRed, shortName: "XY", hasDetectionSensorMetrics: false, isOnline: false, calculatedDelay: 0.0)
}
}
struct PulsingCircle: View {
let nodeColor: UIColor
let calculatedDelay: Double

View file

@ -177,6 +177,7 @@ struct MeshMapContent: MapContent {
}
}
}
.annotationTitles(.automatic)
}
}
}

View file

@ -228,3 +228,13 @@ struct MapSettingsForm: View {
}
}
#Preview {
MapSettingsForm(
traffic: .constant(false),
pointsOfInterest: .constant(true),
mapLayer: .constant(.standard),
meshMap: .constant(true),
enabledOverlayConfigs: .constant(Set<UUID>())
)
}

View file

@ -10,6 +10,7 @@ import MapKit
import MeshtasticProtobufs
import OSLog
import SwiftUI
import CoreData
struct WaypointForm: View {
@ -31,134 +32,218 @@ struct WaypointForm: View {
@State private var lockedTo: Int64 = 0
@State private var selectedDetent: PresentationDetent = .medium
@State private var waypointFailedAlert: Bool = false
@State private var createdByNode : NodeInfoEntity? = nil
@State private var lastUpdatedByNode : NodeInfoEntity? = nil
var body: some View {
NavigationStack {
if editMode {
Text((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint")
.font(.largeTitle)
Divider()
Form {
if let cl = LocationsHandler.currentLocation {
let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude ))
Section(header: Text("Coordinate") ) {
Group {
if editMode {
Text((waypoint.id > 0) ? "Editing Waypoint" : "Create Waypoint")
.font(.largeTitle)
Divider()
Form {
if let cl = LocationsHandler.currentLocation {
let distance = CLLocation(latitude: cl.latitude, longitude: cl.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude ))
Section(header: Text("Coordinate") ) {
HStack {
Text("Location:")
.foregroundColor(.secondary)
Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))")
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption)
}
Button {
waypoint.coordinate.longitude = cl.longitude
waypoint.coordinate.latitude = cl.latitude
} label: {
HStack {
Text("Location:")
.foregroundColor(.secondary)
Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))")
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption)
}
Button {
waypoint.coordinate.longitude = cl.longitude
waypoint.coordinate.latitude = cl.latitude
} label: {
HStack {
Text("Use my Location")
Image(systemName: "location")
}
}
.accessibilityLabel("Set to current location")
HStack {
if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 {
DistanceText(meters: distance)
.foregroundColor(Color.gray)
}
Text("Use my Location")
Image(systemName: "location")
}
}
}
Section(header: Text("Waypoint Options")) {
.accessibilityLabel("Set to current location")
HStack {
Text("Name")
Spacer()
TextField(
"Name",
text: $name,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: name) {
var totalBytes = name.utf8.count
// Only mess with the value if it is too big
while totalBytes > 30 {
name = String(name.dropLast())
totalBytes = name.utf8.count
}
waypoint.name = name.count > 0 ? name : "Dropped Pin"
if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 {
DistanceText(meters: distance)
.foregroundColor(Color.gray)
}
}
HStack {
Text("Description")
Spacer()
TextField(
"Description",
text: $description,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: description) {
var totalBytes = description.utf8.count
// Only mess with the value if it is too big
while totalBytes > 100 {
description = String(description.dropLast())
totalBytes = description.utf8.count
}
}
}
HStack {
Text("Icon")
Spacer()
TextField("Select an emoji", text: $icon)
.keyboardType(.emoji)
.font(.title)
.focused($iconIsFocused)
.onChange(of: icon) { _, value in
// If a second emoji is entered delete the first one
if value.count >= 1 {
if value.count > 1 {
let index = value.index(value.startIndex, offsetBy: 1)
icon = String(value[index])
}
}
}
}
Toggle(isOn: $expires) {
Label("Expires", systemImage: "clock.badge.xmark")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if expires {
DatePicker("Expire", selection: $expire, in: Date.now...)
.datePickerStyle(.compact)
.font(.callout)
}
Toggle(isOn: $locked) {
Label("Locked", systemImage: "lock")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
}
.scrollDismissesKeyboard(.immediately)
HStack {
Button {
guard let deviceNum = accessoryManager.activeDeviceNum else {
Logger.mesh.warning("Send waypoint failed: No deviceNum")
return
}
if accessoryManager.isConnected {
/// Send a new or exiting waypoint
var newWaypoint = Waypoint()
if waypoint.id == 0 {
newWaypoint.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
waypoint.id = Int64(newWaypoint.id)
} else {
newWaypoint.id = UInt32(waypoint.id)
Section(header: Text("Waypoint Options")) {
HStack {
Text("Name")
Spacer()
TextField(
"Name",
text: $name,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: name) {
var totalBytes = name.utf8.count
// Only mess with the value if it is too big
while totalBytes > 30 {
name = String(name.dropLast())
totalBytes = name.utf8.count
}
newWaypoint.latitudeI = waypoint.latitudeI
newWaypoint.longitudeI = waypoint.longitudeI
waypoint.name = name.count > 0 ? name : "Dropped Pin"
}
}
HStack {
Text("Description")
Spacer()
TextField(
"Description",
text: $description,
axis: .vertical
)
.foregroundColor(Color.gray)
.onChange(of: description) {
var totalBytes = description.utf8.count
// Only mess with the value if it is too big
while totalBytes > 100 {
description = String(description.dropLast())
totalBytes = description.utf8.count
}
}
}
HStack {
Text("Icon")
Spacer()
EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji")
.font(.title)
.focused($iconIsFocused)
.onChange(of: icon) {
// If it contains non-emoji characters, clear it
if !icon.onlyEmojis() {
icon = ""
return
}
// If multiple emojis are entered or pasted, keep only the last one
if icon.count > 1 {
icon = String(icon.suffix(1))
}
iconIsFocused = false
}
}
Toggle(isOn: $expires) {
Label("Expires", systemImage: "clock.badge.xmark")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if expires {
DatePicker("Expire", selection: $expire, in: Date.now...)
.datePickerStyle(.compact)
.font(.callout)
}
Toggle(isOn: $locked) {
Label("Locked", systemImage: "lock")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
}
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
HStack {
Button {
guard let deviceNum = accessoryManager.activeDeviceNum else {
Logger.mesh.warning("Send waypoint failed: No deviceNum")
return
}
if accessoryManager.isConnected {
/// Send a new or exiting waypoint
var newWaypoint = Waypoint()
if waypoint.id == 0 {
newWaypoint.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
waypoint.createdBy = Int64(deviceNum)
waypoint.id = Int64(newWaypoint.id)
} else {
waypoint.lastUpdatedBy = Int64(deviceNum)
newWaypoint.id = UInt32(waypoint.id)
}
newWaypoint.latitudeI = waypoint.latitudeI
newWaypoint.longitudeI = waypoint.longitudeI
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
newWaypoint.description_p = description
// Unicode scalar value for the icon emoji string
let unicodeScalers = icon.unicodeScalars
// First element as an UInt32
let unicode = unicodeScalers[unicodeScalers.startIndex].value
newWaypoint.icon = unicode
if locked {
if lockedTo == 0 {
newWaypoint.lockedTo = UInt32(deviceNum)
} else {
newWaypoint.lockedTo = UInt32(lockedTo)
}
}
if expires {
newWaypoint.expire = UInt32(expire.timeIntervalSince1970)
} else {
newWaypoint.expire = 0
}
Task {
do {
try await accessoryManager.sendWaypoint(waypoint: newWaypoint)
dismiss()
} catch {
Logger.mesh.warning("Send waypoint failed: \(error)")
Task { @MainActor in
waypointFailedAlert = true
}
}
}
} else {
Logger.mesh.warning("Send waypoint failed, node not connected")
}
} label: {
Label("Send", systemImage: "arrow.up")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.disabled(!accessoryManager.isConnected)
.padding(.bottom)
Button(role: .cancel) {
dismiss()
} label: {
Label("Cancel", systemImage: "x.circle")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.padding(.bottom)
if waypoint.id > 0 && accessoryManager.isConnected {
Menu {
Button("For me", action: {
context.delete(waypoint)
do {
try context.save()
} catch {
context.rollback()
}
dismiss() })
Button("For everyone", action: {
guard let deviceNum = accessoryManager.activeDeviceNum else {
Logger.mesh.error("Unable to set waypoint: No Device num")
return
}
var newWaypoint = Waypoint()
newWaypoint.id = UInt32(waypoint.id)
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
newWaypoint.description_p = description
newWaypoint.latitudeI = waypoint.latitudeI
newWaypoint.longitudeI = waypoint.longitudeI
// Unicode scalar value for the icon emoji string
let unicodeScalers = icon.unicodeScalars
// First element as an UInt32
@ -171,101 +256,28 @@ struct WaypointForm: View {
newWaypoint.lockedTo = UInt32(lockedTo)
}
}
if expires {
newWaypoint.expire = UInt32(expire.timeIntervalSince1970)
} else {
newWaypoint.expire = 0
}
newWaypoint.expire = UInt32(1)
Task {
do {
try await accessoryManager.sendWaypoint(waypoint: newWaypoint)
dismiss()
} catch {
Logger.mesh.warning("Send waypoint failed: \(error)")
Task { @MainActor in
context.delete(waypoint)
do {
try context.save()
} catch {
context.rollback()
}
dismiss()
}
} catch {
Logger.mesh.warning("Send waypoint failed")
Task {@MainActor in
waypointFailedAlert = true
}
}
}
} else {
Logger.mesh.warning("Send waypoint failed, node not connected")
}
} label: {
Label("Send", systemImage: "arrow.up")
})
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.disabled(!accessoryManager.isConnected)
.padding(.bottom)
Button(role: .cancel) {
dismiss()
} label: {
Label("Cancel", systemImage: "x.circle")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.padding(.bottom)
if waypoint.id > 0 && accessoryManager.isConnected {
Menu {
Button("For me", action: {
context.delete(waypoint)
do {
try context.save()
} catch {
context.rollback()
}
dismiss() })
Button("For everyone", action: {
guard let deviceNum = accessoryManager.activeDeviceNum else {
Logger.mesh.error("Unable to set waypoint: No Device num")
return
}
var newWaypoint = Waypoint()
newWaypoint.id = UInt32(waypoint.id)
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
newWaypoint.description_p = description
newWaypoint.latitudeI = waypoint.latitudeI
newWaypoint.longitudeI = waypoint.longitudeI
// Unicode scalar value for the icon emoji string
let unicodeScalers = icon.unicodeScalars
// First element as an UInt32
let unicode = unicodeScalers[unicodeScalers.startIndex].value
newWaypoint.icon = unicode
if locked {
if lockedTo == 0 {
newWaypoint.lockedTo = UInt32(deviceNum)
} else {
newWaypoint.lockedTo = UInt32(lockedTo)
}
}
newWaypoint.expire = UInt32(1)
Task {
do {
try await accessoryManager.sendWaypoint(waypoint: newWaypoint)
Task { @MainActor in
context.delete(waypoint)
do {
try context.save()
} catch {
context.rollback()
}
dismiss()
}
} catch {
Logger.mesh.warning("Send waypoint failed")
Task {@MainActor in
waypointFailedAlert = true
}
}
}
})
}
label: {
Label("Delete", systemImage: "trash")
.foregroundColor(.red)
@ -274,130 +286,167 @@ struct WaypointForm: View {
.buttonBorderShape(.capsule)
.controlSize(.regular)
.padding(.bottom)
}
}
} else {
VStack {
HStack {
CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 50)
Spacer()
Text(waypoint.name ?? "?")
.font(.largeTitle)
Spacer()
if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) {
Image(systemName: "lock.fill")
.font(.largeTitle)
} else {
Button {
editMode = true
selectedDetent = .fraction(0.85)
} label: {
Image(systemName: "square.and.pencil" )
.font(.largeTitle)
.symbolRenderingMode(.hierarchical)
.foregroundColor(.accentColor)
}
}
}
} else {
VStack {
HStack {
CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 50)
Spacer()
Text(waypoint.name ?? "?")
.font(.largeTitle)
Spacer()
if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) {
Image(systemName: "lock.fill")
.font(.largeTitle)
} else {
Button {
editMode = true
selectedDetent = .fraction(0.85)
} label: {
Image(systemName: "square.and.pencil" )
.font(.largeTitle)
.symbolRenderingMode(.hierarchical)
.foregroundColor(.accentColor)
Divider()
VStack(alignment: .leading) {
// Nodes who created/modified
VStack(alignment: .leading, spacing: 12) {
if let created = createdByNode {
VStack(alignment: .leading, spacing: 6) {
Text("Created by:")
.font(.headline)
HStack(spacing: 8) {
CircleText(
text: created.user?.shortName ?? "?",
color: Color(UIColor(hex: UInt32(created.user?.num ?? 0x808080)))
)
Text(created.user?.longName ?? "Unknown")
.font(.body)
}
}
}
if let updated = lastUpdatedByNode {
VStack(alignment: .leading, spacing: 6) {
Text("Last updated by:")
.font(.headline)
HStack(spacing: 8) {
CircleText(
text: updated.user?.shortName ?? "?",
color: Color(UIColor(hex: UInt32(updated.user?.num ?? 0x808080)))
)
Text(updated.user?.longName ?? "Unknown")
.font(.body)
}
}
}
}
Divider()
VStack(alignment: .leading) {
// Description
if (waypoint.longDescription ?? "").count > 0 {
Label {
Text(waypoint.longDescription ?? "")
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "doc.plaintext")
}
.padding(.bottom)
}
/// Coordinate
.padding(.bottom)
// Description
if (waypoint.longDescription ?? "").count > 0 {
Label {
Text("Coordinates:")
Text(waypoint.longDescription ?? "")
.foregroundColor(.primary)
Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))")
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption2)
} icon: {
Image(systemName: "mappin.circle")
Image(systemName: "doc.plaintext")
}
.padding(.bottom)
// Drop Maps Pin
Button(action: {
if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") {
UIApplication.shared.open(url)
}
}) {
Label("Drop Pin in Maps", systemImage: "mappin.and.ellipse")
}
/// Coordinate
Label {
Text("Coordinates:")
.foregroundColor(.primary)
Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))")
.textSelection(.enabled)
.foregroundColor(.secondary)
.font(.caption2)
} icon: {
Image(systemName: "mappin.circle")
}
.padding(.bottom)
// Drop Maps Pin
Button(action: {
if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") {
UIApplication.shared.open(url)
}
.padding(.bottom)
/// Created
}) {
Label("Drop Pin in Maps", systemImage: "mappin.and.ellipse")
}
.padding(.bottom)
/// Created
Label {
Text("Created: \(waypoint.created?.formatted() ?? "?")")
.foregroundColor(.primary)
} icon: {
Image(systemName: "clock.badge.checkmark")
.symbolRenderingMode(.hierarchical)
}
.padding(.bottom)
/// Updated
if waypoint.lastUpdated != nil {
Label {
Text("Created: \(waypoint.created?.formatted() ?? "?")")
Text("Updated: \(waypoint.lastUpdated?.formatted() ?? "?")")
.foregroundColor(.primary)
} icon: {
Image(systemName: "clock.badge.checkmark")
Image(systemName: "clock.arrow.circlepath")
.symbolRenderingMode(.hierarchical)
}
.padding(.bottom)
/// Updated
if waypoint.lastUpdated != nil {
Label {
Text("Updated: \(waypoint.lastUpdated?.formatted() ?? "?")")
.foregroundColor(.primary)
} icon: {
Image(systemName: "clock.arrow.circlepath")
.symbolRenderingMode(.hierarchical)
}
.padding(.bottom)
}
/// Expires
if waypoint.expire != nil {
Label {
Text("Expires: \(waypoint.expire?.formatted() ?? "?")")
.foregroundColor(.primary)
} icon: {
Image(systemName: "hourglass.bottomhalf.filled")
.symbolRenderingMode(.hierarchical)
}
/// Expires
if waypoint.expire != nil {
.padding(.bottom, 5)
}
/// Distance
if let cl = LocationsHandler.currentLocation {
if cl.distance(from: cl) > 0.0 {
let metersAway = waypoint.coordinate.distance(from: cl)
Label {
Text("Expires: \(waypoint.expire?.formatted() ?? "?")")
Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
.foregroundColor(.primary)
} icon: {
Image(systemName: "hourglass.bottomhalf.filled")
Image(systemName: "lines.measurement.horizontal")
.symbolRenderingMode(.hierarchical)
.frame(width: 35)
}
.padding(.bottom, 5)
}
/// Distance
if let cl = LocationsHandler.currentLocation {
if cl.distance(from: cl) > 0.0 {
let metersAway = waypoint.coordinate.distance(from: cl)
Label {
Text("Distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
.foregroundColor(.primary)
} icon: {
Image(systemName: "lines.measurement.horizontal")
.symbolRenderingMode(.hierarchical)
.frame(width: 35)
}
.padding(.bottom, 5)
}
}
}
.padding(.top)
#if targetEnvironment(macCatalyst)
Spacer()
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
}
.padding(.top)
#if targetEnvironment(macCatalyst)
Spacer()
Button {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
#endif
}
}
}
.alert("Waypoint Failed to Send", isPresented: $waypointFailedAlert) {
Button("OK", role: .cancel) {
context.delete(waypoint)
@ -421,6 +470,9 @@ struct WaypointForm: View {
}
}
}
.task {
await fetchNodeInfo()
}
.onAppear {
if waypoint.id > 0 {
let waypoint = getWaypoint(id: Int64(waypoint.id), context: context)
@ -453,4 +505,37 @@ struct WaypointForm: View {
.presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.85)))
.presentationDragIndicator(.visible)
}
private func fetchNodeInfo() async {
// --- Fetch createdBy node ---
if waypoint.createdBy != 0 {
let createdByFetch: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
createdByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.createdBy))
createdByFetch.fetchLimit = 1
do {
let nodes = try context.fetch(createdByFetch)
createdByNode = nodes.first
} catch {
Logger.services.warning("Error fetching createdBy node: \(error.localizedDescription)")
}
}
// --- Fetch lastUpdatedBy node (only if different from createdBy) ---
if waypoint.lastUpdatedBy != 0,
waypoint.lastUpdatedBy != waypoint.createdBy {
let updatedByFetch: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
updatedByFetch.predicate = NSPredicate(format: "num == %lld", Int64(waypoint.lastUpdatedBy))
updatedByFetch.fetchLimit = 1
do {
let nodes = try context.fetch(updatedByFetch)
lastUpdatedByNode = nodes.first
} catch {
Logger.services.warning("Error fetching lastUpdatedBy node: \(error.localizedDescription)")
}
}
}
}

View file

@ -99,3 +99,10 @@ struct MetricsColumnDetail: View {
.interactiveDismissDisabled(false)
}
}
#Preview {
MetricsColumnDetail(
columnList: MetricsColumnList(columns: []),
seriesList: MetricsSeriesList()
)
}

View file

@ -208,3 +208,7 @@ struct NodeListFilter: View {
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}
#Preview {
NodeListFilter(filters: NodeFilterParameters())
}

View file

@ -120,12 +120,17 @@ struct MeshMap: View {
}
.sheet(item: $selectedWaypoint) { selection in
WaypointForm(waypoint: selection)
.presentationDetents([.large])
.padding()
.presentationDetents([.large]) // full screen
.presentationDragIndicator(.visible)
}
.sheet(item: $editingWaypoint) { selection in
WaypointForm(waypoint: selection, editMode: true)
.padding()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $editingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs)
}

View file

@ -224,3 +224,16 @@ struct PaxCounterLog: View {
)
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return PaxCounterLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -181,3 +181,16 @@ struct PositionLog: View {
})
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return PositionLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -297,3 +297,16 @@ struct PowerMetricsLog: View {
)
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return PowerMetricsLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -287,3 +287,16 @@ func getTraceRouteHops(context: NSManagedObjectContext) -> [TraceRouteHopEntity]
array.append(trh8)
return array
}
#Preview {
let context = PersistenceController.preview.container.viewContext
let node = NodeInfoEntity(context: context)
node.num = 123456789
let user = UserEntity(context: context)
user.longName = "Test Node"
user.shortName = "TN"
node.user = user
return TraceRouteLog(node: node)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -454,3 +454,8 @@ struct DeviceOnboarding: View {
}
}
#Preview {
DeviceOnboarding()
.environmentObject(AccessoryManager.shared)
}

View file

@ -67,3 +67,9 @@ struct AboutMeshtastic: View {
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationView {
AboutMeshtastic()
}
}

View file

@ -143,3 +143,10 @@ struct AppData: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return AppData()
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -271,3 +271,7 @@ extension AppLog {
}
extension OSLogEntry: @retroactive Identifiable { }
#Preview {
AppLog()
}

View file

@ -250,3 +250,21 @@ struct ChannelForm: View {
}
}
}
#Preview {
ChannelForm(
channelIndex: .constant(0),
channelName: .constant("LongFast"),
channelKeySize: .constant(32),
channelKey: .constant("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="),
channelRole: .constant(1),
uplink: .constant(false),
downlink: .constant(false),
positionPrecision: .constant(14),
preciseLocation: .constant(false),
positionsEnabled: .constant(true),
hasChanges: .constant(false),
hasValidKey: .constant(true),
supportedVersion: .constant(true)
)
}

View file

@ -147,3 +147,10 @@ struct BluetoothConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return BluetoothConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -37,3 +37,13 @@ struct ConfigHeader<T>: View {
}
}
}
#Preview {
ConfigHeader(
title: "Bluetooth Configuration",
config: \NodeInfoEntity.bluetoothConfig,
node: nil,
onAppear: { }
)
.environmentObject(AccessoryManager.shared)
}

View file

@ -345,3 +345,10 @@ struct DeviceConfig: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return DeviceConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -235,3 +235,10 @@ struct DisplayConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return DisplayConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -321,3 +321,10 @@ struct LoRaConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return LoRaConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -134,3 +134,10 @@ struct AmbientLightingConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return AmbientLightingConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -355,3 +355,10 @@ struct CannedMessagesConfig: View {
self.hasMessagesChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return CannedMessagesConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -261,3 +261,10 @@ struct DetectionSensorConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return DetectionSensorConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -283,3 +283,9 @@ struct ExternalNotificationConfig: View {
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return ExternalNotificationConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -464,3 +464,10 @@ struct MQTTConfig: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return MQTTConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -123,3 +123,10 @@ struct PaxCounterConfig: View {
paxcounterUpdateInterval = UpdateInterval(from: Int(node?.paxCounterConfig?.updateInterval ?? 1800))
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return PaxCounterConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -143,3 +143,10 @@ struct RangeTestConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return RangeTestConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -114,3 +114,10 @@ struct RtttlConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return RtttlConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -205,3 +205,10 @@ struct SerialConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return SerialConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -197,3 +197,10 @@ struct StoreForwardConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return StoreForwardConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -241,3 +241,10 @@ struct TelemetryConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return TelemetryConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -209,3 +209,10 @@ struct NetworkConfig: View {
self.hasChanges = false
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return NetworkConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -562,3 +562,10 @@ struct PositionConfig: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return PositionConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -226,3 +226,10 @@ private struct FloatField: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return PowerConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -58,3 +58,8 @@ struct SaveConfigButton: View {
}
}
}
#Preview {
SaveConfigButton(node: nil, hasChanges: .constant(true), onConfirmation: { })
.environmentObject(AccessoryManager.shared)
}

View file

@ -428,3 +428,10 @@ struct SecurityConfig: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return SecurityConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -206,3 +206,10 @@ struct Firmware: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return Firmware(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -65,3 +65,7 @@ struct GPSStatus: View {
}
}
}
#Preview {
GPSStatus()
}

View file

@ -169,3 +169,7 @@ struct AppLogFilter: View {
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
}
#Preview {
AppLogFilter(categories: .constant(Set<Int>()), levels: .constant(Set<Int>()))
}

View file

@ -424,9 +424,7 @@ struct TAKServerConfig: View {
}
}
// MARK: - Channel Label
@ViewBuilder
private func channelLabel(_ channel: ChannelEntity) -> some View {
if channel.name?.isEmpty ?? false {

View file

@ -55,3 +55,11 @@ struct UpdateIntervalPicker: View {
}
}
}
#Preview {
UpdateIntervalPicker(
config: .broadcastShort,
pickerLabel: "Update Interval",
selectedInterval: .constant(UpdateInterval(from: 30))
)
}

View file

@ -253,3 +253,10 @@ struct UserConfig: View {
}
}
}
#Preview {
let context = PersistenceController.preview.container.viewContext
return UserConfig(node: nil)
.environmentObject(AccessoryManager.shared)
.environment(\.managedObjectContext, context)
}

View file

@ -55,7 +55,7 @@ struct DeviceTests {
(-80, BLESignalStrength.normal),
(-84, BLESignalStrength.normal),
(-85, BLESignalStrength.weak),
(-100, BLESignalStrength.weak),
(-100, BLESignalStrength.weak)
])
func signalStrength(rssi: Int, expected: BLESignalStrength) {
let device = Device(
@ -209,7 +209,7 @@ struct TransportTypeTests {
@Test(arguments: [
(TransportType.ble, "BLE"),
(TransportType.tcp, "TCP"),
(TransportType.serial, "Serial"),
(TransportType.serial, "Serial")
])
func rawValues(type: TransportType, expected: String) {
#expect(type.rawValue == expected)
@ -307,7 +307,7 @@ struct NavigationStateTests {
NavigationState.Tab.connect,
NavigationState.Tab.nodes,
NavigationState.Tab.map,
NavigationState.Tab.settings,
NavigationState.Tab.settings
])
func tabRawValues(tab: NavigationState.Tab) {
#expect(NavigationState.Tab(rawValue: tab.rawValue) == tab)

View file

@ -214,7 +214,7 @@ struct RouterTests {
("debugLogs", SettingsNavigationState.debugLogs),
("appFiles", SettingsNavigationState.appFiles),
("firmwareUpdates", SettingsNavigationState.firmwareUpdates),
("tak", SettingsNavigationState.tak),
("tak", SettingsNavigationState.tak)
])
func routeSettingsPage(path: String, expected: SettingsNavigationState) async throws {
try await assertRoute(