From b72f4de09596c182e516133501e6a12849207607 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sat, 21 Mar 2026 12:13:36 -0500 Subject: [PATCH 01/48] Validate packet inputs and fail closed on malformed responses --- .../MeshCore/Protocol/PacketBuilder.swift | 21 +++++-- .../MeshCore/Protocol/PacketParser.swift | 6 ++ .../Sources/MeshCore/Protocol/Parsers.swift | 55 +++++++++++++++-- .../MeshCore/Session/MeshCoreSession.swift | 31 ++++++++-- .../Protocol/NewCommandsTests.swift | 53 ++++++++++++++++ .../Session/ConnectionStateTests.swift | 45 ++++++++++++++ .../Validation/NewResponseParsingTests.swift | 17 ++++++ .../Validation/ProtocolBugFixTests.swift | 16 +++++ .../Validation/RoundTripTests.swift | 61 +++++++++++++++++++ 9 files changed, 290 insertions(+), 15 deletions(-) diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift index 29485a2bf..14117c90c 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift @@ -37,6 +37,15 @@ public enum PacketBuilder: Sendable { /// Size of a public key in bytes. static let publicKeySize = 32 + static let rawDataMaxPathBytes = 64 + static let rawDataMaxPayloadBytes = 184 + + private static func encodePublicKey(_ publicKey: Data) -> Data { + if publicKey.count >= publicKeySize { + return publicKey.prefix(publicKeySize) + } + return publicKey + Data(repeating: 0, count: publicKeySize - publicKey.count) + } // MARK: - Device Commands @@ -790,7 +799,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (1 byte): Reserved `0x00` /// - Offset 2 (1 byte): Mode value (0, 1, or 2) public static func setPathHashMode(_ mode: UInt8) -> Data { - Data([CommandCode.setPathHashMode.rawValue, 0x00, mode]) + Data([CommandCode.setPathHashMode.rawValue, 0x00, min(mode, 2)]) } /// Builds a factoryReset command to wipe all settings and data from the device. @@ -864,10 +873,10 @@ public enum PacketBuilder: Sendable { /// - Offset 2+N (M bytes): Payload public static func sendRawData(path: Data, payload: Data) -> Data { var data = Data([CommandCode.sendRawData.rawValue]) - let clampedPath = path.prefix(255) + let clampedPath = path.prefix(rawDataMaxPathBytes) data.append(UInt8(clampedPath.count)) data.append(clampedPath) - data.append(payload) + data.append(payload.prefix(rawDataMaxPayloadBytes)) return data } @@ -881,7 +890,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (32 bytes): Full public key public static func hasConnection(publicKey: Data) -> Data { var data = Data([CommandCode.hasConnection.rawValue]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } @@ -895,7 +904,7 @@ public enum PacketBuilder: Sendable { /// - Offset 1 (32 bytes): Full public key public static func getContactByKey(publicKey: Data) -> Data { var data = Data([CommandCode.getContactByKey.rawValue]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } @@ -910,7 +919,7 @@ public enum PacketBuilder: Sendable { /// - Offset 2 (32 bytes): Full public key public static func getAdvertPath(publicKey: Data) -> Data { var data = Data([CommandCode.getAdvertPath.rawValue, 0x00]) - data.append(publicKey.prefix(publicKeySize)) + data.append(encodePublicKey(publicKey)) return data } diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift index c18b9ed4b..b6468fab0 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift @@ -102,6 +102,12 @@ extension PacketParser { reason: "Battery response too short: \(payload.count) < \(PacketSize.batteryMinimum)" ) } + if payload.count > PacketSize.batteryMinimum && payload.count < PacketSize.batteryExtended { + return .parseFailure( + data: payload, + reason: "Battery response has partial extended payload: \(payload.count) < \(PacketSize.batteryExtended)" + ) + } let level = Int(payload.readUInt16LE(at: 0)) var usedKB: Int? var totalKB: Int? diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 0c7b54ece..a30f91f02 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -150,6 +150,7 @@ public enum Parsers { let type = ContactType(rawValue: data[offset]) ?? .chat; offset += 1 let flags = ContactFlags(rawValue: data[offset]); offset += 1 let pathLen = data[offset]; offset += 1 + guard pathLen == 0xFF || decodePathLen(pathLen) != nil else { return nil } let actualPathLen = (pathLen == 0xFF) ? 0 : (decodePathLen(pathLen)?.byteLength ?? 0) // Read full 64-byte path field, but only use first actualPathLen bytes let pathBytes = Data(data[offset.. MeshEvent { + if data.count >= PacketSize.contact { + let pathLen = data[34] + if pathLen != 0xFF && decodePathLen(pathLen) == nil { + return .parseFailure( + data: data, + reason: "Contact response uses reserved path length encoding: 0x\(String(format: "%02X", pathLen))" + ) + } + } guard let contact = parseContactData(data) else { return .parseFailure( data: data, @@ -296,6 +306,13 @@ public enum Parsers { var model: String? var version: String? + if fwVer >= 3 && data.count < PacketSize.deviceInfoV3Full { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) response too short: \(data.count) < \(PacketSize.deviceInfoV3Full)" + ) + } + // v3+ format: fwBuild=12, model=40, version=20 bytes if fwVer >= 3 && data.count >= PacketSize.deviceInfoV3Full { maxContacts = Int(data[offset]) * 2 /// Stored as count/2 in firmware. @@ -320,14 +337,26 @@ public enum Parsers { // v9+: client_repeat byte after version string var clientRepeat = false - if fwVer >= 9 && data.count > offset { + if fwVer >= 9 { + guard data.count > offset else { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) missing client_repeat byte" + ) + } clientRepeat = data[offset] != 0 offset += 1 } // v10+: path_hash_mode byte after client_repeat var pathHashMode: UInt8 = 0 - if fwVer >= 10 && data.count > offset { + if fwVer >= 10 { + guard data.count > offset else { + return .parseFailure( + data: data, + reason: "DeviceInfo v\(fwVer) missing pathHashMode byte" + ) + } pathHashMode = data[offset] } @@ -391,7 +420,13 @@ public enum Parsers { let timestamp = Date(timeIntervalSince1970: TimeInterval(data.readUInt32LE(at: offset))); offset += 4 var signature: Data? - if txtType == 2 && data.count >= offset + 4 { + if txtType == 2 { + guard data.count >= offset + 4 else { + return .parseFailure( + data: data, + reason: "ContactMessage signature truncated: \(data.count) < \(offset + 4)" + ) + } signature = Data(data[offset..= 5 + byteLen else { + return .parseFailure( + data: data, + reason: "AdvertPathResponse path truncated: \(data.count) < \(5 + byteLen)" + ) + } let path = Data(data.dropFirst(5).prefix(byteLen)) return .advertPathResponse(MeshCore.AdvertPathResponse( diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 1523bc83d..6c0e28fac 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -720,6 +720,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. public func getContact(publicKey: Data) async throws -> MeshContact? { + try requireFullPublicKey(publicKey, operation: "getContact") let data = PacketBuilder.getContactByKey(publicKey: publicKey) return try await sendAndWait(data) { event in switch event { @@ -873,8 +874,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. public func requestStatus(from publicKey: Data) async throws -> StatusResponse { + try requireFullPublicKey(publicKey, operation: "requestStatus") // Serialize binary requests to prevent messageSent race conditions - try await binaryRequestSerializer.withSerialization { [self] in + return try await binaryRequestSerializer.withSerialization { [self] in try await performStatusRequest(from: publicKey) } } @@ -998,6 +1000,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: Information about the sent message. /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo { + try requireFullPublicKey(publicKey, operation: "sendKeepAlive") var syncSinceLE = syncSince.littleEndian let payload = withUnsafeBytes(of: &syncSinceLE) { Data($0) } let data = PacketBuilder.binaryRequest(to: publicKey, type: .keepAlive, payload: payload) @@ -1386,6 +1389,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func resetPath(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "resetPath") try await sendSimpleCommand(PacketBuilder.resetPath(publicKey: publicKey)) } @@ -1394,6 +1398,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact to remove. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func removeContact(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "removeContact") try await sendSimpleCommand(PacketBuilder.removeContact(publicKey: publicKey)) } @@ -1405,6 +1410,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the contact to share. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func shareContact(publicKey: Data) async throws { + try requireFullPublicKey(publicKey, operation: "shareContact") try await sendSimpleCommand(PacketBuilder.shareContact(publicKey: publicKey)) } @@ -1414,7 +1420,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: A URI string encoding the contact information. /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. public func exportContact(publicKey: Data? = nil) async throws -> String { - try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in + if let publicKey { + try requireFullPublicKey(publicKey, operation: "exportContact") + } + return try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in if case .contactURI(let uri) = event { return uri } return nil } @@ -1819,6 +1828,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func setPathHashMode(_ mode: UInt8) async throws { + guard mode <= 2 else { + throw MeshCoreError.invalidInput("Path hash mode must be 0, 1, or 2") + } try await sendSimpleCommand(PacketBuilder.setPathHashMode(mode)) } @@ -1870,8 +1882,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. /// ``MeshCoreError/invalidResponse`` if unexpected response received. public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { + try requireFullPublicKey(publicKey, operation: "requestTelemetry") // Serialize binary requests to prevent messageSent race conditions - try await binaryRequestSerializer.withSerialization { [self] in + return try await binaryRequestSerializer.withSerialization { [self] in try await performTelemetryRequest(from: publicKey) } } @@ -1990,7 +2003,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: MMA response containing aggregated statistics. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. public func requestMMA(from publicKey: Data, start: Date, end: Date) async throws -> MMAResponse { - try await binaryRequestSerializer.withSerialization { [self] in + try requireFullPublicKey(publicKey, operation: "requestMMA") + return try await binaryRequestSerializer.withSerialization { [self] in try await performMMARequest(from: publicKey, start: start, end: end) } } @@ -2073,7 +2087,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: ACL response containing authorized public keys. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. public func requestACL(from publicKey: Data) async throws -> ACLResponse { - try await binaryRequestSerializer.withSerialization { [self] in + try requireFullPublicKey(publicKey, operation: "requestACL") + return try await binaryRequestSerializer.withSerialization { [self] in try await performACLRequest(from: publicKey) } } @@ -2581,6 +2596,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { break } } + + private func requireFullPublicKey(_ publicKey: Data, operation: String) throws { + guard publicKey.count == PacketBuilder.publicKeySize else { + throw MeshCoreError.invalidInput("Full \(PacketBuilder.publicKeySize)-byte public key required for \(operation)") + } + } } // MARK: - Configuration Types diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift index 9cd840bc4..97d3626e7 100644 --- a/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift @@ -25,6 +25,18 @@ struct NewCommandsTests { #expect(packet.count == 3, "command + pathLen + payload") } + @Test("sendRawData clamps to firmware limits") + func sendRawDataClampsToFirmwareLimits() { + let path = Data(repeating: 0x11, count: 80) + let payload = Data(repeating: 0x22, count: 220) + + let packet = PacketBuilder.sendRawData(path: path, payload: payload) + + #expect(packet[1] == 64, "Path length should clamp to firmware 64-byte limit") + #expect(Data(packet[2..<66]) == Data(repeating: 0x11, count: 64), "Path bytes should be truncated to 64 bytes") + #expect(Data(packet[66...]) == Data(repeating: 0x22, count: 184), "Payload should be truncated to firmware 184-byte limit") + } + @Test("hasConnection format") func hasConnectionFormat() { let pubkey = Data(repeating: 0xAA, count: 32) @@ -36,6 +48,17 @@ struct NewCommandsTests { #expect(Data(packet[1...]) == pubkey, "Public key") } + @Test("hasConnection pads short public key to protocol width") + func hasConnectionPadsShortPublicKey() { + let pubkey = Data(repeating: 0xAA, count: 31) + + let packet = PacketBuilder.hasConnection(publicKey: pubkey) + + #expect(packet.count == 33, "Packet should still contain a full 32-byte key field") + #expect(Data(packet[1..<32]) == Data(repeating: 0xAA, count: 31)) + #expect(packet[32] == 0x00, "Short public keys should be zero-padded at the builder layer") + } + @Test("getContactByKey format") func getContactByKeyFormat() { let pubkey = Data(repeating: 0xBB, count: 32) @@ -47,6 +70,17 @@ struct NewCommandsTests { #expect(Data(packet[1...]) == pubkey, "Public key") } + @Test("getContactByKey pads short public key to protocol width") + func getContactByKeyPadsShortPublicKey() { + let pubkey = Data(repeating: 0xBB, count: 31) + + let packet = PacketBuilder.getContactByKey(publicKey: pubkey) + + #expect(packet.count == 33, "Packet should still contain a full 32-byte key field") + #expect(Data(packet[1..<32]) == Data(repeating: 0xBB, count: 31)) + #expect(packet[32] == 0x00) + } + @Test("getAdvertPath format") func getAdvertPathFormat() { let pubkey = Data(repeating: 0xCC, count: 32) @@ -59,6 +93,17 @@ struct NewCommandsTests { #expect(Data(packet[2...]) == pubkey, "Public key") } + @Test("getAdvertPath pads short public key to protocol width") + func getAdvertPathPadsShortPublicKey() { + let pubkey = Data(repeating: 0xCC, count: 31) + + let packet = PacketBuilder.getAdvertPath(publicKey: pubkey) + + #expect(packet.count == 34, "Packet should still contain reserved byte plus a full 32-byte key field") + #expect(Data(packet[2..<33]) == Data(repeating: 0xCC, count: 31)) + #expect(packet[33] == 0x00) + } + @Test("getTuningParams format") func getTuningParamsFormat() { let packet = PacketBuilder.getTuningParams() @@ -82,4 +127,12 @@ struct NewCommandsTests { #expect(packet[1] == 0x00, "Reserved byte at offset 1 should be 0x00") #expect(packet[2] == mode, "Mode byte at offset 2 should be \(mode) (\(label))") } + + @Test("setPathHashMode clamps reserved values to max supported mode") + func setPathHashModeClampsReservedValues() { + let packet = PacketBuilder.setPathHashMode(3) + + #expect(packet.count == 3) + #expect(packet[2] == 2, "Reserved path hash modes should clamp to the max supported mode") + } } diff --git a/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift b/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift index 1113e13ef..e6cbe526b 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/ConnectionStateTests.swift @@ -60,6 +60,51 @@ struct ConnectionStateTests { await session.stop() } + @Test("getContact rejects short public key before sending") + func getContactRejectsShortPublicKey() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + _ = try await session.getContact(publicKey: Data(repeating: 0xAA, count: 31)) + } + + #expect(await transport.sentData.isEmpty) + } + + @Test("requestStatus rejects short public key before sending") + func requestStatusRejectsShortPublicKey() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + _ = try await session.requestStatus(from: Data(repeating: 0xBB, count: 31)) + } + + #expect(await transport.sentData.isEmpty) + } + + @Test("setPathHashMode rejects reserved mode before sending") + func setPathHashModeRejectsReservedMode() async { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + + await #expect(throws: MeshCoreError.self) { + try await session.setPathHashMode(3) + } + + #expect(await transport.sentData.isEmpty) + } + private func makeSelfInfoPacket(name: String = "TestNode") -> Data { var payload = Data() payload.append(0) diff --git a/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift index d68fc9e54..26652e37a 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/NewResponseParsingTests.swift @@ -54,6 +54,23 @@ struct NewResponseParsingTests { } } + @Test("advertPathResponse rejects reserved path length encoding") + func advertPathResponseRejectsReservedPathLengthEncoding() { + var payload = Data() + payload.appendLittleEndian(UInt32(1704067200)) + payload.append(0xC1) // mode 3 (reserved), hop count 1 + payload.append(0x11) + + let event = Parsers.AdvertPathResponse.parse(payload) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected parseFailure for reserved path length, got \(event)") + return + } + + #expect(reason.contains("reserved path length encoding")) + } + @Test("tuningParamsResponse parse") func tuningParamsResponseParse() { var payload = Data() diff --git a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift index b93d77d8c..79cf1b453 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift @@ -213,6 +213,22 @@ struct ProtocolBugFixTests { #expect(status.receiveErrors == 0, "receiveErrors should default to 0 when not present") } + @Test("battery response rejects partial extended payload") + func batteryResponseRejectsPartialExtendedPayload() { + var packet = Data([ResponseCode.battery.rawValue]) + packet.append(contentsOf: [0xE8, 0x03]) // 1000 mV + packet.append(contentsOf: [0x01, 0x02, 0x03]) // partial extended fields only + + let event = PacketParser.parse(packet) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("partial extended payload")) + } + @Test("statusResponse parseFromBinaryResponse 52 bytes parses rxAirtime") func statusResponseParseFromBinaryResponse52BytesParsesRxAirtime() { // 52 bytes: has rxAirtime but no receiveErrors diff --git a/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift index f6b873447..1ae6dacb6 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/RoundTripTests.swift @@ -59,6 +59,24 @@ struct RoundTripTests { #expect(abs(contact.longitude - (-122.4194)) <= 0.0001) } + @Test("Contact rejects reserved path length encoding") + func contactRejectsReservedPathLengthEncoding() { + var data = Data(repeating: 0, count: 147) + data.replaceSubrange(0..<32, with: Data(repeating: 0xAA, count: 32)) + data[32] = 1 + data[33] = 0 + data[34] = 0xC1 // mode 3 (reserved), hop count 1 + + let event = Parsers.Contact.parse(data) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("reserved path length encoding")) + } + // MARK: - SelfInfo Round-Trip @Test("SelfInfo round trip") @@ -265,6 +283,27 @@ struct RoundTripTests { #expect(msg.text == "Hello World") } + @Test("ContactMessage rejects truncated signature payload") + func contactMessageRejectsTruncatedSignaturePayload() { + var data = Data() + data.append(UInt8(bitPattern: Int8(0))) + data.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Data($0) }) + data.append(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xAB])) + data.append(0x00) + data.append(0x02) // signed text + data.append(contentsOf: withUnsafeBytes(of: UInt32(1704067200).littleEndian) { Data($0) }) + data.append(Data([0xDE, 0xAD, 0xBE])) // only 3 signature bytes, no text payload + + let event = Parsers.ContactMessage.parse(data, version: .v3) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("signature truncated")) + } + @Test("ChannelMessage v3 round trip") func channelMessageV3RoundTrip() { var data = Data() @@ -680,6 +719,28 @@ struct RoundTripTests { #expect(caps.pathHashMode == 2, "pathHashMode should be 2 (3-byte hashes)") } + @Test("DeviceInfo v10 rejects truncated payload before pathHashMode") + func deviceInfoV10RejectsTruncatedPayloadBeforePathHashMode() { + var data = Data() + data.append(10) // fwVer + data.append(50) // maxContacts + data.append(8) // maxChannels + let blePin: UInt32 = 654321 + data.append(contentsOf: withUnsafeBytes(of: blePin.littleEndian) { Data($0) }) + data.append(Data(repeating: 0, count: 12 + 40 + 20)) + data.append(1) // client_repeat present + #expect(data.count == 80, "v10 payload missing pathHashMode byte should be truncated") + + let event = Parsers.DeviceInfo.parse(data) + + guard case .parseFailure(_, let reason) = event else { + Issue.record("Expected .parseFailure event, got \(event)") + return + } + + #expect(reason.contains("missing pathHashMode byte")) + } + @Test("DeviceInfo v9 defaults pathHashMode to 0") func deviceInfoV9DefaultsPathHashMode() { // v9 firmware doesn't include pathHashMode — it should default to 0 From f57db0727612cc9b405ef48511002e7e77bd70d3 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sun, 22 Mar 2026 23:13:56 -0500 Subject: [PATCH 02/48] Fix MeshCoreSession response and error miscorrelation --- .../MeshCore/Events/EventDispatcher.swift | 21 +- .../MeshCore/Session/MeshCoreSession.swift | 249 +++++---- .../MeshCore/Session/RequestContext.swift | 49 +- ...shCoreSessionCommandCorrelationTests.swift | 522 ++++++++++++++++++ 4 files changed, 729 insertions(+), 112 deletions(-) create mode 100644 MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift diff --git a/MeshCore/Sources/MeshCore/Events/EventDispatcher.swift b/MeshCore/Sources/MeshCore/Events/EventDispatcher.swift index a6a15240c..f83beed38 100644 --- a/MeshCore/Sources/MeshCore/Events/EventDispatcher.swift +++ b/MeshCore/Sources/MeshCore/Events/EventDispatcher.swift @@ -58,6 +58,17 @@ public actor EventDispatcher { public func subscribe( filter: (@Sendable (MeshEvent) -> Bool)? ) -> AsyncStream { + subscribeTracked(filter: filter).stream + } + + /// Subscribes to events and returns the stream together with a handle that can + /// be finished explicitly by the caller. + /// + /// Explicit finishing is useful for timeout races, where a waiting task may + /// otherwise remain suspended on the stream after the caller has already moved on. + public func subscribeTracked( + filter: (@Sendable (MeshEvent) -> Bool)? = nil + ) -> (id: UUID, stream: AsyncStream) { let (stream, continuation) = AsyncStream.makeStream( of: MeshEvent.self, bufferingPolicy: .bufferingNewest(100) @@ -75,7 +86,7 @@ public actor EventDispatcher { } } - return stream + return (id, stream) } /// Dispatches an event to all subscribers, applying filters. @@ -104,6 +115,14 @@ public actor EventDispatcher { subscriptions.removeAll() } + /// Finishes and removes a specific subscription. + /// + /// Safe to call multiple times; unknown ids are ignored. + public func finishSubscription(id: UUID) { + guard let subscription = subscriptions.removeValue(forKey: id) else { return } + subscription.continuation.finish() + } + /// Removes a subscription from the dispatcher. /// /// - Parameter id: The unique identifier of the subscription to remove. diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 1523bc83d..929498bdb 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -84,6 +84,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { private let dispatcher = EventDispatcher() private let pendingRequests = PendingRequests() private let binaryRequestSerializer = BinaryRequestSerializer() + private let requestResponseSerializer = RequestResponseSerializer() // State private var contactManager = ContactManager() @@ -423,10 +424,11 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeout: TimeInterval? = nil ) async -> MeshEvent? { let effectiveTimeout = timeout ?? configuration.defaultTimeout + let (subscriptionID, events) = await dispatcher.subscribeTracked() return await withTaskGroup(of: MeshEvent?.self) { group in group.addTask { - for await event in await self.events() { + for await event in events { if Task.isCancelled { return nil } if predicate(event) { return event @@ -442,6 +444,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { let result = await group.next() ?? nil group.cancelAll() + await self.dispatcher.finishSubscription(id: subscriptionID) return result } } @@ -470,14 +473,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeout: TimeInterval? = nil ) async -> MeshEvent? { let effectiveTimeout = timeout ?? configuration.defaultTimeout + let (subscriptionID, stream) = await dispatcher.subscribeTracked(filter: filter.matches) return await withTaskGroup(of: MeshEvent?.self) { group in group.addTask { - // Use filtered subscription for efficiency - let stream = await self.dispatcher.subscribe(filter: filter.matches) for await event in stream { if Task.isCancelled { return nil } - // Event already passed filter, return immediately return event } return nil @@ -490,6 +491,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { let result = await group.next() ?? nil group.cancelAll() + await self.dispatcher.finishSubscription(id: subscriptionID) return result } } @@ -497,6 +499,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Sends a command and waits for a matching response. /// /// This method avoids race conditions by subscribing to events before sending the command. + /// Events that do not satisfy the matcher, including unrelated `.error` events, are + /// ignored until a matching response arrives or the timeout expires. /// /// - Parameters: /// - data: The command data to send. @@ -509,37 +513,11 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { matching predicate: @escaping @Sendable (MeshEvent) -> T?, timeout: TimeInterval? = nil ) async throws -> T { - let effectiveTimeout = timeout ?? configuration.defaultTimeout - - // Subscribe BEFORE sending to avoid race condition - let events = await dispatcher.subscribe() - - // Send after subscribing - try await transport.send(data) - - // Now wait for matching event - return try await withThrowingTaskGroup(of: T?.self) { group in - group.addTask { - for await event in events { - if Task.isCancelled { return nil } - if let result = predicate(event) { - return result - } - } - return nil + try await sendAndMatch(data, timeout: timeout) { event in + if let result = predicate(event) { + return .success(result) } - - group.addTask { [clock = self.clock] in - try await clock.sleep(for: .seconds(effectiveTimeout)) - return nil - } - - if let result = try await group.next() ?? nil { - group.cancelAll() - return result - } - group.cancelAll() - throw MeshCoreError.timeout + return .ignore } } @@ -548,50 +526,92 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameters: /// - data: Command data to send. /// - successPredicate: Predicate to match success events and extract result. + /// - errorMatcher: Optional matcher for request-specific error events. Errors that + /// do not match are ignored so unrelated commands cannot fail the active request. /// - timeout: Optional timeout override. /// - Returns: The extracted result on success. - /// - Throws: ``MeshCoreError/deviceError(code:)`` on error response, + /// - Throws: A matched ``MeshCoreError`` from `errorMatcher`, /// ``MeshCoreError/timeout`` on timeout. private func sendAndWaitWithError( _ data: Data, matching successPredicate: @escaping @Sendable (MeshEvent) -> T?, + errorMatcher: (@Sendable (MeshEvent) -> MeshCoreError?)? = nil, timeout: TimeInterval? = nil ) async throws -> T { - let effectiveTimeout = timeout ?? configuration.defaultTimeout + try await sendAndMatch(data, timeout: timeout) { event in + if let error = errorMatcher?(event) { + return .failure(error) + } + if let result = successPredicate(event) { + return .success(result) + } + return .ignore + } + } - // Subscribe BEFORE sending to avoid race condition - let events = await dispatcher.subscribe() + private enum ResponseDisposition { + case success(T) + case failure(MeshCoreError) + case ignore + } - // Send after subscribing - try await transport.send(data) + private func sendAndMatch( + _ data: Data, + timeout: TimeInterval? = nil, + matching matcher: @escaping @Sendable (MeshEvent) -> ResponseDisposition + ) async throws -> T { + try await requestResponseSerializer.withSerialization { [self] in + let effectiveTimeout = timeout ?? configuration.defaultTimeout - // Now wait for matching event - return try await withThrowingTaskGroup(of: T?.self) { group in - group.addTask { - for await event in events { - if Task.isCancelled { return nil } - // Check for error response first - if case .error(let code) = event { - throw MeshCoreError.deviceError(code: code ?? 0) - } - if let result = successPredicate(event) { - return result + // Subscribe BEFORE sending to avoid race condition, then ignore all + // non-matching events until this request sees its own response. + let (subscriptionID, events) = await dispatcher.subscribeTracked() + + do { + // Send after subscribing + try await transport.send(data) + + return try await withThrowingTaskGroup(of: T?.self) { group in + group.addTask { + for await event in events { + if Task.isCancelled { return nil } + + switch matcher(event) { + case .success(let result): + return result + case .failure(let error): + throw error + case .ignore: + continue + } + } + return nil } - } - return nil - } - group.addTask { [clock = self.clock] in - try await clock.sleep(for: .seconds(effectiveTimeout)) - return nil - } + group.addTask { [clock = self.clock] in + try await clock.sleep(for: .seconds(effectiveTimeout)) + return nil + } - if let result = try await group.next() ?? nil { - group.cancelAll() - return result + do { + if let result = try await group.next() ?? nil { + group.cancelAll() + await dispatcher.finishSubscription(id: subscriptionID) + return result + } + group.cancelAll() + await dispatcher.finishSubscription(id: subscriptionID) + throw MeshCoreError.timeout + } catch { + group.cancelAll() + await dispatcher.finishSubscription(id: subscriptionID) + throw error + } + } + } catch { + await dispatcher.finishSubscription(id: subscriptionID) + throw error } - group.cancelAll() - throw MeshCoreError.timeout } } @@ -602,11 +622,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// This is typically called automatically by ``start()``. /// /// - Returns: Information about the device itself. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit `selfInfo`. public func sendAppStart() async throws -> SelfInfo { let data = PacketBuilder.appStart(clientId: configuration.clientIdentifier) - return try await sendAndWaitWithError(data) { event in + return try await sendAndWait(data) { event in if case .selfInfo(let info) = event { return info } return nil } @@ -615,11 +634,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Queries the device for its capabilities and system information. /// /// - Returns: Information about the device hardware, firmware, and supported features. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit `deviceInfo`. public func queryDevice() async throws -> DeviceCapabilities { let data = PacketBuilder.deviceQuery() - return try await sendAndWaitWithError(data) { event in + return try await sendAndWait(data) { event in if case .deviceInfo(let info) = event { return info } return nil } @@ -628,11 +646,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Retrieves the current battery status from the device. /// /// - Returns: Battery voltage and charge level information. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit battery info. public func getBattery() async throws -> BatteryInfo { - let data = PacketBuilder.getBattery() - return try await sendAndWaitWithError(data) { event in + try await sendAndWait(PacketBuilder.getBattery()) { event in if case .battery(let info) = event { return info } return nil } @@ -717,19 +733,21 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// /// - Parameter publicKey: The full 32-byte public key of the contact. /// - Returns: The contact if found, or `nil` if no contact exists with that key. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit a matching contact response. public func getContact(publicKey: Data) async throws -> MeshContact? { let data = PacketBuilder.getContactByKey(publicKey: publicKey) - return try await sendAndWait(data) { event in + return try await sendAndMatch(data) { event in switch event { case .contact(let contact): - return contact + if contact.publicKey == publicKey { + return .success(contact) + } + return .ignore case .error: // Contact not found returns error, treat as nil - return nil as MeshContact? + return .success(nil) default: - return nil + return .ignore } } } @@ -859,11 +877,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter flood: If `true`, the advertisement is broadcast using flood routing. /// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. public func sendAdvertisement(flood: Bool = false) async throws { - let data = PacketBuilder.sendAdvertisement(flood: flood) - let _: Bool = try await sendAndWaitWithError(data) { event in - if case .ok = event { return true } - return nil - } + try await sendSimpleCommand(PacketBuilder.sendAdvertisement(flood: flood)) } /// Requests status information from a remote node using the binary protocol. @@ -871,6 +885,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the remote node. /// - Returns: A status response containing battery, uptime, and other metrics. /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. public func requestStatus(from publicKey: Data) async throws -> StatusResponse { // Serialize binary requests to prevent messageSent race conditions @@ -916,6 +931,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeoutContinuation.finish() case .error(let code): + timeoutContinuation.finish() throw MeshCoreError.deviceError(code: code ?? 0) case .binaryResponse(let tag, let responseData): @@ -934,6 +950,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { case .statusResponse(let response): // Handle already-routed response (if routing happens elsewhere) + guard response.publicKeyPrefix == publicKeyPrefix else { continue } let elapsed = ContinuousClock.now - startTime logger.info("Status request to \(prefixHex): routed response received in \(elapsed)") return response @@ -1082,10 +1099,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Gets the allowed frequency ranges for client repeat mode (v9+ firmware). /// /// - Returns: The allowed frequency ranges for repeat mode. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit repeat-frequency data. public func getRepeatFreq() async throws -> [FrequencyRange] { - try await sendAndWaitWithError(PacketBuilder.getRepeatFreq()) { event in + try await sendAndWait(PacketBuilder.getRepeatFreq()) { event in if case .allowedRepeatFreq(let ranges) = event { return ranges } return nil } @@ -1211,10 +1227,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Gets the current auto-add configuration from the device. /// /// - Returns: The auto-add configuration (bitmask + max hops). - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. - /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit auto-add configuration. public func getAutoAddConfig() async throws -> AutoAddConfig { - try await sendAndWaitWithError(PacketBuilder.getAutoAddConfig()) { event in + try await sendAndWait(PacketBuilder.getAutoAddConfig()) { event in if case .autoAddConfig(let config) = event { return config } return nil } @@ -1226,10 +1241,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. public func setAutoAddConfig(_ config: AutoAddConfig) async throws { - try await sendAndWaitWithError(PacketBuilder.setAutoAddConfig(config)) { event in - if case .ok = event { return () } - return nil - } + try await sendSimpleCommand(PacketBuilder.setAutoAddConfig(config)) } /// Returns the current device configuration from selfInfo. @@ -1277,10 +1289,15 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Retrieves telemetry data from the device. /// /// - Returns: Device telemetry including battery, temperature, and sensor data. + /// When `selfInfo` is available, only telemetry for the current device is accepted. /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. public func getSelfTelemetry() async throws -> TelemetryResponse { - try await sendAndWait(PacketBuilder.getSelfTelemetry()) { event in - if case .telemetryResponse(let response) = event { return response } + let expectedPrefix = selfInfo.map { Data($0.publicKey.prefix(6)) } + return try await sendAndWait(PacketBuilder.getSelfTelemetry()) { event in + if case .telemetryResponse(let response) = event, + expectedPrefix == nil || response.publicKeyPrefix == expectedPrefix { + return response + } return nil } } @@ -1327,14 +1344,18 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// /// - Parameter key: The 64-byte expanded private key to import. /// - Throws: ``MeshCoreError/featureDisabled`` if the device does not support key import, - /// ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure. + /// ``MeshCoreError/timeout`` if the device does not acknowledge the import, + /// or ``MeshCoreError/deviceError(code:)`` for a matched device error response. public func importPrivateKey(_ key: Data) async throws { let succeeded: Bool = try await sendAndWaitWithError( PacketBuilder.importPrivateKey(key) ) { event in - if case .ok = event { return true } + if case .ok(value: nil) = event { return true } if case .disabled = event { return false } return nil + } errorMatcher: { event in + guard case .error(let code?) = event else { return nil } + return MeshCoreError.deviceError(code: code) } if !succeeded { throw MeshCoreError.featureDisabled @@ -1828,10 +1849,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// /// - Parameter index: Channel index (0-255). /// - Returns: Channel information including name and secret. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't emit configuration for the requested channel. public func getChannel(index: UInt8) async throws -> ChannelInfo { try await sendAndWait(PacketBuilder.getChannel(index: index)) { event in - if case .channelInfo(let info) = event { return info } + if case .channelInfo(let info) = event, info.index == index { return info } return nil } } @@ -1868,6 +1889,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the remote node. /// - Returns: Telemetry response containing sensor data and device status. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. /// ``MeshCoreError/invalidResponse`` if unexpected response received. public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse { // Serialize binary requests to prevent messageSent race conditions @@ -1916,6 +1938,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeoutContinuation.finish() case .error(let code): + timeoutContinuation.finish() throw MeshCoreError.deviceError(code: code ?? 0) case .binaryResponse(let tag, let responseData): @@ -1932,6 +1955,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { case .telemetryResponse(let response): // Handle already-routed response (if routing happens elsewhere) + guard response.publicKeyPrefix == publicKeyPrefix else { continue } let elapsed = ContinuousClock.now - startTime logger.info("Telemetry request to \(prefixHex): routed response received in \(elapsed)") return response @@ -1989,6 +2013,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - end: End of the time range. /// - Returns: MMA response containing aggregated statistics. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. public func requestMMA(from publicKey: Data, start: Date, end: Date) async throws -> MMAResponse { try await binaryRequestSerializer.withSerialization { [self] in try await performMMARequest(from: publicKey, start: start, end: end) @@ -2030,6 +2055,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeoutContinuation.finish() case .error(let code): + timeoutContinuation.finish() throw MeshCoreError.deviceError(code: code ?? 0) case .binaryResponse(let tag, let responseData): @@ -2072,6 +2098,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Parameter publicKey: The full 32-byte public key of the remote node. /// - Returns: ACL response containing authorized public keys. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. public func requestACL(from publicKey: Data) async throws -> ACLResponse { try await binaryRequestSerializer.withSerialization { [self] in try await performACLRequest(from: publicKey) @@ -2106,6 +2133,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeoutContinuation.finish() case .error(let code): + timeoutContinuation.finish() throw MeshCoreError.deviceError(code: code ?? 0) case .binaryResponse(let tag, let responseData): @@ -2153,6 +2181,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - pubkeyPrefixLength: Length of public key prefix to include (default 4). /// - Returns: Neighbors response containing list of adjacent nodes. /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. public func requestNeighbours( from publicKey: Data, count: UInt8 = 255, @@ -2214,6 +2243,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { timeoutContinuation.finish() case .error(let code): + timeoutContinuation.finish() throw MeshCoreError.deviceError(code: code ?? 0) case .binaryResponse(let tag, let responseData): @@ -2425,10 +2455,21 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Sends a command and waits for an "OK" response from the device. private func sendSimpleCommand(_ data: Data) async throws { - let _: Bool = try await sendAndWaitWithError(data) { event in - if case .ok = event { return true } - return nil - } + let _: Bool = try await sendAndWaitWithError( + data, + matching: { event in + if case .ok(let value) = event, value == nil { + return true + } + return nil + }, + errorMatcher: { event in + if case .error(let code) = event { + return MeshCoreError.deviceError(code: code ?? 0) + } + return nil + } + ) } /// The background loop for receiving data from the transport. diff --git a/MeshCore/Sources/MeshCore/Session/RequestContext.swift b/MeshCore/Sources/MeshCore/Session/RequestContext.swift index e2a753c9d..60688a883 100644 --- a/MeshCore/Sources/MeshCore/Session/RequestContext.swift +++ b/MeshCore/Sources/MeshCore/Session/RequestContext.swift @@ -234,13 +234,48 @@ public actor BinaryRequestSerializer { _ operation: @Sendable () async throws -> T ) async throws -> T { await acquire() - do { - let result = try await operation() - release() - return result - } catch { - release() - throw error + defer { release() } + return try await operation() + } +} + +/// Serializes broad command-response operations that rely on event matching. +/// +/// Many MeshCore commands wait for generic events such as `.ok`, `.error`, or a +/// singleton typed response. Serializing those request/response exchanges avoids +/// cross-command event miscorrelation when multiple callers issue commands at once. +public actor RequestResponseSerializer { + private var isRequestInFlight = false + private var waiters: [CheckedContinuation] = [] + + /// Acquires the serializer, waiting if another request/response exchange is active. + public func acquire() async { + if !isRequestInFlight { + isRequestInFlight = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) } } + + /// Releases the serializer to the next waiting request. + public func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + isRequestInFlight = false + } + } + + /// Executes a request/response operation while holding the serializer. + public func withSerialization( + _ operation: @Sendable () async throws -> T + ) async throws -> T { + await acquire() + defer { release() } + return try await operation() + } } diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift new file mode 100644 index 000000000..61aebe77d --- /dev/null +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -0,0 +1,522 @@ +import Foundation +import Testing +@testable import MeshCore + +@Suite("MeshCoreSession command correlation") +struct MeshCoreSessionCommandCorrelationTests { + @Test("simple commands serialize concurrent OK/ERROR waits") + func simpleCommandsSerializeConcurrentOKWaits() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let first = Task { + try await session.factoryReset() + } + let second = Task { + try await session.sendAdvertisement(flood: true) + } + + try await waitUntil("first command should be sent") { + await transport.sentData.count == 2 + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await transport.sentData.count == 2) + + await transport.simulateOK() + + try await waitUntil("second command should wait for the first command to complete") { + await transport.sentData.count == 3 + } + + await transport.simulateOK() + + try await first.value + try await second.value + await session.stop() + } + + @Test("simple commands ignore OK responses with payloads") + func simpleCommandsIgnoreOKResponsesWithPayloads() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let resetTask = Task { + try await session.factoryReset() + } + + try await waitUntil("factoryReset should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateOK(value: 7) + + let error = await #expect(throws: MeshCoreError.self) { + try await resetTask.value + } + guard case .timeout? = error else { + Issue.record("Expected timeout after unrelated OK payload, got \(String(describing: error))") + await session.stop() + return + } + + await session.stop() + } + + @Test("simple commands still fail on device errors") + func simpleCommandsStillFailOnDeviceErrors() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let commandTask = Task { + try await session.setAutoAddConfig(AutoAddConfig(bitmask: 0x1E, maxHops: 2)) + } + + try await waitUntil("setAutoAddConfig should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateError(code: 42) + + let error = await #expect(throws: MeshCoreError.self) { + try await commandTask.value + } + guard case .deviceError(let code)? = error else { + Issue.record("Expected deviceError, got \(String(describing: error))") + await session.stop() + return + } + #expect(code == 42) + + await session.stop() + } + + @Test("session start ignores unrelated errors until selfInfo arrives") + func sessionStartIgnoresUnrelatedErrorsUntilSelfInfoArrives() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + let startTask = Task { + try await session.start() + } + + try await waitUntil("transport should send appStart before session starts") { + await transport.sentData.count == 1 + } + + await transport.simulateError(code: 99) + await transport.simulateReceive(makeSelfInfoPacket()) + + try await startTask.value + #expect(await session.currentSelfInfo?.name == "Test") + await session.stop() + } + + @Test("getBattery ignores unrelated errors while waiting for a battery response") + func getBatteryIgnoresUnrelatedErrorsWhileWaitingForBatteryResponse() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let batteryTask = Task { + try await session.getBattery() + } + + try await waitUntil("getBattery should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateError(code: 10) + await transport.simulateReceive(makeBatteryPacket(level: 4018)) + + let battery = try await batteryTask.value + #expect(battery.level == 4018) + await session.stop() + } + + @Test("getSelfTelemetry ignores telemetry for other nodes") + func getSelfTelemetryIgnoresTelemetryForOtherNodes() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let telemetryTask = Task { + try await session.getSelfTelemetry() + } + + try await waitUntil("getSelfTelemetry should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive( + makeTelemetryPacket( + publicKeyPrefix: Data([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + lppPayload: Data([0x01, 0x67, 0x00, 0xFA]) + ) + ) + await transport.simulateReceive( + makeTelemetryPacket( + publicKeyPrefix: Data(repeating: 0x01, count: 6), + lppPayload: Data([0x01, 0x67, 0x00, 0xF0]) + ) + ) + + let response = try await telemetryTask.value + #expect(response.publicKeyPrefix == Data(repeating: 0x01, count: 6)) + await session.stop() + } + + @Test("getChannel ignores responses for other channel indexes") + func getChannelIgnoresResponsesForOtherChannelIndexes() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let channelTask = Task { + try await session.getChannel(index: 3) + } + + try await waitUntil("getChannel should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive( + makeChannelInfoPacket(index: 9, name: "Wrong", secret: Data(repeating: 0xAA, count: 16)) + ) + await transport.simulateReceive( + makeChannelInfoPacket(index: 3, name: "Right", secret: Data(repeating: 0xBB, count: 16)) + ) + + let channel = try await channelTask.value + #expect(channel.index == 3) + #expect(channel.name == "Right") + await session.stop() + } + + @Test("getContact ignores responses for other public keys") + func getContactIgnoresResponsesForOtherPublicKeys() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let requestedKey = Data(repeating: 0x11, count: 32) + let contactTask = Task { + try await session.getContact(publicKey: requestedKey) + } + + try await waitUntil("getContact should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive( + makeContactPacket(publicKey: Data(repeating: 0x22, count: 32), name: "Wrong") + ) + await transport.simulateReceive( + makeContactPacket(publicKey: requestedKey, name: "Right") + ) + + let contact = try #require(await contactTask.value) + #expect(contact.publicKey == requestedKey) + #expect(contact.advertisedName == "Right") + await session.stop() + } + + @Test("importPrivateKey ignores OK responses with payloads") + func importPrivateKeyIgnoresOKResponsesWithPayloads() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let importTask = Task { + try await session.importPrivateKey(Data(repeating: 0x33, count: 64)) + } + + try await waitUntil("importPrivateKey should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateOK(value: 7) + + let error = await #expect(throws: MeshCoreError.self) { + try await importTask.value + } + guard case .timeout? = error else { + Issue.record("Expected timeout after unrelated OK payload, got \(String(describing: error))") + await session.stop() + return + } + + await session.stop() + } + + @Test("requestStatus fails fast on device error before messageSent") + func requestStatusFailsFastOnDeviceErrorBeforeMessageSent() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let target = Data(repeating: 0x31, count: 32) + let statusTask = Task { + try await session.requestStatus(from: target) + } + + try await waitUntil("requestStatus should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateError(code: 10) + + let error = await #expect(throws: MeshCoreError.self) { + try await statusTask.value + } + guard case .deviceError(let code)? = error else { + Issue.record("Expected deviceError for binary status request, got \(String(describing: error))") + await session.stop() + return + } + #expect(code == 10) + await session.stop() + } + + @Test("requestTelemetry fails fast on device error before messageSent") + func requestTelemetryFailsFastOnDeviceErrorBeforeMessageSent() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let target = Data(repeating: 0x31, count: 32) + let telemetryTask = Task { + try await session.requestTelemetry(from: target) + } + + try await waitUntil("requestTelemetry should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateError(code: 11) + + let error = await #expect(throws: MeshCoreError.self) { + try await telemetryTask.value + } + guard case .deviceError(let code)? = error else { + Issue.record("Expected deviceError for binary telemetry request, got \(String(describing: error))") + await session.stop() + return + } + #expect(code == 11) + await session.stop() + } + + @Test("binary request errors release the serializer for following requests") + func binaryRequestErrorsReleaseTheSerializer() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let firstTarget = Data(repeating: 0x31, count: 32) + let secondTarget = Data(repeating: 0x42, count: 32) + + let statusTask = Task { + try await session.requestStatus(from: firstTarget) + } + let telemetryTask = Task { + try await session.requestTelemetry(from: secondTarget) + } + + try await waitUntil("first binary request should be sent") { + await transport.sentData.count == 2 + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await transport.sentData.count == 2) + + await transport.simulateError(code: 12) + + let statusError = await #expect(throws: MeshCoreError.self) { + try await statusTask.value + } + guard case .deviceError(let firstCode)? = statusError else { + Issue.record("Expected first binary request to fail with deviceError, got \(String(describing: statusError))") + await session.stop() + return + } + #expect(firstCode == 12) + + try await waitUntil("second binary request should send after the first one fails") { + await transport.sentData.count == 3 + } + + await transport.simulateError(code: 13) + + let telemetryError = await #expect(throws: MeshCoreError.self) { + try await telemetryTask.value + } + guard case .deviceError(let secondCode)? = telemetryError else { + Issue.record("Expected second binary request to fail with deviceError, got \(String(describing: telemetryError))") + await session.stop() + return + } + #expect(secondCode == 13) + await session.stop() + } +} + +private func startSession( + _ session: MeshCoreSession, + transport: MockTransport +) async throws { + let startTask = Task { + try await session.start() + } + + try await waitUntil("transport should send appStart before session starts") { + await transport.sentData.count == 1 + } + + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value +} + +private func makeSelfInfoPacket() -> Data { + var payload = Data() + payload.append(1) + payload.append(UInt8(bitPattern: 22)) + payload.append(UInt8(bitPattern: 22)) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) + payload.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(0) + payload.append(contentsOf: withUnsafeBytes(of: UInt32(915_000).littleEndian) { Array($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt32(125_000).littleEndian) { Array($0) }) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet +} + +private func makeBatteryPacket(level: UInt16) -> Data { + var packet = Data([ResponseCode.battery.rawValue]) + packet.append(contentsOf: withUnsafeBytes(of: level.littleEndian) { Array($0) }) + return packet +} + +private func makeTelemetryPacket(publicKeyPrefix: Data, lppPayload: Data) -> Data { + var packet = Data([ResponseCode.telemetryResponse.rawValue]) + packet.append(0x00) + packet.append(publicKeyPrefix) + packet.append(lppPayload) + return packet +} + +private func makeStatusResponsePacket(publicKeyPrefix: Data, battery: UInt16) -> Data { + var packet = Data([ResponseCode.statusResponse.rawValue, 0x00]) + packet.append(publicKeyPrefix) + packet.append(contentsOf: withUnsafeBytes(of: battery.littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: Int16(-110).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: Int16(-85).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(100).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(50).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(25).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(3600).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(5).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(10).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(15).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(20).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: Int16(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt16(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Array($0) }) + return packet +} + +private func makeChannelInfoPacket(index: UInt8, name: String, secret: Data) -> Data { + var packet = Data([ResponseCode.channelInfo.rawValue, index]) + let nameBytes = Array(name.utf8.prefix(31)) + packet.append(contentsOf: nameBytes) + packet.append(0) + if nameBytes.count < 31 { + packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) + } + packet.append(secret) + return packet +} + +private func makeContactPacket(publicKey: Data, name: String) -> Data { + var packet = Data([ResponseCode.contact.rawValue]) + packet.append(publicKey) + packet.append(ContactType.chat.rawValue) + packet.append(ContactFlags().rawValue) + packet.append(0xFF) + packet.append(Data(repeating: 0, count: 64)) + + let nameBytes = Array(name.utf8.prefix(31)) + packet.append(contentsOf: nameBytes) + packet.append(0) + if nameBytes.count < 31 { + packet.append(Data(repeating: 0, count: 31 - nameBytes.count)) + } + + packet.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) + packet.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Array($0) }) + return packet +} From c972bc845e750148ff903cd7c059c437f449450f Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:38:45 -0700 Subject: [PATCH 03/48] feat(l10n): add Saved History localization strings --- MC1/Resources/Generated/L10n.swift | 18 ++++++++++++++++++ .../Localization/de.lproj/Contacts.strings | 3 +++ .../Localization/de.lproj/Map.strings | 3 +++ .../Localization/de.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/en.lproj/Contacts.strings | 3 +++ .../Localization/en.lproj/Map.strings | 3 +++ .../Localization/en.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/es.lproj/Contacts.strings | 3 +++ .../Localization/es.lproj/Map.strings | 3 +++ .../Localization/es.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/fr.lproj/Contacts.strings | 3 +++ .../Localization/fr.lproj/Map.strings | 3 +++ .../Localization/fr.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/nl.lproj/Contacts.strings | 3 +++ .../Localization/nl.lproj/Map.strings | 3 +++ .../Localization/nl.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/pl.lproj/Contacts.strings | 3 +++ .../Localization/pl.lproj/Map.strings | 3 +++ .../Localization/pl.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/ru.lproj/Contacts.strings | 3 +++ .../Localization/ru.lproj/Map.strings | 3 +++ .../Localization/ru.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../Localization/uk.lproj/Contacts.strings | 3 +++ .../Localization/uk.lproj/Map.strings | 3 +++ .../Localization/uk.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ .../zh-Hans.lproj/Contacts.strings | 3 +++ .../Localization/zh-Hans.lproj/Map.strings | 3 +++ .../zh-Hans.lproj/RemoteNodes.strings | 18 ++++++++++++++++++ 28 files changed, 234 insertions(+) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f11c96b64..24e2610b7 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1067,6 +1067,8 @@ public enum L10n { public static func routePrefix(_ p1: Any) -> String { return L10n.tr("Contacts", "contacts.detail.routePrefix", String(describing: p1), fallback: "Route: %@") } + /// Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry + public static let savedHistory = L10n.tr("Contacts", "contacts.detail.savedHistory", fallback: "Saved History") /// Location: ContactDetailView.swift - Purpose: Discovery countdown public static func secondsRemaining(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.detail.secondsRemaining", p1, fallback: "Up to %d seconds remaining") @@ -2016,6 +2018,8 @@ public enum L10n { public static let adminAccess = L10n.tr("Map", "map.detail.action.adminAccess", fallback: "Admin Access") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room public static let joinRoom = L10n.tr("Map", "map.detail.action.joinRoom", fallback: "Join Room") + /// Location: MapView.swift - Purpose: Saved History button for offline telemetry + public static let savedHistory = L10n.tr("Map", "map.detail.action.savedHistory", fallback: "Saved History") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to send a message public static let sendMessage = L10n.tr("Map", "map.detail.action.sendMessage", fallback: "Send Message") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry @@ -2319,20 +2323,34 @@ public enum L10n { public static let neighborCount = L10n.tr("RemoteNodes", "remoteNodes.history.neighborCount", fallback: "Neighbor Count") /// Location: NeighborHistoryView.swift - Neighbors section title public static let neighbors = L10n.tr("RemoteNodes", "remoteNodes.history.neighbors", fallback: "Neighbors") + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header + public static let neighborsSection = L10n.tr("RemoteNodes", "remoteNodes.history.neighborsSection", fallback: "Neighbors") /// Location: NeighborRow - New neighbor badge public static let new = L10n.tr("RemoteNodes", "remoteNodes.history.new", fallback: "New") /// Location: NodeStatusHistoryView.swift - Noise floor chart title public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.history.noiseFloor", fallback: "Noise Floor") + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist + public static let noSnapshotsMessage = L10n.tr("RemoteNodes", "remoteNodes.history.noSnapshotsMessage", fallback: "Connect to this repeater at least once to see history.") /// Location: NeighborRow - Not seen status public static let notSeen = L10n.tr("RemoteNodes", "remoteNodes.history.notSeen", fallback: "Not seen") + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title + public static let overviewTitle = L10n.tr("RemoteNodes", "remoteNodes.history.overviewTitle", fallback: "Saved History") /// Location: NodeStatusHistoryView.swift - Packets received chart title public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.history.packetsReceived", fallback: "Packets Received") /// Location: NodeStatusHistoryView.swift - Packets sent chart title public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.history.packetsSent", fallback: "Packets Sent") + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header + public static let radioSection = L10n.tr("RemoteNodes", "remoteNodes.history.radioSection", fallback: "Radio") /// Location: NodeStatusHistoryView.swift - Footer about data retention public static let retentionNotice = L10n.tr("RemoteNodes", "remoteNodes.history.retentionNotice", fallback: "History data older than one year is automatically removed.") /// Location: NodeStatusHistoryView.swift - RSSI chart title public static let rssi = L10n.tr("RemoteNodes", "remoteNodes.history.rssi", fallback: "RSSI") + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured + public static func sectionNotCaptured(_ p1: Any) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.history.sectionNotCaptured", String(describing: p1), fallback: "This data is captured when you view the %@ section during a live telemetry session.") + } + /// Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header + public static let sensorsSection = L10n.tr("RemoteNodes", "remoteNodes.history.sensorsSection", fallback: "Sensors") /// Location: NodeStatusHistoryView.swift - SNR chart title public static let snr = L10n.tr("RemoteNodes", "remoteNodes.history.snr", fallback: "SNR") /// Location: NodeStatusHistoryView.swift - Time range picker diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 0d68767e3..13ae26a3e 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetrie"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetriezugriff"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 5e7866079..ee865aa92 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Telemetrie"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetriezugriff"; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 3fd5ff080..1b9e83539 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Verlaufsdaten, die älter als ein Jahr sind, werden automatisch entfernt."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index fee159706..4a83383ba 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetry"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetry Access"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index e43db984a..20b83e7fd 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Telemetry"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetry Access"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index e0407fe3d..8ee1f8a61 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "History data older than one year is automatically removed."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 7c6036416..ca46800c0 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetría"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Acceso a telemetría"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 14ebc09f5..51bf03198 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Telemetría"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Acceso a telemetría"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 47923cc47..734bbe4fd 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Los datos del historial con más de un año se eliminan automáticamente."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index c97bd6fa5..a7dd3174b 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Télémétrie"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Accès à la télémétrie"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 636f96fd4..7513d0546 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Télémétrie"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Accès à la télémétrie"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 739c0f0ee..7c5b6d29b 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Les données d'historique de plus d'un an sont automatiquement supprimées."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 85c4c89cd..65cfada0c 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetrie"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetrietoegang"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index 5f91efc8c..26a6e5026 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Telemetrie"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetrietoegang"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 0a6858431..560bbd18e 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Geschiedenisgegevens ouder dan een jaar worden automatisch verwijderd."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 10dd1ec11..f35662d66 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetria"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Dostęp do telemetrii"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index f228d19f2..5125d121d 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Telemetria"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Dostęp do telemetrii"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index d725c0651..90d1e3fbd 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -499,6 +499,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Dane historyczne starsze niż rok są automatycznie usuwane."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index a8e11fe32..4fb6661d3 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Телеметрия"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ к телеметрии"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index f600210f0..afe6dace3 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Телеметрия"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Доступ к телеметрии"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 5a9084ea7..35d729ec6 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -499,6 +499,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Данные истории старше одного года автоматически удаляются."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 6e9c03d72..a3dae6185 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Телеметрія"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ до телеметрії"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 0cefe9f55..3f8e01e01 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "Телеметрія"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Доступ до телеметрії"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 87b3428e9..f4d8f3468 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -499,6 +499,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "Дані історії старіші за один рік автоматично видаляються."; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index bdacd5170..bc2d8caa8 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -206,6 +206,9 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "遥测数据"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Saved History"; + /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "遥测数据访问权限"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index d075bbf8b..183b867a8 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -102,6 +102,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ "map.detail.action.telemetry" = "遥测数据"; +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +"map.detail.action.savedHistory" = "Saved History"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "遥测数据访问权限"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index b9a5e52b5..23b33ed5b 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -502,6 +502,24 @@ /* Location: NodeStatusHistoryView.swift - Footer about data retention */ "remoteNodes.history.retentionNotice" = "超过一年的历史数据将被自动删除。"; +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Radio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Neighbors"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Saved History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ From 3898bfa560635f62713c0ed714442a2ec341c43d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:52:40 -0700 Subject: [PATCH 04/48] feat(telemetry): add TelemetryHistoryOverviewViewModel with tests Extract ChannelGroup and TelemetryChartGroup from TelemetryHistoryView into shared types so both the existing view and the new overview ViewModel can use them. --- .../TelemetryHistoryOverviewViewModel.swift | 103 ++++++++++ .../RemoteNodes/TelemetryHistoryView.swift | 16 -- ...lemetryHistoryOverviewViewModelTests.swift | 186 ++++++++++++++++++ 3 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift create mode 100644 MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift new file mode 100644 index 000000000..5b6fdb547 --- /dev/null +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift @@ -0,0 +1,103 @@ +import MC1Services +import MeshCore +import SwiftUI + +@Observable +@MainActor +final class TelemetryHistoryOverviewViewModel { + + // MARK: - State + + private(set) var snapshots: [NodeStatusSnapshotDTO] = [] + private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray + var timeRange: HistoryTimeRange = .all + + // MARK: - Computed + + var filteredSnapshots: [NodeStatusSnapshotDTO] { + guard let start = timeRange.startDate else { return snapshots } + return snapshots.filter { $0.timestamp >= start } + } + + var hasSnapshots: Bool { !snapshots.isEmpty } + + var hasNeighborData: Bool { + filteredSnapshots.contains { $0.neighborSnapshots?.isEmpty == false } + } + + var hasTelemetryData: Bool { + filteredSnapshots.contains { $0.telemetryEntries?.isEmpty == false } + } + + var channelGroups: [ChannelGroup] { + let allEntries = filteredSnapshots.flatMap { snapshot in + (snapshot.telemetryEntries ?? []).map { (snapshot: snapshot, entry: $0) } + } + + guard !allEntries.isEmpty else { return [] } + + var channelTypeGroups: [Int: [String: TelemetryChartGroup]] = [:] + + for item in allEntries { + let channel = item.entry.channel + let type = item.entry.type + let point = MetricChartView.DataPoint( + id: item.snapshot.id, + date: item.snapshot.timestamp, + value: item.entry.value + ) + + channelTypeGroups[channel, default: [:]][type, default: TelemetryChartGroup( + key: "\(channel)-\(type)", title: type, sensorType: LPPSensorType(name: type), dataPoints: [] + )].dataPoints.append(point) + } + + return channelTypeGroups.keys.sorted().map { channel in + let charts = channelTypeGroups[channel]!.values.sorted { lhs, rhs in + let lhsPriority = lhs.sensorType?.chartSortPriority ?? 1 + let rhsPriority = rhs.sensorType?.chartSortPriority ?? 1 + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + return ChannelGroup(channel: channel, charts: charts) + } + } + + // MARK: - Loading + + func loadData(dataStore: PersistenceStore, publicKey: Data, deviceID: UUID) async { + do { + snapshots = try await dataStore.fetchNodeStatusSnapshots( + nodePublicKey: publicKey, since: nil + ) + } catch { + snapshots = [] + } + + do { + if let contact = try await dataStore.fetchContact( + deviceID: deviceID, publicKey: publicKey + ) { + ocvArray = contact.activeOCVArray + } + } catch { + // Keep default liIon + } + } +} + +// MARK: - Supporting Types + +struct ChannelGroup: Identifiable { + let channel: Int + let charts: [TelemetryChartGroup] + var id: Int { channel } +} + +struct TelemetryChartGroup: Identifiable { + let key: String + let title: String + let sensorType: LPPSensorType? + var dataPoints: [MetricChartView.DataPoint] + var id: String { key } +} diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift index ccbe0a9c9..07bb232b7 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryView.swift @@ -89,19 +89,3 @@ struct TelemetryHistoryView: View { } } } - -// MARK: - Supporting Types - -private struct ChannelGroup: Identifiable { - let channel: Int - let charts: [TelemetryChartGroup] - var id: Int { channel } -} - -private struct TelemetryChartGroup { - let key: String - let title: String - let sensorType: LPPSensorType? - var dataPoints: [MetricChartView.DataPoint] -} - diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift new file mode 100644 index 000000000..e49135736 --- /dev/null +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -0,0 +1,186 @@ +import Foundation +import MeshCore +import Testing + +@testable import MC1 +@testable import MC1Services + +@Suite("TelemetryHistoryOverviewViewModel Tests") +@MainActor +struct TelemetryHistoryOverviewViewModelTests { + + private let testPublicKey = Data(repeating: 0xAB, count: 32) + private let testDeviceID = UUID() + + private func createStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + private func createContactDTO(ocvPreset: String? = nil) -> ContactDTO { + ContactDTO( + id: UUID(), + deviceID: testDeviceID, + publicKey: testPublicKey, + name: "Test Repeater", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + ocvPreset: ocvPreset + ) + } + + // MARK: - Loading + + @Test("loadData fetches snapshots from persistence store") + func loadDataFetchesSnapshots() async throws { + let store = try await createStore() + + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, + batteryMillivolts: 3800, lastSNR: 8.0, lastRSSI: -90, + noiseFloor: -120, uptimeSeconds: 3600, rxAirtimeSeconds: 100, + packetsSent: 500, packetsReceived: 1000 + ) + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, + batteryMillivolts: 3750, lastSNR: 7.5, lastRSSI: -92, + noiseFloor: -118, uptimeSeconds: 7200, rxAirtimeSeconds: 200, + packetsSent: 600, packetsReceived: 1100 + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + + #expect(viewModel.snapshots.count == 2) + } + + @Test("loadData with no snapshots leaves empty array") + func loadDataNoSnapshots() async throws { + let store = try await createStore() + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + + #expect(viewModel.snapshots.isEmpty) + } + + // MARK: - OCV Resolution + + @Test("loadData resolves OCV from contact preset") + func loadDataResolvesOCVFromContact() async throws { + let store = try await createStore() + + let contact = createContactDTO(ocvPreset: OCVPreset.liFePO4.rawValue) + try await store.saveContact(contact) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + + #expect(viewModel.ocvArray == OCVPreset.liFePO4.ocvArray) + } + + @Test("loadData defaults to liIon when no contact found") + func loadDataDefaultsToLiIon() async throws { + let store = try await createStore() + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + + #expect(viewModel.ocvArray == OCVPreset.liIon.ocvArray) + } + + // MARK: - Filtering + + @Test("filteredSnapshots returns all when timeRange is .all") + func filteredSnapshotsAll() async throws { + let store = try await createStore() + + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + viewModel.timeRange = .all + + #expect(viewModel.filteredSnapshots.count == 1) + } + + @Test("filteredSnapshots excludes old snapshots for .week range") + func filteredSnapshotsWeek() async throws { + let store = try await createStore() + + // Save an old snapshot (30 days ago) + let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: .now)! + _ = try await store.saveNodeStatusSnapshot( + timestamp: thirtyDaysAgo, + nodePublicKey: testPublicKey, batteryMillivolts: 3600, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + // Save a recent snapshot + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + viewModel.timeRange = .week + + #expect(viewModel.filteredSnapshots.count == 1) + #expect(viewModel.filteredSnapshots.first?.batteryMillivolts == 3800) + } + + // MARK: - Computed Properties + + @Test("hasSnapshots reflects snapshot count") + func hasSnapshots() async throws { + let viewModel = TelemetryHistoryOverviewViewModel() + #expect(!viewModel.hasSnapshots) + + let store = try await createStore() + _ = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #expect(viewModel.hasSnapshots) + } +} From 2fdd68a1a306c5e3a49e7931c93ee097ec9336c8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:03:19 -0700 Subject: [PATCH 05/48] test(telemetry): add channelGroups and computed property tests --- ...lemetryHistoryOverviewViewModelTests.swift | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift index e49135736..ca79b0d62 100644 --- a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -183,4 +183,112 @@ struct TelemetryHistoryOverviewViewModelTests { ) #expect(viewModel.hasSnapshots) } + + @Test("hasTelemetryData returns true when telemetry entries exist") + func hasTelemetryData() async throws { + let store = try await createStore() + + // Snapshot without telemetry + let idNoTelemetry = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #expect(!viewModel.hasTelemetryData, "Should be false with no telemetry entries") + + // Add telemetry to the snapshot + try await store.updateSnapshotTelemetry( + id: idNoTelemetry, + telemetry: [TelemetrySnapshotEntry(channel: 0, type: "Voltage", value: 3.8)] + ) + + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #expect(viewModel.hasTelemetryData, "Should be true after adding telemetry entries") + } + + @Test("hasNeighborData returns true when neighbor snapshots exist") + func hasNeighborData() async throws { + let store = try await createStore() + + // Snapshot without neighbors + let id = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #expect(!viewModel.hasNeighborData, "Should be false with no neighbor snapshots") + + // Add neighbors to the snapshot + try await store.updateSnapshotNeighbors( + id: id, + neighbors: [NeighborSnapshotEntry(publicKeyPrefix: Data([0x01, 0x02]), snr: 6.5, secondsAgo: 30)] + ) + + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #expect(viewModel.hasNeighborData, "Should be true after adding neighbor snapshots") + } + + // MARK: - Channel Groups + + @Test("channelGroups groups by channel and sorts by chartSortPriority then alphabetically") + func channelGroupsGrouping() async throws { + let store = try await createStore() + + let snapshotID = try await store.saveNodeStatusSnapshot( + nodePublicKey: testPublicKey, batteryMillivolts: 3800, + lastSNR: nil, lastRSSI: nil, noiseFloor: nil, + uptimeSeconds: nil, rxAirtimeSeconds: nil, + packetsSent: nil, packetsReceived: nil + ) + + // Channel 0: Voltage (priority 0) and Temperature (priority 1) + // Channel 2: Humidity (priority 1) and Voltage (priority 0) + try await store.updateSnapshotTelemetry( + id: snapshotID, + telemetry: [ + TelemetrySnapshotEntry(channel: 0, type: "Voltage", value: 3.8), + TelemetrySnapshotEntry(channel: 0, type: "Temperature", value: 22.5), + TelemetrySnapshotEntry(channel: 2, type: "Humidity", value: 55.0), + TelemetrySnapshotEntry(channel: 2, type: "Voltage", value: 4.1), + ] + ) + + let viewModel = TelemetryHistoryOverviewViewModel() + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + + let groups = viewModel.channelGroups + + // Two channel groups, sorted by channel number + #expect(groups.count == 2, "Should have 2 channel groups") + #expect(groups[0].channel == 0, "First group should be channel 0") + #expect(groups[1].channel == 2, "Second group should be channel 2") + + // Channel 0: Voltage (priority 0) before Temperature (priority 1) + #expect(groups[0].charts.count == 2, "Channel 0 should have 2 charts") + #expect(groups[0].charts[0].title == "Voltage", "Voltage should sort first (priority 0)") + #expect(groups[0].charts[1].title == "Temperature", "Temperature should sort second (priority 1)") + + // Channel 2: Voltage (priority 0) before Humidity (priority 1) + #expect(groups[1].charts.count == 2, "Channel 2 should have 2 charts") + #expect(groups[1].charts[0].title == "Voltage", "Voltage should sort first (priority 0)") + #expect(groups[1].charts[1].title == "Humidity", "Humidity should sort second (priority 1)") + } } From 81509b66a8356bdd28d94bdb9ed8da699fa685fb Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:07:08 -0700 Subject: [PATCH 06/48] feat(telemetry): add TelemetryHistoryOverviewView with chart sections --- .../TelemetryHistoryOverviewView.swift | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift new file mode 100644 index 000000000..110355fab --- /dev/null +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -0,0 +1,253 @@ +import MC1Services +import SwiftUI + +/// Offline-accessible overview of all historical telemetry charts for a repeater. +struct TelemetryHistoryOverviewView: View { + let publicKey: Data + let deviceID: UUID + + @Environment(\.appState) private var appState + @State private var viewModel = TelemetryHistoryOverviewViewModel() + @State private var radioExpanded = true + @State private var sensorsExpanded = false + @State private var neighborsExpanded = false + + var body: some View { + List { + if !viewModel.hasSnapshots { + emptyState + } else { + HistoryTimeRangePicker(selection: $viewModel.timeRange) + radioSection + sensorsSection + neighborsSection + retentionFooter + } + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.History.overviewTitle) + .liquidGlassToolbarBackground() + .task { + guard let store = appState.offlineDataStore else { return } + await viewModel.loadData( + dataStore: store, publicKey: publicKey, deviceID: deviceID + ) + } + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + ContentUnavailableView( + L10n.RemoteNodes.RemoteNodes.History.overviewTitle, + systemImage: "chart.line.uptrend.xyaxis", + description: Text(L10n.RemoteNodes.RemoteNodes.History.noSnapshotsMessage) + ) + } + + // MARK: - Radio Section + + @ViewBuilder + private var radioSection: some View { + let filtered = viewModel.filteredSnapshots + let hasRadioData = filtered.contains { + $0.batteryMillivolts != nil || $0.lastSNR != nil || + $0.lastRSSI != nil || $0.noiseFloor != nil || + $0.packetsSent != nil || $0.packetsReceived != nil + } + + if hasRadioData { + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.radioSection, + isExpanded: $radioExpanded + ) { + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.battery, + unit: "V", color: .mint, + dataPoints: filtered.compactMap { s in + s.batteryMillivolts.map { + .init(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) + } + }, + yAxisDomain: viewModel.ocvArray.voltageChartDomain() + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.snr, + unit: "dB", color: .blue, + dataPoints: filtered.compactMap { s in + s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.rssi, + unit: "dBm", color: .purple, + dataPoints: filtered.compactMap { s in + s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, + unit: "dBm", color: .indigo, + dataPoints: filtered.compactMap { s in + s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, + unit: "", color: .green, + dataPoints: filtered.compactMap { s in + s.packetsSent.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, + unit: "", color: .orange, + dataPoints: filtered.compactMap { s in + s.packetsReceived.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + } + } + } + } + + // MARK: - Sensors Section + + @ViewBuilder + private var sensorsSection: some View { + if viewModel.hasTelemetryData { + let groups = viewModel.channelGroups + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection, + isExpanded: $sensorsExpanded + ) { + if groups.count > 1 { + ForEach(groups) { group in + Section(L10n.RemoteNodes.RemoteNodes.Status.channel(group.channel)) { + ForEach(group.charts) { chart in + chartView(for: chart) + } + } + } + } else if let group = groups.first { + ForEach(group.charts) { chart in + chartView(for: chart) + } + } + } + } + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.sensorsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Neighbors Section + + @ViewBuilder + private var neighborsSection: some View { + if viewModel.hasNeighborData { + let neighborCharts = buildNeighborCharts() + Section { + DisclosureGroup( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection, + isExpanded: $neighborsExpanded + ) { + ForEach(neighborCharts, id: \.prefix) { neighbor in + MetricChartView( + title: neighbor.name, + unit: "dB", + dataPoints: neighbor.dataPoints, + accentColor: .blue + ) + } + } + } + } else if viewModel.hasSnapshots { + Section { + Text(L10n.RemoteNodes.RemoteNodes.History.sectionNotCaptured( + L10n.RemoteNodes.RemoteNodes.History.neighborsSection + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Helpers + + @ViewBuilder + private func metricChart( + title: String, unit: String, color: Color, + dataPoints: [MetricChartView.DataPoint], + yAxisDomain: ClosedRange? = nil + ) -> some View { + if !dataPoints.isEmpty { + MetricChartView( + title: title, unit: unit, + dataPoints: dataPoints, accentColor: color, + yAxisDomain: yAxisDomain + ) + } + } + + private func chartView(for chart: TelemetryChartGroup) -> MetricChartView { + MetricChartView( + title: chart.title, + unit: chart.sensorType?.unit ?? "", + dataPoints: chart.dataPoints, + accentColor: chart.sensorType?.chartColor ?? .cyan, + yAxisDomain: chart.sensorType == .voltage ? viewModel.ocvArray.voltageChartDomain() : nil + ) + } + + private func buildNeighborCharts() -> [NeighborChart] { + var charts: [Data: NeighborChart] = [:] + for snapshot in viewModel.filteredSnapshots { + for neighbor in snapshot.neighborSnapshots ?? [] { + let point = MetricChartView.DataPoint( + id: snapshot.id, date: snapshot.timestamp, value: neighbor.snr + ) + if charts[neighbor.publicKeyPrefix] != nil { + charts[neighbor.publicKeyPrefix]!.dataPoints.append(point) + } else { + let hexName = neighbor.publicKeyPrefix + .map { String(format: "%02X", $0) }.joined() + charts[neighbor.publicKeyPrefix] = NeighborChart( + prefix: neighbor.publicKeyPrefix, + name: hexName, + dataPoints: [point] + ) + } + } + } + return charts.values.sorted { $0.name < $1.name } + } + + private var retentionFooter: some View { + Section { + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) + } + } +} + +// MARK: - Private Types + +private struct NeighborChart { + let prefix: Data + let name: String + var dataPoints: [MetricChartView.DataPoint] +} From adb2a84cbe74648c9a2ae7b2f2e48425b7602cef Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:13:35 -0700 Subject: [PATCH 07/48] feat(contacts): add Saved History button to ContactDetailView --- MC1/Views/Contacts/ContactDetailView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 1b74f6955..5dfa9235f 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -547,6 +547,16 @@ private struct ContactActionsSection: View { } .radioDisabled(for: appState.connectionState) + // Saved History - offline telemetry charts + NavigationLink { + TelemetryHistoryOverviewView( + publicKey: currentContact.publicKey, + deviceID: currentContact.deviceID + ) + } label: { + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + // Admin Access - navigates to settings view after auth Button(action: onShowAdminAccess) { Label(L10n.Contacts.Contacts.Detail.adminAccess, systemImage: "gearshape.2") From 08130165468e26b2102ac7b8fab1c59142205eaf Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:20:37 -0700 Subject: [PATCH 08/48] feat(regions): add anonymous region request support - Add FloodScope.region case deriving scope key as SHA256("#"+name).prefix(16) - Add CommandCode.sendAnonReq (0x39) and AnonRequestType enum (regions/owner/basic) - Add PacketBuilder.sendAnonReq with reversed out-path wire format - Add RegionsParser to decode comma-separated UTF-8 region names from binary response - Add MeshCoreSession.requestRegions(from:) with flood-route workaround --- .../Sources/MeshCore/Models/Destination.swift | 11 + .../MeshCore/Protocol/PacketBuilder.swift | 29 ++ .../MeshCore/Protocol/PacketCodes.swift | 12 + .../Sources/MeshCore/Protocol/Parsers.swift | 28 ++ .../MeshCore/Session/MeshCoreSession.swift | 140 ++++++ .../MeshCoreTests/Protocol/RegionTests.swift | 419 ++++++++++++++++++ 6 files changed, 639 insertions(+) create mode 100644 MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift diff --git a/MeshCore/Sources/MeshCore/Models/Destination.swift b/MeshCore/Sources/MeshCore/Models/Destination.swift index 73e7e965a..77a494032 100644 --- a/MeshCore/Sources/MeshCore/Models/Destination.swift +++ b/MeshCore/Sources/MeshCore/Models/Destination.swift @@ -68,6 +68,12 @@ public enum FloodScope: Sendable { case channelName(String) /// Scope based on a raw 16-byte key. case rawKey(Data) + /// Scope based on a public region name. The key is derived as `SHA256("#" + name).prefix(16)`, + /// matching the firmware convention for public hashtag regions. + /// + /// Region names from ``MeshCoreSession/requestRegions(from:)`` can be passed directly + /// (e.g., `"Europe"`). The `#` prefix is added automatically if not present. + case region(String) /// Generates a 16-byte scope key from the current scope. /// @@ -87,6 +93,11 @@ public enum FloodScope: Sendable { padded.append(0) } return Data(padded) + + case .region(let name): + let prefixed = name.hasPrefix("#") ? name : "#\(name)" + let hash = SHA256.hash(data: Data(prefixed.utf8)) + return Data(hash.prefix(16)) } } } diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift index 29485a2bf..e28ba592a 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift @@ -780,6 +780,35 @@ public enum PacketBuilder: Sendable { return data } + /// Builds a command to send an anonymous request to a remote node. + /// + /// ### Binary Format + /// - Offset 0 (1 byte): Command code `0x39` + /// - Offset 1–32 (32 bytes): Destination public key + /// - Offset 33 (1 byte): Anonymous request type + /// - Offset 34 (1 byte): Encoded path length (bits 7-6 = hash_size-1, bits 5-0 = hop count) + /// - Offset 35+ (variable): Out-path bytes, reversed to form the return route + /// + /// - Parameters: + /// - publicKey: The 32-byte public key of the destination node. + /// - type: The anonymous request type. + /// - pathLength: The encoded path length byte. + /// - path: The raw out-path bytes for the destination. Reversed to form the return route. + /// - Returns: The command data to send to the companion radio. + public static func sendAnonReq( + to publicKey: Data, + type: AnonRequestType, + pathLength: UInt8, + path: Data + ) -> Data { + var data = Data([CommandCode.sendAnonReq.rawValue]) + data.append(publicKey.prefix(publicKeySize)) + data.append(type.rawValue) + data.append(pathLength) + data.append(Data(path.reversed())) + return data + } + /// Builds a setPathHashMode command to configure the path hash size. /// /// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes). diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift b/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift index ad995b07d..1c17a77e0 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift @@ -98,6 +98,8 @@ public enum CommandCode: UInt8, Sendable { case sendControlData = 0x37 /// Requests device statistics. case getStats = 0x38 + /// Sends an anonymous request to a remote node. + case sendAnonReq = 0x39 /// Sets the auto-add configuration bitmask. case setAutoAddConfig = 0x3A /// Gets the current auto-add configuration bitmask. @@ -218,6 +220,16 @@ public enum BinaryRequestType: UInt8, Sendable { case neighbours = 0x06 } +/// Defines the types of anonymous requests that can be sent to remote nodes. +public enum AnonRequestType: UInt8, Sendable { + /// Requests the list of allowed regions from a repeater. + case regions = 0x01 + /// Requests owner information from a repeater. + case owner = 0x02 + /// Requests basic information (clock, features) from a repeater. + case basic = 0x03 +} + /// Defines the types of control data packets. public enum ControlType: UInt8, Sendable { /// Requests node discovery. diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 0c7b54ece..244533863 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -1607,3 +1607,31 @@ enum NeighboursParser { ) } } + +// MARK: - Regions Parser + +enum RegionsParser { + /// Parses a region query response from binary response data. + /// + /// The response data layout (after the binary response parser strips the frame header): + /// - Offset 0–3 (4 bytes): Repeater timestamp (UInt32 LE) — skipped + /// - Offset 4+ (variable): Comma-separated UTF-8 region names + /// + /// The sender timestamp (4 bytes) is already consumed as the tag by the binary response parser. + /// + /// - Parameter responseData: The `data` field from `.binaryResponse(tag:data:)`. + /// - Returns: An array of region name strings. Empty array if no regions are configured. + /// - Throws: ``MeshCoreError/parseError(_:)`` if the response is too short or not valid UTF-8. + static func parse(_ responseData: Data) throws -> [String] { + guard responseData.count >= 4 else { + throw MeshCoreError.parseError("Region response too short (\(responseData.count) bytes)") + } + let regionData = responseData.dropFirst(4) + guard let regionString = String(data: regionData, encoding: .utf8) else { + throw MeshCoreError.parseError("Invalid UTF-8 in region response") + } + let trimmed = regionString.trimmingCharacters(in: .controlCharacters) + if trimmed.isEmpty { return [] } + return trimmed.split(separator: ",").map(String.init) + } +} diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 929498bdb..73a2ad5b3 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -2283,6 +2283,146 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { } } + // MARK: - Region Requests + + /// Queries a repeater for its list of allowed regions. + /// + /// Sends an anonymous region request to the specified contact and waits for the + /// repeater to respond with its configured region list. + /// + /// - Parameter contact: The repeater contact to query. Must have a full 32-byte public key. + /// - Returns: An array of region name strings (e.g., `["Europe", "UK"]`). + /// Names prefixed with `$` are private regions requiring pre-shared keys. + /// - Throws: ``MeshCoreError/timeout`` if no response is received, + /// ``MeshCoreError/deviceError(code:)`` if the firmware rejects the request, + /// ``MeshCoreError/parseError(_:)`` if the response is malformed. + public func requestRegions(from contact: MeshContact) async throws -> [String] { + try await binaryRequestSerializer.withSerialization { [self] in + try await performRegionsRequest(from: contact) + } + } + + /// Internal implementation of regions request, called within serialization. + private func performRegionsRequest(from contact: MeshContact) async throws -> [String] { + let isFloodRouted = contact.outPathLength == 0xFF + let pathLength: UInt8 + let path: Data + if isFloodRouted { + pathLength = 0 + path = Data() + } else { + pathLength = contact.outPathLength + path = contact.outPath + } + + let prefixHex = contact.publicKey.prefix(6).map { String(format: "%02x", $0) }.joined() + let startTime = ContinuousClock.now + + // Firmware requires isRouteDirect() for region requests. For flood-routed + // contacts, temporarily set the contact to zero-hop direct on the firmware, + // matching the Python reference (base.py:269-273). + if isFloodRouted { + try await updateContact( + publicKey: contact.publicKey, + type: contact.type, + flags: contact.flags, + outPathLength: 0, + outPath: Data(), + advertisedName: contact.advertisedName, + lastAdvertisement: contact.lastAdvertisement, + latitude: contact.latitude, + longitude: contact.longitude + ) + } + + let data = PacketBuilder.sendAnonReq( + to: contact.publicKey, + type: .regions, + pathLength: pathLength, + path: path + ) + + logger.info("Regions request to \(prefixHex): sending") + + // Subscribe BEFORE sending to avoid race condition + let events = await dispatcher.subscribe() + try await transport.send(data) + + let result: [String] + do { + result = try await withThrowingTaskGroup(of: [String]?.self) { group in + let (timeoutStream, timeoutContinuation) = AsyncStream.makeStream() + + group.addTask { [logger] in + var expectedAck: Data? + + for await event in events { + if Task.isCancelled { return nil } + + switch event { + case .messageSent(let info): + expectedAck = info.expectedAck + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 + logger.info("Regions request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s") + timeoutContinuation.yield(timeout) + timeoutContinuation.finish() + + case .error(let code): + throw MeshCoreError.deviceError(code: code ?? 0) + + case .binaryResponse(let tag, let responseData): + guard let expected = expectedAck, tag == expected else { continue } + let result = try RegionsParser.parse(responseData) + let elapsed = ContinuousClock.now - startTime + logger.info("Regions request to \(prefixHex): response received in \(elapsed)") + return result + + default: + continue + } + } + timeoutContinuation.finish() + return nil + } + + group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in + var timeout = defaultTimeout + var usedFirmwareTimeout = false + for await t in timeoutStream { + timeout = t + usedFirmwareTimeout = true + break + } + logger.info("Regions request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))") + try await clock.sleep(for: .seconds(timeout)) + let elapsed = ContinuousClock.now - startTime + logger.warning("Regions request to \(prefixHex): timed out after \(elapsed)") + return nil + } + + if let result = try await group.next() ?? nil { + group.cancelAll() + return result + } + group.cancelAll() + throw MeshCoreError.timeout + } + } catch { + // Restore flood routing before propagating the error + if isFloodRouted { + try? await resetPath(publicKey: contact.publicKey) + } + throw error + } + + // Restore flood routing after successful request + if isFloodRouted { + try? await resetPath(publicKey: contact.publicKey) + } + + return result + } + /// Fetches all neighbors from a remote node with automatic pagination. /// /// This is a convenience method that automatically handles pagination to retrieve diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift new file mode 100644 index 000000000..b40e4dcd3 --- /dev/null +++ b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift @@ -0,0 +1,419 @@ +import CryptoKit +import Foundation +import Testing +@testable import MeshCore + +// MARK: - FloodScope.region + +@Suite("FloodScope.region key derivation") +struct FloodScopeRegionTests { + + @Test("region key matches SHA256 of #-prefixed name") + func regionKeyMatchesSHA256() { + let key = FloodScope.region("Europe").scopeKey() + + let expected = Data(SHA256.hash(data: Data("#Europe".utf8)).prefix(16)) + #expect(key == expected) + } + + @Test("region handles explicit # prefix idempotently") + func regionExplicitHashPrefix() { + let withoutHash = FloodScope.region("Europe").scopeKey() + let withHash = FloodScope.region("#Europe").scopeKey() + + #expect(withoutHash == withHash) + } + + @Test("region differs from channelName for the same string") + func regionDiffersFromChannelName() { + let regionKey = FloodScope.region("Europe").scopeKey() + let channelKey = FloodScope.channelName("Europe").scopeKey() + + #expect(regionKey != channelKey) + } + + @Test("disabled still produces 16 zero bytes") + func disabledRegression() { + let key = FloodScope.disabled.scopeKey() + + #expect(key == Data(repeating: 0, count: 16)) + #expect(key.count == 16) + } +} + +// MARK: - PacketBuilder.sendAnonReq + +@Suite("PacketBuilder.sendAnonReq wire format") +struct SendAnonReqTests { + + @Test("regions request with path") + func regionsRequestWithPath() { + let pubkey = Data(repeating: 0xAA, count: 32) + let path = Data([0x11, 0x22]) + let pathLength: UInt8 = 0x41 // 2-byte hashes, 1 hop + + let packet = PacketBuilder.sendAnonReq( + to: pubkey, + type: .regions, + pathLength: pathLength, + path: path + ) + + #expect(packet[0] == 0x39, "Command code") + #expect(Data(packet[1..<33]) == pubkey, "Public key") + #expect(packet[33] == 0x01, "Request type (regions)") + #expect(packet[34] == 0x41, "Path length byte") + #expect(Data(packet[35..<37]) == Data([0x22, 0x11]), "Reversed path") + #expect(packet.count == 37, "Total packet size") + } + + @Test("regions request zero-hop (no path)") + func regionsRequestZeroHop() { + let pubkey = Data(repeating: 0xBB, count: 32) + + let packet = PacketBuilder.sendAnonReq( + to: pubkey, + type: .regions, + pathLength: 0x00, + path: Data() + ) + + #expect(packet[0] == 0x39, "Command code") + #expect(packet[33] == 0x01, "Request type (regions)") + #expect(packet[34] == 0x00, "Zero path length") + #expect(packet.count == 35, "No path bytes") + } + + @Test("pubkey longer than 32 bytes is truncated") + func pubkeyTruncation() { + let longPubkey = Data(repeating: 0xCC, count: 64) + + let packet = PacketBuilder.sendAnonReq( + to: longPubkey, + type: .regions, + pathLength: 0x00, + path: Data() + ) + + #expect(Data(packet[1..<33]) == Data(repeating: 0xCC, count: 32)) + #expect(packet.count == 35, "Truncated to 32-byte pubkey") + } +} + +// MARK: - RegionsParser + +@Suite("RegionsParser") +struct RegionsParserTests { + + /// Builds a mock region response: [4-byte timestamp][UTF-8 string] + private func makeResponse(_ regionString: String, timestamp: UInt32 = 0x12345678) -> Data { + var data = Data() + data.append(contentsOf: withUnsafeBytes(of: timestamp.littleEndian) { Array($0) }) + data.append(Data(regionString.utf8)) + return data + } + + @Test("parses comma-separated regions") + func parsesMultipleRegions() throws { + let result = try RegionsParser.parse(makeResponse("Europe,UK,France")) + + #expect(result == ["Europe", "UK", "France"]) + } + + @Test("parses single region") + func parsesSingleRegion() throws { + let result = try RegionsParser.parse(makeResponse("Europe")) + + #expect(result == ["Europe"]) + } + + @Test("parses empty string to empty array") + func parsesEmptyString() throws { + let result = try RegionsParser.parse(makeResponse("")) + + #expect(result == []) + } + + @Test("strips null terminators") + func stripsNullTerminators() throws { + let result = try RegionsParser.parse(makeResponse("Europe,UK\0\0")) + + #expect(result == ["Europe", "UK"]) + } + + @Test("throws on response shorter than 4 bytes") + func throwsOnShortResponse() { + #expect(throws: MeshCoreError.self) { + _ = try RegionsParser.parse(Data([0x01, 0x02])) + } + } + + @Test("throws on invalid UTF-8") + func throwsOnInvalidUTF8() { + var data = Data(repeating: 0, count: 4) // timestamp + data.append(contentsOf: [0xFF, 0xFE]) // invalid UTF-8 + #expect(throws: MeshCoreError.self) { + _ = try RegionsParser.parse(data) + } + } +} + +// MARK: - requestRegions Integration + +@Suite("requestRegions integration") +struct RequestRegionsIntegrationTests { + + /// Builds a selfInfo packet to complete session.start(). + /// Format: [0x01][advType:1][txPower:1][maxTxPower:1][pubkey:32][lat:4LE][lon:4LE] + /// [flags:1][reserved:1][reserved:1][reserved:1][freq:4LE][bw:4LE][sf:1][cr:1][name:UTF8] + private func makeSelfInfoPacket() -> Data { + var data = Data([ResponseCode.selfInfo.rawValue]) + data.append(0) // advType + data.append(0) // txPower + data.append(0) // maxTxPower + data.append(Data(repeating: 0x01, count: 32)) // publicKey + data.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) // lat + data.append(contentsOf: withUnsafeBytes(of: Int32(0).littleEndian) { Array($0) }) // lon + data.append(0) // flags + data.append(0) // reserved + data.append(0) // reserved + data.append(0) // reserved + data.append(contentsOf: withUnsafeBytes(of: UInt32(915_000).littleEndian) { Array($0) }) // freq + data.append(contentsOf: withUnsafeBytes(of: UInt32(125_000).littleEndian) { Array($0) }) // bw + data.append(7) // sf + data.append(5) // cr + data.append(contentsOf: "Test".utf8) // name + return data + } + + /// Starts a session by driving the appStart → selfInfo handshake. + private func startSession(_ session: MeshCoreSession, transport: MockTransport) async throws { + let startTask = Task { try await session.start() } + + try await waitUntil("transport should have sent appStart") { + await transport.sentData.count >= 1 + } + + await transport.simulateReceive(makeSelfInfoPacket()) + try await startTask.value + await transport.clearSentData() + } + + /// Builds a messageSent raw packet. + /// Wire format: [0x06][type:1][expectedAck:4][suggestedTimeoutMs:4LE] + private func makeMessageSentPacket(type: UInt8 = 0, expectedAck: Data, timeoutMs: UInt32 = 5000) -> Data { + var data = Data([ResponseCode.messageSent.rawValue]) + data.append(type) + data.append(expectedAck) + data.append(contentsOf: withUnsafeBytes(of: timeoutMs.littleEndian) { Array($0) }) + return data + } + + /// Builds a binaryResponse raw packet. + /// Wire format: [0x8C][requestType:1][tag:4][responseData...] + private func makeBinaryResponsePacket(tag: Data, regionString: String, repeaterTimestamp: UInt32 = 0xAABBCCDD) -> Data { + var data = Data([ResponseCode.binaryResponse.rawValue]) + data.append(0x00) // requestType (unused by parser) + data.append(tag) + // responseData: [repeater_timestamp:4LE][UTF-8 regions] + data.append(contentsOf: withUnsafeBytes(of: repeaterTimestamp.littleEndian) { Array($0) }) + data.append(Data(regionString.utf8)) + return data + } + + /// Waits for the next command to be sent, then responds with OK. + private func acknowledgeNextCommand(_ transport: MockTransport, sentCountBefore: Int, label: String = "command") async throws { + try await waitUntil("transport should have sent \(label)") { + await transport.sentData.count > sentCountBefore + } + await transport.simulateOK() + } + + private func makeTestContact(outPathLength: UInt8 = 0xFF, outPath: Data = Data()) -> MeshContact { + let publicKey = Data(repeating: 0xDD, count: 32) + return MeshContact( + id: publicKey.hexString, + publicKey: publicKey, + type: .repeater, + flags: [], + outPathLength: outPathLength, + outPath: outPath, + advertisedName: "TestRepeater", + lastAdvertisement: Date(), + latitude: 0, + longitude: 0, + lastModified: Date() + ) + } + + @Test("full two-phase flow returns parsed regions") + func fullTwoPhaseFlow() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "Test") + ) + try await startSession(session, transport: transport) + + let contact = makeTestContact() + let expectedAck = Data([0x01, 0x02, 0x03, 0x04]) + + let regionsTask = Task { + try await session.requestRegions(from: contact) + } + + // Acknowledge updateContact (sets zero-hop for flood-routed contact) + try await acknowledgeNextCommand(transport, sentCountBefore: 0, label: "updateContact") + + try await waitUntil("transport should have sent anon request") { + await transport.sentData.count >= 2 + } + + // Phase 1: firmware acknowledges the send + await transport.simulateReceive(makeMessageSentPacket(expectedAck: expectedAck)) + // Phase 2: repeater responds with region list + await transport.simulateReceive(makeBinaryResponsePacket(tag: expectedAck, regionString: "Europe,UK,France")) + + // Acknowledge resetPath (restores flood routing) + try await acknowledgeNextCommand(transport, sentCountBefore: 2, label: "resetPath") + + let result = try await regionsTask.value + #expect(result == ["Europe", "UK", "France"]) + + await session.stop() + } + + @Test("timeout when no binaryResponse arrives") + func timeoutWhenNoResponse() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + try await startSession(session, transport: transport) + + let contact = makeTestContact() + let expectedAck = Data([0xDE, 0xAD, 0xBE, 0xEF]) + + let regionsTask = Task { + try await session.requestRegions(from: contact) + } + + // Acknowledge updateContact for flood-routed contact + try await acknowledgeNextCommand(transport, sentCountBefore: 0, label: "updateContact") + + try await waitUntil("transport should have sent anon request") { + await transport.sentData.count >= 2 + } + + // Firmware acknowledges the send (unblocks timeout stream) but repeater never responds + await transport.simulateReceive(makeMessageSentPacket(expectedAck: expectedAck, timeoutMs: 100)) + + // Acknowledge resetPath after timeout fires + try await acknowledgeNextCommand(transport, sentCountBefore: 2, label: "resetPath") + + await #expect(throws: MeshCoreError.self) { + _ = try await regionsTask.value + } + + await session.stop() + } + + @Test("device error propagates correctly") + func deviceErrorPropagates() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.5, clientIdentifier: "Test") + ) + try await startSession(session, transport: transport) + + let contact = makeTestContact() + + let regionsTask = Task { + try await session.requestRegions(from: contact) + } + + // Acknowledge updateContact for flood-routed contact + try await acknowledgeNextCommand(transport, sentCountBefore: 0, label: "updateContact") + + try await waitUntil("transport should have sent anon request") { + await transport.sentData.count >= 2 + } + + await transport.simulateError(code: 10) + + // Acknowledge resetPath after error + try await acknowledgeNextCommand(transport, sentCountBefore: 2, label: "resetPath") + + do { + _ = try await regionsTask.value + Issue.record("Expected requestRegions to throw") + } catch let error as MeshCoreError { + guard case .deviceError(let code) = error else { + Issue.record("Expected MeshCoreError.deviceError, got \(error)") + return + } + #expect(code == 10) + } + + await session.stop() + } + + @Test("temporarily sets zero-hop before sending for flood-routed contact") + func sendsCorrectWireFormatFloodRouted() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "Test") + ) + try await startSession(session, transport: transport) + + let contact = makeTestContact(outPathLength: 0xFF, outPath: Data()) + + let regionsTask = Task { + try await session.requestRegions(from: contact) + } + + // Acknowledge updateContact for flood-routed contact + try await acknowledgeNextCommand(transport, sentCountBefore: 0, label: "updateContact") + + try await waitUntil("transport should have sent anon request") { + await transport.sentData.count >= 2 + } + + // Send messageSent to unblock timeout stream, then let it timeout (no binaryResponse) + await transport.simulateReceive(makeMessageSentPacket(expectedAck: Data([0xAA, 0xBB, 0xCC, 0xDD]), timeoutMs: 100)) + + // Acknowledge resetPath after timeout + try await acknowledgeNextCommand(transport, sentCountBefore: 2, label: "resetPath") + + // Let it timeout — we just want to inspect the sent packets + _ = try? await regionsTask.value + + let sentData = await transport.sentData + #expect(sentData.count >= 3, "Should have sent updateContact, sendAnonReq, and resetPath") + + // First packet: updateContact (0x09) setting outPathLength to 0 (zero-hop direct) + let updatePacket = sentData[0] + #expect(updatePacket[0] == CommandCode.updateContact.rawValue, "First command is updateContact") + #expect(Data(updatePacket[1..<33]) == contact.publicKey, "updateContact public key") + #expect(updatePacket[35] == 0x00, "outPathLength set to zero-hop") + + // Second packet: sendAnonReq (0x39) with zero-hop path + let anonPacket = sentData[1] + #expect(anonPacket[0] == CommandCode.sendAnonReq.rawValue, "Command code") + #expect(Data(anonPacket[1..<33]) == contact.publicKey, "Public key") + #expect(anonPacket[33] == AnonRequestType.regions.rawValue, "Request type") + #expect(anonPacket[34] == 0x00, "Zero path length for flood-routed") + #expect(anonPacket.count == 35, "No path bytes for flood-routed") + + // Third packet: resetPath (0x0D) to restore flood routing + let resetPacket = sentData[2] + #expect(resetPacket[0] == CommandCode.resetPath.rawValue, "Third command is resetPath") + #expect(Data(resetPacket[1..<33]) == contact.publicKey, "resetPath public key") + + await session.stop() + } +} + From cffa880ba3e2c5a69ac71de17060237e66d20d1a Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:13:25 -0700 Subject: [PATCH 09/48] feat(regions): add region filtering UI and management - Add regionScope and knownRegions fields to Channel/Device data models - Add localization strings for region filtering across all languages - Add region picker section to ChannelInfoSheet with scope selection - Add RegionManagementView with inline alert for manual region entry - Wire setFloodScope to channel open and region change - Show active region in channel title bar subtitle - Implement broadcast-first region discovery via DISCOVER_REQ - Filter wildcard '*' and whitespace from region parser responses - Add region filtering unit tests --- MC1/Resources/Generated/L10n.swift | 44 ++ .../Localization/de.lproj/Chats.strings | 62 +++ .../Localization/en.lproj/Chats.strings | 62 +++ .../Localization/es.lproj/Chats.strings | 62 +++ .../Localization/fr.lproj/Chats.strings | 62 +++ .../Localization/nl.lproj/Chats.strings | 62 +++ .../Localization/pl.lproj/Chats.strings | 62 +++ .../Localization/ru.lproj/Chats.strings | 62 +++ .../Localization/uk.lproj/Chats.strings | 62 +++ .../Localization/zh-Hans.lproj/Chats.strings | 62 +++ MC1/Views/Chats/ChannelInfoSheet.swift | 396 +++++++++++++++++- MC1/Views/Chats/ChatConversationType.swift | 46 +- MC1/Views/Chats/ChatConversationView.swift | 17 +- MC1/Views/Chats/ChatViewModel+Channels.swift | 14 + MC1/Views/Chats/ChatViewModel.swift | 10 + .../Chats/RegionDiscoveryResultsView.swift | 61 +++ MC1/Views/Chats/RegionManagementView.swift | 178 ++++++++ MC1/Views/Chats/RegionNameValidator.swift | 33 ++ .../Components/NavigationHeaderModifier.swift | 9 +- .../MC1Services/ConnectionManager.swift | 3 +- .../Sources/MC1Services/Models/Channel.swift | 31 +- .../Sources/MC1Services/Models/Device.swift | 14 +- .../MC1Services/Services/ContactService.swift | 2 +- .../Services/PersistenceStore+Channels.swift | 18 +- .../Services/PersistenceStore+Devices.swift | 41 +- .../Models/ChannelRegionScopeTests.swift | 119 ++++++ .../Chats/RegionNameValidatorTests.swift | 71 ++++ .../Sources/MeshCore/Protocol/Parsers.swift | 4 +- .../Protocol/FloodScopeMappingTests.swift | 39 ++ .../MeshCoreTests/Protocol/RegionTests.swift | 28 ++ 30 files changed, 1711 insertions(+), 25 deletions(-) create mode 100644 MC1/Views/Chats/RegionDiscoveryResultsView.swift create mode 100644 MC1/Views/Chats/RegionManagementView.swift create mode 100644 MC1/Views/Chats/RegionNameValidator.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift create mode 100644 MC1Tests/Views/Chats/RegionNameValidatorTests.swift create mode 100644 MeshCore/Tests/MeshCoreTests/Protocol/FloodScopeMappingTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 24e2610b7..c68787206 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -91,6 +91,8 @@ public enum L10n { public static let manualSharing = L10n.tr("Chats", "chats.channelInfo.manualSharing", fallback: "Manual Sharing") /// Location: ChannelInfoSheet.swift - Footer explaining manual sharing public static let manualSharingFooter = L10n.tr("Chats", "chats.channelInfo.manualSharingFooter", fallback: "Share the channel name and this secret key for others to join manually.") + /// Location: ChannelInfoSheet.swift - Purpose: Region row label + public static let region = L10n.tr("Chats", "chats.channelInfo.region", fallback: "Region") /// Location: ChannelInfoSheet.swift - QR code instruction text public static let scanToJoin = L10n.tr("Chats", "chats.channelInfo.scanToJoin", fallback: "Scan to join this channel") /// Location: ChannelInfoSheet.swift - Label for secret key @@ -125,6 +127,48 @@ public enum L10n { /// Location: ChatsView.swift - Alert title when channel deletion fails public static let title = L10n.tr("Chats", "chats.channelInfo.deleteFailed.title", fallback: "Channel Deletion Failed") } + public enum Region { + /// Location: RegionManagementView.swift - Purpose: Add manually button + public static let addManually = L10n.tr("Chats", "chats.channelInfo.region.addManually", fallback: "Add Manually") + /// Location: AddRegionView.swift - Purpose: Text field placeholder + public static let addRegionPlaceholder = L10n.tr("Chats", "chats.channelInfo.region.addRegionPlaceholder", fallback: "Region name") + /// Location: AddRegionView.swift - Purpose: Navigation title + public static let addRegionTitle = L10n.tr("Chats", "chats.channelInfo.region.addRegionTitle", fallback: "Add Region") + /// Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button + public static let addSelected = L10n.tr("Chats", "chats.channelInfo.region.addSelected", fallback: "Add") + /// Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set + public static let allRegions = L10n.tr("Chats", "chats.channelInfo.region.allRegions", fallback: "All Regions") + /// Location: ChannelInfoSheet.swift - Purpose: Discover button + public static let discover = L10n.tr("Chats", "chats.channelInfo.region.discover", fallback: "Discover Nearby Regions") + /// Location: ChannelInfoSheet.swift - Purpose: Discover button loading state + public static let discovering = L10n.tr("Chats", "chats.channelInfo.region.discovering", fallback: "Discovering…") + /// Location: AddRegionView.swift - Purpose: Duplicate error + public static let duplicate = L10n.tr("Chats", "chats.channelInfo.region.duplicate", fallback: "This region is already in your list.") + /// Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist + public static let explanation = L10n.tr("Chats", "chats.channelInfo.region.explanation", fallback: "Limit messages to a geographic area") + /// Location: AddRegionView.swift - Purpose: Validation error + public static let invalidName = L10n.tr("Chats", "chats.channelInfo.region.invalidName", fallback: "Region names cannot contain spaces or start with # or $.") + /// Location: RegionManagementView.swift - Purpose: Navigation title + public static let manage = L10n.tr("Chats", "chats.channelInfo.region.manage", fallback: "Regions") + /// Location: RegionManagementView.swift - Purpose: Manage regions link + public static let manageRegions = L10n.tr("Chats", "chats.channelInfo.region.manageRegions", fallback: "Manage Regions") + /// Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found + public static let noNewRegions = L10n.tr("Chats", "chats.channelInfo.region.noNewRegions", fallback: "No new regions found") + /// Location: RegionManagementView.swift - Purpose: Empty state title + public static let noRegions = L10n.tr("Chats", "chats.channelInfo.region.noRegions", fallback: "No regions added") + /// Location: RegionManagementView.swift - Purpose: Empty state description + public static let noRegionsDescription = L10n.tr("Chats", "chats.channelInfo.region.noRegionsDescription", fallback: "Discover regions from nearby repeaters or add them manually.") + /// Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded + public static let noRepeatersResponded = L10n.tr("Chats", "chats.channelInfo.region.noRepeatersResponded", fallback: "No repeaters responded") + /// Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured + public static let notConfigured = L10n.tr("Chats", "chats.channelInfo.region.notConfigured", fallback: "Not configured") + /// Location: ChannelInfoSheet.swift - Purpose: Private region label + public static let `private` = L10n.tr("Chats", "chats.channelInfo.region.private", fallback: "Private") + /// Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle + public static func scopedAccessibility(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Chats", "chats.channelInfo.region.scopedAccessibility", String(describing: p1), String(describing: p2), fallback: "%@, scoped to %@") + } + } } public enum ChannelOptions { /// Location: ChannelOptionsSheet.swift - Loading indicator text diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index d14b0ba84..3636e61c6 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -979,3 +979,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Warnung: Link zu %@ als verdächtig markiert"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index 7f97f0f69..0cd7d1eb9 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -984,3 +984,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Warning: link to %@ flagged as suspicious"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 378a6e94e..fd5de7e53 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -975,3 +975,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Advertencia: enlace a %@ marcado como sospechoso"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index a70286e75..8b37e67c6 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -976,3 +976,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Avertissement : lien vers %@ signalé comme suspect"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index 18eecb760..f9018c380 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -975,3 +975,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Waarschuwing: link naar %@ gemarkeerd als verdacht"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 6cc2def6c..6c322dee7 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -969,3 +969,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Ostrzeżenie: link do %@ oznaczony jako podejrzany"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index 903013067..c645f86af 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -966,3 +966,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Предупреждение: ссылка на %@ отмечена как подозрительная"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index 2f090c906..79ae7a08a 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -968,3 +968,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "Попередження: посилання на %@ позначено як підозріле"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index c2ad5d7fb..3ac9206c6 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -976,3 +976,65 @@ /* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ "chats.malwareWarning.accessibility" = "警告:指向%@的链接被标记为可疑"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Region"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "All Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Not configured"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limit messages to a geographic area"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Discover Nearby Regions"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "Discovering…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regions"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Manage Regions"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Add Manually"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "No regions added"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Discover regions from nearby repeaters or add them manually."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "No new regions found"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "No repeaters responded"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Add"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Add Region"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Region name"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Region names cannot contain spaces or start with # or $."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "This region is already in your list."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Private"; + +/* Location: ChatConversationType.swift - Purpose: Accessibility label for scoped subtitle */ +"chats.channelInfo.region.scopedAccessibility" = "%@, scoped to %@"; diff --git a/MC1/Views/Chats/ChannelInfoSheet.swift b/MC1/Views/Chats/ChannelInfoSheet.swift index 775a018f2..63249e6f1 100644 --- a/MC1/Views/Chats/ChannelInfoSheet.swift +++ b/MC1/Views/Chats/ChannelInfoSheet.swift @@ -1,6 +1,9 @@ import SwiftUI import MC1Services import CoreImage.CIFilterBuiltins +import OSLog + +private let logger = Logger(subsystem: "com.mc1", category: "ChannelInfoSheet") /// Sheet displaying channel info with sharing and deletion options struct ChannelInfoSheet: View { @@ -22,6 +25,16 @@ struct ChannelInfoSheet: View { @State private var copyHapticTrigger = 0 @State private var notificationTask: Task? @State private var favoriteTask: Task? + @State private var knownRegions: [String] = [] + @State private var isRegionExpanded = false + @State private var isDiscoveringRegions = false + @State private var discoveryMessage: String? + @State private var showingRegionManagement = false + @State private var discoveryTask: Task? + @State private var discoveredNewRegions: [String] = [] + @State private var showingDiscoveryResults = false + @State private var selectedRegionScope: String? + @State private var hasLoadedRegions = false init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { self.channel = channel @@ -29,6 +42,7 @@ struct ChannelInfoSheet: View { self.onDelete = onDelete self._notificationLevel = State(initialValue: channel.notificationLevel) self._isFavorite = State(initialValue: channel.isFavorite) + self._selectedRegionScope = State(initialValue: channel.regionScope) } var body: some View { @@ -58,8 +72,29 @@ struct ChannelInfoSheet: View { .onDisappear { notificationTask?.cancel() favoriteTask?.cancel() + discoveryTask?.cancel() } + // Region Scope Section + ChannelInfoRegionSection( + knownRegions: knownRegions, + selectedRegionScope: selectedRegionScope, + isExpanded: $isRegionExpanded, + isDiscovering: $isDiscoveringRegions, + discoveryMessage: $discoveryMessage, + onRegionSelected: { region in + selectRegion(region) + }, + onDiscoverTapped: { + runDiscovery { newRegions in + for region in newRegions { addRegion(region) } + } + }, + onManageTapped: { + showingRegionManagement = true + } + ) + // QR Code Section (only for private channels with secrets) if channel.hasSecret && !channel.isPublicChannel { ChannelInfoQRCodeSection(channel: channel) @@ -102,6 +137,39 @@ struct ChannelInfoSheet: View { } } } + .navigationDestination(isPresented: $showingRegionManagement) { + RegionManagementView( + knownRegions: $knownRegions, + isDiscovering: $isDiscoveringRegions, + discoveryMessage: $discoveryMessage, + onRemoveRegion: { region in + removeRegion(region) + }, + onAddRegion: { region in + addRegion(region) + }, + onDiscoverTapped: { + runDiscovery { newRegions in + discoveredNewRegions = newRegions + showingDiscoveryResults = true + } + } + ) + } + .navigationDestination(isPresented: $showingDiscoveryResults) { + RegionDiscoveryResultsView(discoveredRegions: discoveredNewRegions) { selected in + for region in selected { + addRegion(region) + } + } + } + .task { + guard !hasLoadedRegions else { return } + hasLoadedRegions = true + if let device = try? await appState.offlineDataStore?.fetchDevice(id: channel.deviceID) { + knownRegions = device.knownRegions + } + } } .confirmationDialog( L10n.Chats.Chats.ChannelInfo.ClearMessagesConfirm.title, @@ -205,6 +273,144 @@ struct ChannelInfoSheet: View { isClearingMessages = false } } + + private func selectRegion(_ region: String?) { + let previousScope = selectedRegionScope + selectedRegionScope = region + do { + try appState.offlineDataStore?.setChannelRegionScope(channel.id, regionScope: region) + } catch { + logger.error("Failed to save region scope: \(error.localizedDescription)") + selectedRegionScope = previousScope + return + } + + Task { + if let session = appState.services?.session { + let scope: FloodScope = region.map { .region($0) } ?? .disabled + try? await session.setFloodScope(scope) + } + } + } + + private func removeRegion(_ region: String) { + do { + try appState.offlineDataStore?.removeDeviceKnownRegion(deviceID: channel.deviceID, region: region) + knownRegions.removeAll { $0 == region } + } catch { + logger.error("Failed to remove region: \(error.localizedDescription)") + } + } + + private func addRegion(_ region: String) { + do { + try appState.offlineDataStore?.addDeviceKnownRegion(deviceID: channel.deviceID, region: region) + if !knownRegions.contains(region) { + knownRegions.append(region) + } + } catch { + logger.error("Failed to add region: \(error.localizedDescription)") + } + } + + private func runDiscovery(onNewRegions: @escaping ([String]) -> Void) { + discoveryTask?.cancel() + discoveryTask = Task { + isDiscoveringRegions = true + discoveryMessage = nil + + let newRegions = await discoverNewRegions() + + guard !Task.isCancelled else { + isDiscoveringRegions = false + return + } + + if newRegions.isEmpty { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noNewRegions + } else { + onNewRegions(newRegions) + } + isDiscoveringRegions = false + } + } + + /// Broadcasts a discover probe to find nearby repeaters, then queries only those for regions + private func discoverNewRegions() async -> [String] { + guard let session = appState.services?.session, + let contactService = appState.services?.contactService else { + return [] + } + let deviceID = channel.deviceID + + // Phase 1: Broadcast DISCOVER_REQ to find nearby repeaters (~3s) + let discoveredPubkeys: Set + do { + let tag = try await session.sendNodeDiscoverRequest( + filter: NodeDiscoveryFilter.repeaters.filterValue, + prefixOnly: false + ) + let tagData = withUnsafeBytes(of: tag.littleEndian) { Data($0) } + + let listenTask = Task { () -> Set in + var keys = Set() + let events = await session.events() + for await event in events { + guard !Task.isCancelled else { break } + if case .discoverResponse(let response) = event, + response.tag == tagData { + keys.insert(response.publicKey) + } + } + return keys + } + + try? await Task.sleep(for: .seconds(3)) + listenTask.cancel() + discoveredPubkeys = await listenTask.value + } catch { + return [] + } + + guard !Task.isCancelled else { return [] } + + if discoveredPubkeys.isEmpty { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + return [] + } + + // Phase 2: Query only responding repeaters for their regions + let repeaters: [ContactDTO] + do { + repeaters = try await contactService.getContacts(deviceID: deviceID) + .filter { $0.type == .repeater && discoveredPubkeys.contains($0.publicKey) } + } catch { + return [] + } + + if repeaters.isEmpty { + discoveryMessage = L10n.Chats.Chats.ChannelInfo.Region.noRepeatersResponded + return [] + } + + var allRegions = Set() + + await withTaskGroup(of: [String].self) { group in + for contact in repeaters { + guard !Task.isCancelled else { break } + let meshContact = contact.toContactFrame().toMeshContact() + group.addTask { + (try? await session.requestRegions(from: meshContact)) ?? [] + } + } + for await regions in group { + allRegions.formUnion(regions) + } + } + + let knownSet = Set(knownRegions) + return allRegions.subtracting(knownSet).sorted() + } } // MARK: - Extracted Views @@ -247,12 +453,14 @@ private struct ChannelInfoHeaderSection: View { private struct ChannelInfoQRCodeSection: View { let channel: ChannelDTO + @State private var qrImage: UIImage? + var body: some View { Section { HStack { Spacer() VStack(spacing: 12) { - if let qrImage = generateQRCode() { + if let qrImage { Image(uiImage: qrImage) .interpolation(.none) .resizable() @@ -269,6 +477,9 @@ private struct ChannelInfoQRCodeSection: View { } header: { Text(L10n.Chats.Chats.ChannelInfo.shareChannel) } + .task { + qrImage = generateQRCode() + } } private func generateQRCode() -> UIImage? { @@ -370,6 +581,189 @@ private struct ChannelInfoActionsSection: View { } } +private struct ChannelInfoRegionSection: View { + let knownRegions: [String] + let selectedRegionScope: String? + @Binding var isExpanded: Bool + @Binding var isDiscovering: Bool + @Binding var discoveryMessage: String? + let onRegionSelected: (String?) -> Void + let onDiscoverTapped: () -> Void + let onManageTapped: () -> Void + + private var sortedPartitioned: (public: [String], private: [String]) { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return (sorted.filter { !$0.isPrivateRegion }, sorted.filter { $0.isPrivateRegion }) + } + + private var regionValueLabel: String { + if knownRegions.isEmpty { + return L10n.Chats.Chats.ChannelInfo.Region.notConfigured + } + if let scope = selectedRegionScope { + return scope + } + return L10n.Chats.Chats.ChannelInfo.Region.allRegions + } + + var body: some View { + Section { + DisclosureGroup(isExpanded: $isExpanded) { + if knownRegions.isEmpty { + ChannelInfoRegionEmptyContent( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onManageTapped: onManageTapped + ) + } else { + ChannelInfoRegionPickerContent( + selectedRegionScope: selectedRegionScope, + publicRegions: sortedPartitioned.public, + privateRegions: sortedPartitioned.private, + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onRegionSelected: onRegionSelected, + onDiscoverTapped: onDiscoverTapped, + onManageTapped: onManageTapped + ) + } + } label: { + ChannelInfoRegionLabel(regionValueLabel: regionValueLabel) + } + } + } +} + +private struct ChannelInfoRegionLabel: View { + let regionValueLabel: String + + var body: some View { + HStack { + Label(L10n.Chats.Chats.ChannelInfo.region, systemImage: "globe") + Spacer() + Text(regionValueLabel) + .foregroundStyle(.secondary) + } + } +} + +private struct ChannelInfoRegionActions: View { + let isDiscovering: Bool + let discoveryMessage: String? + let onDiscoverTapped: () -> Void + let onManageTapped: () -> Void + + var body: some View { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) + } + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { + onDiscoverTapped() + } + .disabled(isDiscovering) + + Button(L10n.Chats.Chats.ChannelInfo.Region.manageRegions, systemImage: "list.bullet") { + onManageTapped() + } + } +} + +private struct ChannelInfoRegionEmptyContent: View { + let isDiscovering: Bool + let discoveryMessage: String? + let onDiscoverTapped: () -> Void + let onManageTapped: () -> Void + + var body: some View { + Text(L10n.Chats.Chats.ChannelInfo.Region.explanation) + .font(.subheadline) + .foregroundStyle(.secondary) + + ChannelInfoRegionActions( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onManageTapped: onManageTapped + ) + } +} + +private struct ChannelInfoRegionPickerContent: View { + let selectedRegionScope: String? + let publicRegions: [String] + let privateRegions: [String] + let isDiscovering: Bool + let discoveryMessage: String? + let onRegionSelected: (String?) -> Void + let onDiscoverTapped: () -> Void + let onManageTapped: () -> Void + + var body: some View { + // "All Regions" option + Button { + onRegionSelected(nil) + } label: { + HStack { + Text(L10n.Chats.Chats.ChannelInfo.Region.allRegions) + Spacer() + if selectedRegionScope == nil { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + + // Public regions + ForEach(publicRegions, id: \.self) { region in + Button { + onRegionSelected(region) + } label: { + HStack { + Text(region) + Spacer() + if selectedRegionScope == region { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + // Private regions (shown disabled) + ForEach(privateRegions, id: \.self) { region in + HStack { + Text(region) + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) + .font(.caption) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.secondary) + } + + ChannelInfoRegionActions( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onManageTapped: onManageTapped + ) + } +} + #Preview { ChannelInfoSheet( channel: ChannelDTO(from: Channel( diff --git a/MC1/Views/Chats/ChatConversationType.swift b/MC1/Views/Chats/ChatConversationType.swift index c5efe0772..255975f84 100644 --- a/MC1/Views/Chats/ChatConversationType.swift +++ b/MC1/Views/Chats/ChatConversationType.swift @@ -22,18 +22,42 @@ enum ChatConversationType: Sendable { switch self { case .dm(let contact): if contact.isFloodRouted { - L10n.Chats.Chats.ConnectionStatus.floodRouting + return L10n.Chats.Chats.ConnectionStatus.floodRouting } else { - L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) + return L10n.Chats.Chats.ConnectionStatus.direct(contact.pathHopCount) } case .channel(let channel): - if channel.isPublicChannel { - L10n.Chats.Chats.Channel.typePublic - } else if channel.name.hasPrefix("#") { - L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag - } else { - L10n.Chats.Chats.Channel.typePrivate + let base = channelTypeSubtitle(for: channel) + if let region = channel.regionScope { + return "\(base) \u{00B7} \(region)" } + return base + } + } + + /// Accessibility label for the subtitle, providing a VoiceOver-friendly description + /// when a region scope is active (the middle dot separator may be read literally). + var navigationSubtitleAccessibilityLabel: String? { + switch self { + case .dm: + return nil + case .channel(let channel): + guard let region = channel.regionScope else { return nil } + return L10n.Chats.Chats.ChannelInfo.Region.scopedAccessibility( + channelTypeSubtitle(for: channel), region + ) + } + } + + // MARK: - Private Helpers + + private func channelTypeSubtitle(for channel: ChannelDTO) -> String { + if channel.isPublicChannel { + L10n.Chats.Chats.Channel.typePublic + } else if channel.name.hasPrefix("#") { + L10n.Chats.Chats.ChannelInfo.ChannelType.hashtag + } else { + L10n.Chats.Chats.Channel.typePrivate } } @@ -62,4 +86,10 @@ enum ChatConversationType: Sendable { guard case .dm = self else { return self } return .dm(contact) } + + /// Returns a copy with the channel replaced (channel only). Returns self unchanged for DMs. + func replacingChannel(_ channel: ChannelDTO) -> ChatConversationType { + guard case .channel = self else { return self } + return .channel(channel) + } } diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 2cc10ca21..48f55a5f9 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -110,7 +110,8 @@ struct ChatConversationView: View { } .navigationHeader( title: conversationType.navigationTitle, - subtitle: conversationType.navigationSubtitle + subtitle: conversationType.navigationSubtitle, + subtitleAccessibilityLabel: conversationType.navigationSubtitleAccessibilityLabel ) .toolbar { ToolbarItem(placement: .primaryAction) { @@ -121,8 +122,11 @@ struct ChatConversationView: View { } // Info sheet — type-specific .sheet(isPresented: $showingInfo, onDismiss: { - if case .dm = conversationType { + switch conversationType { + case .dm: Task { await refreshContact() } + case .channel: + Task { await refreshChannel() } } }, content: { ChatConversationInfoSheet( @@ -365,7 +369,7 @@ struct ChatConversationView: View { return needsReload } - // MARK: - Contact Refresh (DM only) + // MARK: - Conversation Refresh private func refreshContact() async { guard case .dm(let contact) = conversationType else { return } @@ -375,6 +379,13 @@ struct ChatConversationView: View { } } + private func refreshChannel() async { + guard case .channel(let channel) = conversationType else { return } + if let updated = try? await appState.offlineDataStore?.fetchChannel(id: channel.id) { + conversationType = conversationType.replacingChannel(updated) + } + } + // MARK: - Mention Tracking private func loadUnseenMentions() async { diff --git a/MC1/Views/Chats/ChatViewModel+Channels.swift b/MC1/Views/Chats/ChatViewModel+Channels.swift index bf14b7109..2e0db9bb2 100644 --- a/MC1/Views/Chats/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ChatViewModel+Channels.swift @@ -19,6 +19,7 @@ extension ChatViewModel { clearPreviewState() newMessagesDividerMessageID = nil dividerComputed = false + lastSetRegionScope = .unknown } currentChannel = channel @@ -29,6 +30,19 @@ extension ChatViewModel { notificationService?.activeChannelIndex = channel.index notificationService?.activeChannelDeviceID = channel.deviceID + // Set flood scope on device when channel or region changes + if lastSetRegionScope == .unknown || lastSetRegionScope != .set(channel.regionScope) { + if let session = appState?.services?.session { + let scope: FloodScope = channel.regionScope.map { .region($0) } ?? .disabled + do { + try await session.setFloodScope(scope) + lastSetRegionScope = .set(channel.regionScope) + } catch { + logger.error("Failed to set flood scope: \(error.localizedDescription)") + } + } + } + logger.info("loadChannelMessages: setting isLoading=true, current messages.count=\(self.messages.count)") isLoading = true errorMessage = nil diff --git a/MC1/Views/Chats/ChatViewModel.swift b/MC1/Views/Chats/ChatViewModel.swift index a019931d8..bf6b1bc2e 100644 --- a/MC1/Views/Chats/ChatViewModel.swift +++ b/MC1/Views/Chats/ChatViewModel.swift @@ -8,6 +8,12 @@ import OSLog @MainActor final class ChatViewModel { + /// Tracks whether the device's flood scope has been configured this session. + enum RegionScopeState: Equatable { + case unknown + case set(String?) + } + // MARK: - Properties let logger = Logger(subsystem: "com.mc1", category: "ChatViewModel") @@ -64,6 +70,8 @@ final class ChatViewModel { // Stored for lifecycle tracking; queue drains independently of conversation @ObservationIgnored var queueProcessorTask: Task? @ObservationIgnored var channelQueueTask: Task? + /// Tracks the last region scope sent to the device via setFloodScope. + @ObservationIgnored var lastSetRegionScope: RegionScopeState = .unknown /// Fallback date for conversations with no messages, used to sort them to the end. private static let noMessageSentinel = Date.distantPast @@ -279,6 +287,7 @@ final class ChatViewModel { self.contactService = appState.services?.contactService self.syncCoordinator = appState.syncCoordinator self.linkPreviewCache = linkPreviewCache + self.lastSetRegionScope = .unknown } /// Configure with services from AppState (for conversation list views that don't show previews) @@ -291,6 +300,7 @@ final class ChatViewModel { self.roomServerService = appState.services?.roomServerService self.contactService = appState.services?.contactService self.syncCoordinator = appState.syncCoordinator + self.lastSetRegionScope = .unknown } /// Configure with services (for testing) diff --git a/MC1/Views/Chats/RegionDiscoveryResultsView.swift b/MC1/Views/Chats/RegionDiscoveryResultsView.swift new file mode 100644 index 000000000..01ab3903f --- /dev/null +++ b/MC1/Views/Chats/RegionDiscoveryResultsView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Pushed view showing discovered regions with toggleable selection +struct RegionDiscoveryResultsView: View { + let sortedRegions: [String] + let onAdd: ([String]) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var selectedRegions: Set + + init(discoveredRegions: [String], onAdd: @escaping ([String]) -> Void) { + let sorted = discoveredRegions.sorted() + self.sortedRegions = sorted + self.onAdd = onAdd + self._selectedRegions = State(initialValue: Set(discoveredRegions)) + } + + var body: some View { + Form { + Section { + ForEach(sortedRegions, id: \.self) { region in + Button { + toggleSelection(region) + } label: { + HStack { + Text(region) + if region.isPrivateRegion { + Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + if selectedRegions.contains(region) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + } + .tint(.primary) + } + } + + Section { + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + onAdd(Array(selectedRegions)) + dismiss() + } + .disabled(selectedRegions.isEmpty) + } + } + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.discover) + } + + private func toggleSelection(_ region: String) { + if selectedRegions.contains(region) { + selectedRegions.remove(region) + } else { + selectedRegions.insert(region) + } + } +} diff --git a/MC1/Views/Chats/RegionManagementView.swift b/MC1/Views/Chats/RegionManagementView.swift new file mode 100644 index 000000000..fcaacf58c --- /dev/null +++ b/MC1/Views/Chats/RegionManagementView.swift @@ -0,0 +1,178 @@ +import SwiftUI + +/// Form-based view for managing known regions with add/delete functionality +struct RegionManagementView: View { + @Binding var knownRegions: [String] + @Binding var isDiscovering: Bool + @Binding var discoveryMessage: String? + + let onRemoveRegion: (String) -> Void + let onAddRegion: (String) -> Void + let onDiscoverTapped: () -> Void + + @State private var searchText = "" + @State private var showingAddAlert = false + @State private var newRegionName = "" + @State private var validationMessage: String? + + private var filteredRegions: [String] { + let sorted = knownRegions.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + guard !searchText.isEmpty else { return sorted } + return sorted.filter { $0.localizedStandardContains(searchText) } + } + + var body: some View { + Form { + if knownRegions.isEmpty { + RegionManagementEmptyState() + } else { + KnownRegionsSection( + regions: filteredRegions, + onDelete: removeRegions + ) + } + + ActionsSection( + isDiscovering: isDiscovering, + discoveryMessage: discoveryMessage, + onDiscoverTapped: onDiscoverTapped, + onAddTapped: { + newRegionName = "" + showingAddAlert = true + } + ) + } + .navigationTitle(L10n.Chats.Chats.ChannelInfo.Region.manage) + .modifier(SearchableModifier(searchText: $searchText, isEnabled: knownRegions.count >= 15)) + .alert(L10n.Chats.Chats.ChannelInfo.Region.addRegionTitle, isPresented: $showingAddAlert) { + TextField(L10n.Chats.Chats.ChannelInfo.Region.addRegionPlaceholder, text: $newRegionName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(L10n.Chats.Chats.ChannelInfo.Region.addSelected) { + if let error = RegionNameValidator.validate(newRegionName, existingRegions: knownRegions) { + validationMessage = validationText(for: error) + Task { showingAddAlert = true } + return + } + validationMessage = nil + onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) + } + Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { + validationMessage = nil + } + } message: { + if let validationMessage { + Text(validationMessage) + } + } + } + + private func validationText(for error: RegionNameValidator.ValidationError) -> String? { + switch error { + case .empty: nil + case .invalidCharacters, .invalidPrefix: L10n.Chats.Chats.ChannelInfo.Region.invalidName + case .duplicate: L10n.Chats.Chats.ChannelInfo.Region.duplicate + } + } + + private func removeRegions(at offsets: IndexSet) { + let regionsToRemove = offsets.map { filteredRegions[$0] } + for region in regionsToRemove { + onRemoveRegion(region) + } + } +} + +// MARK: - Extracted Views + +private struct RegionManagementEmptyState: View { + var body: some View { + ContentUnavailableView { + Label(L10n.Chats.Chats.ChannelInfo.Region.noRegions, systemImage: "map") + } description: { + Text(L10n.Chats.Chats.ChannelInfo.Region.noRegionsDescription) + } + } +} + +private struct KnownRegionsSection: View { + let regions: [String] + let onDelete: (IndexSet) -> Void + + var body: some View { + Section { + ForEach(regions, id: \.self) { region in + RegionRow(name: region) + } + .onDelete(perform: onDelete) + } + } +} + +private struct RegionRow: View { + let name: String + + private var isPrivate: Bool { + name.isPrivateRegion + } + + var body: some View { + HStack { + Text(name) + if isPrivate { + Spacer() + Text(L10n.Chats.Chats.ChannelInfo.Region.`private`) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } +} + +private struct ActionsSection: View { + let isDiscovering: Bool + let discoveryMessage: String? + let onDiscoverTapped: () -> Void + let onAddTapped: () -> Void + + var body: some View { + Section { + if isDiscovering { + HStack { + ProgressView() + Text(L10n.Chats.Chats.ChannelInfo.Region.discovering) + .foregroundStyle(.secondary) + } + } else if let discoveryMessage { + Text(discoveryMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button(L10n.Chats.Chats.ChannelInfo.Region.discover, systemImage: "antenna.radiowaves.left.and.right") { + onDiscoverTapped() + } + .disabled(isDiscovering) + + Button(L10n.Chats.Chats.ChannelInfo.Region.addManually, systemImage: "plus") { + onAddTapped() + } + } footer: { + Text(L10n.Chats.Chats.ChannelInfo.Region.invalidName) + } + } +} + +/// Conditionally applies `.searchable()` when the region count warrants it +private struct SearchableModifier: ViewModifier { + @Binding var searchText: String + let isEnabled: Bool + + func body(content: Content) -> some View { + if isEnabled { + content.searchable(text: $searchText) + } else { + content + } + } +} diff --git a/MC1/Views/Chats/RegionNameValidator.swift b/MC1/Views/Chats/RegionNameValidator.swift new file mode 100644 index 000000000..a9a8e0263 --- /dev/null +++ b/MC1/Views/Chats/RegionNameValidator.swift @@ -0,0 +1,33 @@ +import Foundation + +extension String { + /// Whether this region name represents a private region (prefixed with "$") + var isPrivateRegion: Bool { hasPrefix("$") } +} + +/// Validates region names before adding them to the device's known regions list +enum RegionNameValidator { + enum ValidationError { + case empty + case invalidCharacters + case invalidPrefix + case duplicate + } + + private static let disallowedCharacters = CharacterSet.controlCharacters.union(.whitespaces) + + static func validate(_ name: String, existingRegions: [String]) -> ValidationError? { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .empty } + if trimmed.unicodeScalars.contains(where: { disallowedCharacters.contains($0) }) { + return .invalidCharacters + } + if trimmed.isPrivateRegion || trimmed.hasPrefix("#") { return .invalidPrefix } + if existingRegions.contains(trimmed) { return .duplicate } + return nil + } + + static func isValid(_ name: String, existingRegions: [String]) -> Bool { + validate(name, existingRegions: existingRegions) == nil + } +} diff --git a/MC1/Views/Components/NavigationHeaderModifier.swift b/MC1/Views/Components/NavigationHeaderModifier.swift index 6a001135f..ddfcf3f52 100644 --- a/MC1/Views/Components/NavigationHeaderModifier.swift +++ b/MC1/Views/Components/NavigationHeaderModifier.swift @@ -6,12 +6,16 @@ import SwiftUI struct NavigationHeaderModifier: ViewModifier { let title: String let subtitle: String + let subtitleAccessibilityLabel: String? @State private var showHeader = false func body(content: Content) -> some View { #if os(iOS) if #available(iOS 26, *) { + // TODO: subtitleAccessibilityLabel is not applied here — .navigationSubtitle() + // renders in system chrome with no public API to override its accessibility label. + // VoiceOver may read separators (e.g. "·") literally. Verify with VoiceOver testing. content .navigationTitle(title) .navigationSubtitle(subtitle) @@ -40,6 +44,7 @@ struct NavigationHeaderModifier: ViewModifier { Text(subtitle) .font(.caption2) .foregroundStyle(.secondary) + .accessibilityLabel(subtitleAccessibilityLabel ?? subtitle) } } } @@ -56,7 +61,7 @@ struct NavigationHeaderModifier: ViewModifier { extension View { /// Applies an animated navigation header with title and subtitle. /// Uses native `.navigationSubtitle()` on iOS 26+, with animated fallback for earlier versions. - func navigationHeader(title: String, subtitle: String) -> some View { - modifier(NavigationHeaderModifier(title: title, subtitle: subtitle)) + func navigationHeader(title: String, subtitle: String, subtitleAccessibilityLabel: String? = nil) -> some View { + modifier(NavigationHeaderModifier(title: title, subtitle: subtitle, subtitleAccessibilityLabel: subtitleAccessibilityLabel)) } } diff --git a/MC1Services/Sources/MC1Services/ConnectionManager.swift b/MC1Services/Sources/MC1Services/ConnectionManager.swift index ab8744f2a..0820d741f 100644 --- a/MC1Services/Sources/MC1Services/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/ConnectionManager.swift @@ -794,7 +794,8 @@ public final class ConnectionManager { ocvPreset: existingDevice?.ocvPreset ?? OCVPreset.preset(forManufacturer: capabilities.model)?.rawValue, customOCVArrayString: existingDevice?.customOCVArrayString, - connectionMethods: mergedMethods + connectionMethods: mergedMethods, + knownRegions: existingDevice?.knownRegions ?? [] ) // If repeat mode was disabled externally, clear orphaned pre-repeat settings diff --git a/MC1Services/Sources/MC1Services/Models/Channel.swift b/MC1Services/Sources/MC1Services/Models/Channel.swift index a290468a3..bb02f0d15 100644 --- a/MC1Services/Sources/MC1Services/Models/Channel.swift +++ b/MC1Services/Sources/MC1Services/Models/Channel.swift @@ -65,6 +65,9 @@ public final class Channel { /// Whether this channel is marked as favorite public var isFavorite: Bool = false + /// Region code this channel is scoped to (nil = no region filter) + public var regionScope: String? + public init( id: UUID = UUID(), deviceID: UUID, @@ -76,7 +79,8 @@ public final class Channel { unreadCount: Int = 0, unreadMentionCount: Int = 0, notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false + isFavorite: Bool = false, + regionScope: String? = nil ) { self.id = id self.deviceID = deviceID @@ -89,6 +93,7 @@ public final class Channel { self.unreadMentionCount = unreadMentionCount self.notificationLevelRawValue = notificationLevel.rawValue self.isFavorite = isFavorite + self.regionScope = regionScope } /// Applies all mutable fields from a DTO to this model instance. @@ -101,6 +106,7 @@ public final class Channel { unreadMentionCount = dto.unreadMentionCount notificationLevel = dto.notificationLevel isFavorite = dto.isFavorite + regionScope = dto.regionScope } /// Creates a Channel from a protocol ChannelInfo @@ -160,6 +166,7 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { public let unreadMentionCount: Int public let notificationLevel: NotificationLevel public let isFavorite: Bool + public let regionScope: String? /// Convenience property for checking if muted public var isMuted: Bool { notificationLevel == .muted } @@ -176,6 +183,7 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { self.unreadMentionCount = channel.unreadMentionCount self.notificationLevel = channel.notificationLevel self.isFavorite = channel.isFavorite + self.regionScope = channel.regionScope } /// Memberwise initializer for creating DTOs directly @@ -190,7 +198,8 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { unreadCount: Int, unreadMentionCount: Int = 0, notificationLevel: NotificationLevel = .all, - isFavorite: Bool = false + isFavorite: Bool = false, + regionScope: String? = nil ) { self.id = id self.deviceID = deviceID @@ -203,6 +212,7 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { self.unreadMentionCount = unreadMentionCount self.notificationLevel = notificationLevel self.isFavorite = isFavorite + self.regionScope = regionScope } /// Returns a copy with only `notificationLevel` changed. @@ -211,7 +221,8 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { id: id, deviceID: deviceID, index: index, name: name, secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite + notificationLevel: notificationLevel, isFavorite: isFavorite, + regionScope: regionScope ) } @@ -221,7 +232,19 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { id: id, deviceID: deviceID, index: index, name: name, secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, - notificationLevel: notificationLevel, isFavorite: isFavorite + notificationLevel: notificationLevel, isFavorite: isFavorite, + regionScope: regionScope + ) + } + + /// Returns a copy with only `regionScope` changed. + public func with(regionScope: String?) -> ChannelDTO { + ChannelDTO( + id: id, deviceID: deviceID, index: index, name: name, + secret: secret, isEnabled: isEnabled, lastMessageDate: lastMessageDate, + unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, + notificationLevel: notificationLevel, isFavorite: isFavorite, + regionScope: regionScope ) } diff --git a/MC1Services/Sources/MC1Services/Models/Device.swift b/MC1Services/Sources/MC1Services/Models/Device.swift index b1ad87858..2c7791882 100644 --- a/MC1Services/Sources/MC1Services/Models/Device.swift +++ b/MC1Services/Sources/MC1Services/Models/Device.swift @@ -115,6 +115,9 @@ public final class Device { /// Connection methods available for this device (BLE, WiFi, etc.) public var connectionMethods: [ConnectionMethod] = [] + /// Region codes known to this device + public var knownRegions: [String] = [] + public init( id: UUID = UUID(), publicKey: Data, @@ -153,7 +156,8 @@ public final class Device { isActive: Bool = false, ocvPreset: String? = nil, customOCVArrayString: String? = nil, - connectionMethods: [ConnectionMethod] = [] + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] ) { self.id = id self.publicKey = publicKey @@ -193,6 +197,7 @@ public final class Device { self.ocvPreset = ocvPreset self.customOCVArrayString = customOCVArrayString self.connectionMethods = connectionMethods + self.knownRegions = knownRegions } /// Applies all mutable fields from a DTO to this model instance. @@ -234,6 +239,7 @@ public final class Device { ocvPreset = dto.ocvPreset customOCVArrayString = dto.customOCVArrayString connectionMethods = dto.connectionMethods + knownRegions = dto.knownRegions } } @@ -287,6 +293,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { public var ocvPreset: String? public var customOCVArrayString: String? public var connectionMethods: [ConnectionMethod] + public var knownRegions: [String] /// Computed auto-add mode based on manualAddContacts and autoAddConfig public var autoAddMode: AutoAddMode { @@ -382,7 +389,8 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { isActive: Bool, ocvPreset: String?, customOCVArrayString: String?, - connectionMethods: [ConnectionMethod] = [] + connectionMethods: [ConnectionMethod] = [], + knownRegions: [String] = [] ) { self.id = id self.publicKey = publicKey @@ -422,6 +430,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { self.ocvPreset = ocvPreset self.customOCVArrayString = customOCVArrayString self.connectionMethods = connectionMethods + self.knownRegions = knownRegions } public init(from device: Device) { @@ -463,6 +472,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { self.ocvPreset = device.ocvPreset self.customOCVArrayString = device.customOCVArrayString self.connectionMethods = device.connectionMethods + self.knownRegions = device.knownRegions } /// The 6-byte public key prefix used for identifying messages diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index a12aa94d0..52285e7e7 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -730,7 +730,7 @@ extension MeshContact { extension ContactFrame { /// Converts a ContactFrame to a MeshContact for session operations - func toMeshContact() -> MeshContact { + public func toMeshContact() -> MeshContact { MeshContact( id: publicKey.hexString(), publicKey: publicKey, diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift index 91e54b129..d8a33d4fb 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Channels.swift @@ -197,7 +197,8 @@ extension PersistenceStore { unreadCount: dto.unreadCount, unreadMentionCount: dto.unreadMentionCount, notificationLevel: dto.notificationLevel, - isFavorite: dto.isFavorite + isFavorite: dto.isFavorite, + regionScope: dto.regionScope ) modelContext.insert(channel) } @@ -334,4 +335,19 @@ extension PersistenceStore { channel.isFavorite = isFavorite try modelContext.save() } + + /// Sets the region scope for a channel + public func setChannelRegionScope(_ channelID: UUID, regionScope: String?) throws { + let targetID = channelID + let predicate = #Predicate { $0.id == targetID } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + + guard let channel = try modelContext.fetch(descriptor).first else { + throw PersistenceStoreError.channelNotFound + } + + channel.regionScope = regionScope + try modelContext.save() + } } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift index 438e56e58..c3f0daf61 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift @@ -85,7 +85,8 @@ extension PersistenceStore { isActive: dto.isActive, ocvPreset: dto.ocvPreset, customOCVArrayString: dto.customOCVArrayString, - connectionMethods: dto.connectionMethods + connectionMethods: dto.connectionMethods, + knownRegions: dto.knownRegions ) modelContext.insert(device) } @@ -134,6 +135,44 @@ extension PersistenceStore { try modelContext.save() } + /// Adds a known region to a device if not already present + public func addDeviceKnownRegion(deviceID: UUID, region: String) throws { + let targetDeviceID = deviceID + let devicePredicate = #Predicate { $0.id == targetDeviceID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + guard !device.knownRegions.contains(region) else { return } + device.knownRegions.append(region) + try modelContext.save() + } + + /// Removes a known region from a device and clears regionScope on affected channels + public func removeDeviceKnownRegion(deviceID: UUID, region: String) throws { + let targetDeviceID = deviceID + let devicePredicate = #Predicate { $0.id == targetDeviceID } + var deviceDescriptor = FetchDescriptor(predicate: devicePredicate) + deviceDescriptor.fetchLimit = 1 + + guard let device = try modelContext.fetch(deviceDescriptor).first else { + throw PersistenceStoreError.deviceNotFound + } + + device.knownRegions.removeAll { $0 == region } + + let channelPredicate = #Predicate { $0.deviceID == targetDeviceID } + let channels = try modelContext.fetch(FetchDescriptor(predicate: channelPredicate)) + for channel in channels where channel.regionScope == region { + channel.regionScope = nil + } + + try modelContext.save() + } + /// Delete a device and all its associated data public func deleteDevice(id: UUID) throws { let targetID = id diff --git a/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift new file mode 100644 index 000000000..0d9b8035f --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Models/ChannelRegionScopeTests.swift @@ -0,0 +1,119 @@ +import Testing +import Foundation +@testable import MC1Services + +@Suite("ChannelDTO regionScope propagation") +struct ChannelRegionScopeTests { + + // MARK: - Helpers + + private func makeDTO(regionScope: String? = nil) -> ChannelDTO { + ChannelDTO( + id: UUID(), + deviceID: UUID(), + index: 1, + name: "Test", + secret: Data(repeating: 0, count: 16), + isEnabled: true, + lastMessageDate: nil, + unreadCount: 0, + regionScope: regionScope + ) + } + + // MARK: - Init Tests + + @Test("nil regionScope preserved on init") + func nilRegionScopePreserved() { + let dto = makeDTO() + #expect(dto.regionScope == nil) + } + + @Test("non-nil regionScope preserved on init") + func regionScopePreserved() { + let dto = makeDTO(regionScope: "Europe") + #expect(dto.regionScope == "Europe") + } + + // MARK: - with(notificationLevel:) + + @Test("with(notificationLevel:) preserves regionScope") + func withNotificationLevelPreservesRegionScope() { + let dto = makeDTO(regionScope: "UK") + let updated = dto.with(notificationLevel: .muted) + + #expect(updated.regionScope == "UK") + #expect(updated.notificationLevel == .muted) + } + + @Test("with(notificationLevel:) preserves nil regionScope") + func withNotificationLevelPreservesNilRegionScope() { + let dto = makeDTO() + let updated = dto.with(notificationLevel: .mentionsOnly) + + #expect(updated.regionScope == nil) + } + + // MARK: - with(isFavorite:) + + @Test("with(isFavorite:) preserves regionScope") + func withIsFavoritePreservesRegionScope() { + let dto = makeDTO(regionScope: "France") + let updated = dto.with(isFavorite: true) + + #expect(updated.regionScope == "France") + #expect(updated.isFavorite == true) + } + + @Test("with(isFavorite:) preserves nil regionScope") + func withIsFavoritePreservesNilRegionScope() { + let dto = makeDTO() + let updated = dto.with(isFavorite: true) + + #expect(updated.regionScope == nil) + } + + // MARK: - with(regionScope:) + + @Test("with(regionScope:) updates the value") + func withRegionScopeUpdates() { + let dto = makeDTO(regionScope: "Europe") + let updated = dto.with(regionScope: "UK") + + #expect(updated.regionScope == "UK") + } + + @Test("with(regionScope: nil) clears the value") + func withRegionScopeClears() { + let dto = makeDTO(regionScope: "Europe") + let updated = dto.with(regionScope: nil) + + #expect(updated.regionScope == nil) + } + + @Test("with(regionScope:) sets value from nil") + func withRegionScopeSetsFromNil() { + let dto = makeDTO() + let updated = dto.with(regionScope: "Asia") + + #expect(updated.regionScope == "Asia") + } + + @Test("with(regionScope:) preserves all other fields") + func withRegionScopePreservesOtherFields() { + let dto = makeDTO(regionScope: "Europe") + let updated = dto.with(regionScope: "UK") + + #expect(updated.id == dto.id) + #expect(updated.deviceID == dto.deviceID) + #expect(updated.index == dto.index) + #expect(updated.name == dto.name) + #expect(updated.secret == dto.secret) + #expect(updated.isEnabled == dto.isEnabled) + #expect(updated.lastMessageDate == dto.lastMessageDate) + #expect(updated.unreadCount == dto.unreadCount) + #expect(updated.unreadMentionCount == dto.unreadMentionCount) + #expect(updated.notificationLevel == dto.notificationLevel) + #expect(updated.isFavorite == dto.isFavorite) + } +} diff --git a/MC1Tests/Views/Chats/RegionNameValidatorTests.swift b/MC1Tests/Views/Chats/RegionNameValidatorTests.swift new file mode 100644 index 000000000..b5f9874fa --- /dev/null +++ b/MC1Tests/Views/Chats/RegionNameValidatorTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import MC1 + +@Suite("RegionNameValidator") +struct RegionNameValidatorTests { + + // MARK: - Valid Names + + @Test("accepts standard region names", arguments: [ + "Europe", "UK", "France", "sample-city", "region-1" + ]) + func validNames(name: String) { + #expect(RegionNameValidator.isValid(name, existingRegions: [])) + } + + @Test("accepts unicode region names") + func unicodeRegionName() { + #expect(RegionNameValidator.isValid("Île-de-France", existingRegions: [])) + } + + // MARK: - Invalid Names + + @Test("rejects empty name") + func emptyNameIsInvalid() { + #expect(RegionNameValidator.validate("", existingRegions: []) == .empty) + } + + @Test("rejects whitespace-only name") + func whitespaceOnlyIsInvalid() { + #expect(RegionNameValidator.validate(" ", existingRegions: []) == .empty) + } + + @Test("rejects name with spaces") + func spacesInNameAreInvalid() { + #expect(RegionNameValidator.validate("my region", existingRegions: []) == .invalidCharacters) + } + + @Test("rejects hash prefix") + func hashPrefixIsInvalid() { + #expect(RegionNameValidator.validate("#Europe", existingRegions: []) == .invalidPrefix) + } + + @Test("rejects dollar prefix") + func dollarPrefixIsInvalid() { + #expect(RegionNameValidator.validate("$secret", existingRegions: []) == .invalidPrefix) + } + + // MARK: - Duplicates + + @Test("rejects duplicate region name") + func duplicateIsInvalid() { + #expect(RegionNameValidator.validate("Europe", existingRegions: ["Europe"]) == .duplicate) + } + + @Test("duplicate check is case-sensitive") + func caseSensitiveDuplicateCheck() { + #expect(RegionNameValidator.isValid("europe", existingRegions: ["Europe"])) + } + + // MARK: - isValid convenience + + @Test("isValid returns true for valid name") + func isValidReturnsTrue() { + #expect(RegionNameValidator.isValid("Europe", existingRegions: [])) + } + + @Test("isValid returns false for invalid name") + func isValidReturnsFalse() { + #expect(!RegionNameValidator.isValid("", existingRegions: [])) + } +} diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 244533863..c6a888049 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -1632,6 +1632,8 @@ enum RegionsParser { } let trimmed = regionString.trimmingCharacters(in: .controlCharacters) if trimmed.isEmpty { return [] } - return trimmed.split(separator: ",").map(String.init) + return trimmed.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != "*" } } } diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/FloodScopeMappingTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/FloodScopeMappingTests.swift new file mode 100644 index 000000000..71f4c704b --- /dev/null +++ b/MeshCore/Tests/MeshCoreTests/Protocol/FloodScopeMappingTests.swift @@ -0,0 +1,39 @@ +import Testing +import Foundation +@testable import MeshCore + +@Suite("Region scope to FloodScope mapping") +struct FloodScopeMappingTests { + + @Test("nil regionScope maps to disabled") + func nilScopeMapsToDisabled() { + let regionScope: String? = nil + let floodScope: FloodScope = regionScope.map { .region($0) } ?? .disabled + + #expect(floodScope.scopeKey() == FloodScope.disabled.scopeKey()) + } + + @Test("non-nil regionScope maps to region") + func regionScopeMapsToRegion() { + let regionScope: String? = "Europe" + let floodScope: FloodScope = regionScope.map { .region($0) } ?? .disabled + + #expect(floodScope.scopeKey() == FloodScope.region("Europe").scopeKey()) + } + + @Test("disabled scope key differs from any region scope key") + func disabledDiffersFromRegion() { + let disabled = FloodScope.disabled.scopeKey() + let region = FloodScope.region("Europe").scopeKey() + + #expect(disabled != region) + } + + @Test("different region names produce different scope keys") + func differentRegionsProduceDifferentKeys() { + let europe = FloodScope.region("Europe").scopeKey() + let uk = FloodScope.region("UK").scopeKey() + + #expect(europe != uk) + } +} diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift index b40e4dcd3..8a295a975 100644 --- a/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift @@ -156,6 +156,34 @@ struct RegionsParserTests { _ = try RegionsParser.parse(data) } } + + @Test("filters out wildcard region") + func filtersWildcard() throws { + let result = try RegionsParser.parse(makeResponse("*,Europe,UK")) + + #expect(result == ["Europe", "UK"]) + } + + @Test("filters out wildcard-only response to empty array") + func filtersWildcardOnly() throws { + let result = try RegionsParser.parse(makeResponse("*")) + + #expect(result == []) + } + + @Test("filters out whitespace-only entries") + func filtersWhitespace() throws { + let result = try RegionsParser.parse(makeResponse("Europe, ,UK")) + + #expect(result == ["Europe", "UK"]) + } + + @Test("trims whitespace around region names") + func trimsWhitespace() throws { + let result = try RegionsParser.parse(makeResponse(" Europe , UK ")) + + #expect(result == ["Europe", "UK"]) + } } // MARK: - requestRegions Integration From bc2ab848c599a1e5dd2b733c635698f9512aa503 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:12:00 -0700 Subject: [PATCH 10/48] fix(regions): restore flood routing if transport.send throws - Move subscribe() and send() inside do/catch that calls resetPath - Prevents firmware contact being left in zero-hop state on transport failure --- MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 73a2ad5b3..bcc17a559 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -2344,12 +2344,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { logger.info("Regions request to \(prefixHex): sending") - // Subscribe BEFORE sending to avoid race condition - let events = await dispatcher.subscribe() - try await transport.send(data) - let result: [String] do { + // Subscribe before sending to avoid race condition + let events = await dispatcher.subscribe() + try await transport.send(data) + result = try await withThrowingTaskGroup(of: [String]?.self) { group in let (timeoutStream, timeoutContinuation) = AsyncStream.makeStream() From 3abf8df151d118c0bc6d6ca7b9f5a480db1c94ec Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:19:44 -0700 Subject: [PATCH 11/48] ui(ble-menu): move Advanced Settings below battery in radio menu --- MC1/Views/Components/BLEStatusIndicatorView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index 3acdbcc84..3cb2132a4 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -236,6 +236,12 @@ private struct ConnectedMenu: View { .foregroundStyle(.secondary) } } + + Button { + onAdvancedSettings() + } label: { + Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") + } } } @@ -258,12 +264,6 @@ private struct ConnectedMenu: View { } Section { - Button { - onAdvancedSettings() - } label: { - Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") - } - Button { onChangeDevice() } label: { From 7f7a0042b7b99db7dc86ece034d6929996efb935 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:26:32 -0700 Subject: [PATCH 12/48] fix(tests): align lastHeard sort test with lastModified sort key --- MC1Tests/ViewModels/ContactsViewModelTests.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/MC1Tests/ViewModels/ContactsViewModelTests.swift b/MC1Tests/ViewModels/ContactsViewModelTests.swift index 10d3d94d6..3a3526dfa 100644 --- a/MC1Tests/ViewModels/ContactsViewModelTests.swift +++ b/MC1Tests/ViewModels/ContactsViewModelTests.swift @@ -14,7 +14,8 @@ private func createContact( isBlocked: Bool = false, lastAdvertTimestamp: UInt32 = 0, latitude: Double = 0, - longitude: Double = 0 + longitude: Double = 0, + lastModified: UInt32 = 0 ) -> ContactDTO { ContactDTO( id: UUID(), @@ -28,7 +29,7 @@ private func createContact( lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, - lastModified: 0, + lastModified: lastModified, nickname: nil, isBlocked: isBlocked, isMuted: false, @@ -236,9 +237,9 @@ struct ContactsViewModelTests { let viewModel = ContactsViewModel() let deviceID = UUID() viewModel.contacts = [ - createContact(deviceID: deviceID, name: "Old", type: .chat, lastAdvertTimestamp: 100), - createContact(deviceID: deviceID, name: "Recent", type: .chat, lastAdvertTimestamp: 300), - createContact(deviceID: deviceID, name: "Middle", type: .chat, lastAdvertTimestamp: 200) + createContact(deviceID: deviceID, name: "Old", type: .chat, lastModified: 100), + createContact(deviceID: deviceID, name: "Recent", type: .chat, lastModified: 300), + createContact(deviceID: deviceID, name: "Middle", type: .chat, lastModified: 200) ] let result = viewModel.filteredContacts( From 9a68ecfccfb7b79f86146dfe858ec1678784c9e4 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:28:27 -0700 Subject: [PATCH 13/48] l10n(contacts): rename sort option from Last Heard to Last Modified --- MC1/Resources/Generated/L10n.swift | 2 +- MC1/Resources/Localization/de.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/en.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/es.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/fr.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/nl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/pl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/ru.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/uk.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index c68787206..0272aa4c9 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1646,7 +1646,7 @@ public enum L10n { /// Location: ContactsViewModel.swift - Purpose: Distance sort option public static let distance = L10n.tr("Contacts", "contacts.sort.distance", fallback: "Distance") /// Location: ContactsViewModel.swift - Purpose: Last heard sort option - public static let lastHeard = L10n.tr("Contacts", "contacts.sort.lastHeard", fallback: "Last Heard") + public static let lastHeard = L10n.tr("Contacts", "contacts.sort.lastHeard", fallback: "Last Modified") /// Location: ContactsViewModel.swift - Purpose: Name sort option public static let name = L10n.tr("Contacts", "contacts.sort.name", fallback: "Name") } diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 13ae26a3e..60821a1b0 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Zuletzt gehört"; +"contacts.sort.lastHeard" = "Zuletzt geändert"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Name"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 4a83383ba..d77ab3985 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Last Heard"; +"contacts.sort.lastHeard" = "Last Modified"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Name"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index ca46800c0..574d5dcaa 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Última vez escuchado"; +"contacts.sort.lastHeard" = "Última modificación"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Nombre"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index a7dd3174b..db112bbbb 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Dernier contact"; +"contacts.sort.lastHeard" = "Dernière modification"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Nom"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 65cfada0c..55df92d88 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Laatst gehoord"; +"contacts.sort.lastHeard" = "Laatst gewijzigd"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Naam"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index f35662d66..900bf5e0c 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Ostatnio widziany"; +"contacts.sort.lastHeard" = "Ostatnio zmodyfikowany"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Nazwa"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 4fb6661d3..d8b89b90d 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Последняя активность"; +"contacts.sort.lastHeard" = "Последнее изменение"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Имя"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index a3dae6185..6c916d31c 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Останній зв'язок"; +"contacts.sort.lastHeard" = "Остання зміна"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Ім'я"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index bc2d8caa8..39250cb35 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -77,7 +77,7 @@ // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "最后听到"; +"contacts.sort.lastHeard" = "最后修改"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "名称"; From b4ee628ecc719a8ce9fd04add74d2bc4fc61a425 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:30:01 -0700 Subject: [PATCH 14/48] fix(chats): force TextField re-creation on send to clear reliably - Track textFieldID state and apply .id() to ChatInputTextField - Rotate textFieldID on send to destroy and recreate the UIKit-backed text field, flushing any pending autocorrect, inline prediction, or IME composition state that could race against text = "" - Assert isFocused = true in the same synchronous block so the keyboard stays visible after re-creation --- MC1/Views/Chats/Components/ChatInputBar.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index 438b7f33b..dde553cec 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -12,6 +12,7 @@ struct ChatInputBar: View { let onSend: (String) -> Void @State private var isCoolingDown = false + @State private var textFieldID = UUID() private var byteCount: Int { text.utf8.count @@ -29,6 +30,7 @@ struct ChatInputBar: View { var body: some View { HStack(alignment: .bottom, spacing: 12) { ChatInputTextField(text: $text, placeholder: placeholder, isFocused: $isFocused, isEncrypted: isEncrypted) + .id(textFieldID) ChatSendButtonWithCounter( canSend: canSend, isOverLimit: isOverLimit, @@ -76,6 +78,8 @@ struct ChatInputBar: View { guard !captured.isEmpty else { return } isCoolingDown = true text = "" + textFieldID = UUID() + isFocused = true onSend(captured) Task { try? await Task.sleep(for: .seconds(1)) From a976a777e6ce2266f9bd79667fdfa99b7abfcf7d Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 25 Mar 2026 11:21:20 -0500 Subject: [PATCH 15/48] fix(meshcore): decode room server status layout --- .../Services/BinaryProtocolService.swift | 18 +++- .../Services/RemoteNodeService.swift | 6 +- MeshCore/README.md | 3 + .../Sources/MeshCore/Events/MeshEvent.swift | 22 ++++- .../MeshCore.docc/Articles/BinaryProtocol.md | 5 +- .../Sources/MeshCore/Protocol/Parsers.swift | 93 +++++++++++++------ .../MeshCore/Session/MeshCoreSession.swift | 54 +++++++++-- ...shCoreSessionCommandCorrelationTests.swift | 70 ++++++++++++++ .../Validation/ProtocolBugFixTests.swift | 49 ++++++++++ 9 files changed, 282 insertions(+), 38 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift index 6f33037f4..3b11d9507 100644 --- a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift +++ b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift @@ -119,7 +119,7 @@ public actor BinaryProtocolService { // MARK: - Status Request /// Request status from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's public key (full or prefix) + /// - Parameter publicKey: The remote node's full 32-byte public key /// - Returns: StatusResponse with device stats public func requestStatus(from publicKey: Data) async throws -> StatusResponse { do { @@ -129,6 +129,22 @@ public actor BinaryProtocolService { } } + /// Request status from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's full 32-byte public key + /// - type: The target node type used to select the correct firmware status layout + /// - Returns: StatusResponse with device stats + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey, type: type) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) + } + } + // MARK: - Telemetry Request /// Request telemetry from a remote node (blocking, waits for response) diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 87e5b70ee..e9148c7e5 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -727,7 +727,8 @@ public actor RemoteNodeService { // Request status (which triggers sync) do { - _ = try await session.requestStatus(from: remoteSession.publicKey) + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) } catch let error as MeshCoreError { throw RemoteNodeError.sessionError(error) } @@ -781,7 +782,8 @@ public actor RemoteNodeService { do { let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { - try await self.session.requestStatus(from: remoteSession.publicKey) + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) } } catch is TimeoutError { throw RemoteNodeError.timeout diff --git a/MeshCore/README.md b/MeshCore/README.md index 73eab7b2f..04c75b1e8 100644 --- a/MeshCore/README.md +++ b/MeshCore/README.md @@ -172,6 +172,9 @@ for await state in await session.connectionState { let status = try await session.requestStatus(from: publicKey) print("Remote battery: \(status.battery) mV, uptime: \(status.uptime)s") +// For room servers, use a typed request so the correct status layout is decoded. +let roomStatus = try await session.requestStatus(from: roomContact) + // Request telemetry let telemetry = try await session.requestTelemetry(from: publicKey) diff --git a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift index 9515d04cf..6dd285e00 100644 --- a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift +++ b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift @@ -508,6 +508,16 @@ public struct ChannelInfo: Sendable, Equatable { /// - Push notification responses: offset=8, pubkey_prefix at bytes 2-8, fields follow /// The parser must handle both cases based on whether this is a solicited vs unsolicited response public struct StatusResponse: Sendable, Equatable { + /// Describes which firmware status layout was used to decode the payload. + public enum Layout: Sendable, Equatable { + /// Standard repeater / legacy status layout. + case repeater + /// Room server layout used by room-server firmware. + case roomServer + } + + /// The decoded status layout. + public let layout: Layout /// The public key prefix of the responding node. public let publicKeyPrefix: Data /// The battery level in millivolts. @@ -546,9 +556,14 @@ public struct StatusResponse: Sendable, Equatable { public let rxAirtime: UInt32 /// Total receive errors (v1.12+, 0 for older firmware). public let receiveErrors: UInt32 + /// Total messages posted to the room server. + public let roomServerPostedCount: UInt16? + /// Total room-server post push attempts. + public let roomServerPostPushCount: UInt16? /// Initializes a new status response object. public init( + layout: Layout = .repeater, publicKeyPrefix: Data, battery: Int, txQueueLength: Int, @@ -567,8 +582,11 @@ public struct StatusResponse: Sendable, Equatable { directDuplicates: Int, floodDuplicates: Int, rxAirtime: UInt32, - receiveErrors: UInt32 = 0 + receiveErrors: UInt32 = 0, + roomServerPostedCount: UInt16? = nil, + roomServerPostPushCount: UInt16? = nil ) { + self.layout = layout self.publicKeyPrefix = publicKeyPrefix self.battery = battery self.txQueueLength = txQueueLength @@ -588,6 +606,8 @@ public struct StatusResponse: Sendable, Equatable { self.floodDuplicates = floodDuplicates self.rxAirtime = rxAirtime self.receiveErrors = receiveErrors + self.roomServerPostedCount = roomServerPostedCount + self.roomServerPostPushCount = roomServerPostPushCount } } diff --git a/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md b/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md index 545e913e1..77c263d7b 100644 --- a/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md +++ b/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md @@ -11,7 +11,7 @@ The binary protocol provides efficient data transfer for complex requests like s Query a remote node's status: ```swift -let status = try await session.requestStatus(from: contact.publicKey) +let status = try await session.requestStatus(from: contact) print("Battery: \(status.battery)mV") print("Uptime: \(status.uptime)s") @@ -124,3 +124,6 @@ do { print("Unexpected response: expected \(expected), got \(got)") } ``` + +When querying a room server, use ``requestStatus(from: MeshContact)`` or +``requestStatus(from:type:)`` so the session can select the room-server status layout. diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 65b5f6ff5..25cecdd7a 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -712,7 +712,11 @@ public enum Parsers { /// - data: Raw binary response payload (without the 4-byte tag). /// - publicKeyPrefix: The 6-byte public key prefix from the pending request context. /// - Returns: A `StatusResponse` if parsing succeeds, `nil` otherwise. - static func parseFromBinaryResponse(_ data: Data, publicKeyPrefix: Data) -> MeshCore.StatusResponse? { + static func parseFromBinaryResponse( + _ data: Data, + publicKeyPrefix: Data, + layout: MeshCore.StatusResponse.Layout = .repeater + ) -> MeshCore.StatusResponse? { // Accept exactly 48 (no rxAirtime), 52 (with rxAirtime), or 56+ (with receiveErrors). // Reject malformed payloads with incomplete fields (49-51, 53-55). guard data.count == PacketSize.binaryResponseStatusBase || @@ -736,33 +740,68 @@ public enum Parsers { let lastSNR = Double(data.readInt16LE(at: offset)) / 4.0; offset += 2 let directDups = Int(data.readUInt16LE(at: offset)); offset += 2 let floodDups = Int(data.readUInt16LE(at: offset)); offset += 2 - let rxAirtime: UInt32 = data.count >= PacketSize.binaryResponseStatusWithRxAirtime - ? data.readUInt32LE(at: offset) : 0 - offset += 4 - let receiveErrors: UInt32 = data.count >= PacketSize.binaryResponseStatusWithReceiveErrors - ? data.readUInt32LE(at: offset) : 0 + switch layout { + case .repeater: + let rxAirtime: UInt32 = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt32LE(at: offset) : 0 + offset += 4 + let receiveErrors: UInt32 = data.count >= PacketSize.binaryResponseStatusWithReceiveErrors + ? data.readUInt32LE(at: offset) : 0 + + return MeshCore.StatusResponse( + layout: .repeater, + publicKeyPrefix: publicKeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: rxAirtime, + receiveErrors: receiveErrors + ) - return MeshCore.StatusResponse( - publicKeyPrefix: publicKeyPrefix, - battery: battery, - txQueueLength: txQueueLen, - noiseFloor: noiseFloor, - lastRSSI: lastRSSI, - packetsReceived: packetsRecv, - packetsSent: packetsSent, - airtime: airtime, - uptime: uptime, - sentFlood: sentFlood, - sentDirect: sentDirect, - receivedFlood: recvFlood, - receivedDirect: recvDirect, - fullEvents: fullEvents, - lastSNR: lastSNR, - directDuplicates: directDups, - floodDuplicates: floodDups, - rxAirtime: rxAirtime, - receiveErrors: receiveErrors - ) + case .roomServer: + let postedCount: UInt16? = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt16LE(at: offset) : nil + let postPushCount: UInt16? = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt16LE(at: offset + 2) : nil + + return MeshCore.StatusResponse( + layout: .roomServer, + publicKeyPrefix: publicKeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: 0, + receiveErrors: 0, + roomServerPostedCount: postedCount, + roomServerPostPushCount: postPushCount + ) + } } } diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 7d142abd3..7eaed6576 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -883,6 +883,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Requests status information from a remote node using the binary protocol. /// + /// Raw public-key status requests use the repeater status layout. + /// For room servers, prefer ``requestStatus(from: MeshContact)`` or + /// ``requestStatus(from:type:)`` so the correct status layout is selected. + /// /// - Parameter publicKey: The full 32-byte public key of the remote node. /// - Returns: A status response containing battery, uptime, and other metrics. /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. @@ -890,14 +894,46 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. public func requestStatus(from publicKey: Data) async throws -> StatusResponse { try requireFullPublicKey(publicKey, operation: "requestStatus") - // Serialize binary requests to prevent messageSent race conditions return try await binaryRequestSerializer.withSerialization { [self] in - try await performStatusRequest(from: publicKey) + try await performStatusRequest(from: publicKey, layout: .repeater) } } + /// Requests status information from a remote node using the binary protocol. + /// + /// - Parameters: + /// - publicKey: The full 32-byte public key of the remote node. + /// - type: The target node type used to choose the correct firmware status layout. + /// - Returns: A status response containing battery, uptime, and other metrics. + /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. + /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + try requireFullPublicKey(publicKey, operation: "requestStatus") + let layout: StatusResponse.Layout = type == .room ? .roomServer : .repeater + return try await binaryRequestSerializer.withSerialization { [self] in + try await performStatusRequest(from: publicKey, layout: layout) + } + } + + /// Requests status information from a remote contact using its contact type to + /// select the correct firmware status layout. + /// + /// - Parameter contact: The remote contact to query. + /// - Returns: A status response containing battery, uptime, and other metrics. + /// - Throws: ``MeshCoreError`` if the request fails. + public func requestStatus(from contact: MeshContact) async throws -> StatusResponse { + try await requestStatus(from: contact.publicKey, type: contact.type) + } + /// Internal implementation of status request, called within serialization. - private func performStatusRequest(from publicKey: Data) async throws -> StatusResponse { + private func performStatusRequest( + from publicKey: Data, + layout: StatusResponse.Layout + ) async throws -> StatusResponse { let data = PacketBuilder.binaryRequest(to: publicKey, type: .status) let publicKeyPrefix = Data(publicKey.prefix(6)) let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined() @@ -942,7 +978,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { guard let response = Parsers.StatusResponse.parseFromBinaryResponse( responseData, - publicKeyPrefix: publicKeyPrefix + publicKeyPrefix: publicKeyPrefix, + layout: layout ) else { return nil } @@ -996,8 +1033,13 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: Status response from the remote node. /// - Throws: ``MeshCoreError`` on failure. public func requestStatus(from destination: Destination) async throws -> StatusResponse { - let publicKey = try destination.fullPublicKey() - return try await requestStatus(from: publicKey) + switch destination { + case .contact(let contact): + return try await requestStatus(from: contact) + case .data, .hexString: + let publicKey = try destination.fullPublicKey() + return try await requestStatus(from: publicKey) + } } // MARK: - Keep-Alive diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift index 61aebe77d..31711793a 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -320,6 +320,45 @@ struct MeshCoreSessionCommandCorrelationTests { await session.stop() } + @Test("requestStatus uses room layout for typed room targets") + func requestStatusUsesRoomLayoutForTypedRoomTargets() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let target = Data(repeating: 0x31, count: 32) + let expectedAck = Data([0xAA, 0xBB, 0xCC, 0xDD]) + + let statusTask = Task { + try await session.requestStatus(from: target, type: .room) + } + + try await waitUntil("requestStatus should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeMessageSentPacket(expectedAck: expectedAck)) + await transport.simulateReceive( + makeBinaryStatusResponsePacket( + tag: expectedAck, + battery: 1000, + roomServerPostedCount: 17, + roomServerPostPushCount: 9 + ) + ) + + let status = try await statusTask.value + #expect(status.battery == 1000) + #expect(status.roomServerPostedCount == 17) + #expect(status.roomServerPostPushCount == 9) + #expect(status.rxAirtime == 0) + await session.stop() + } + @Test("requestTelemetry fails fast on device error before messageSent") func requestTelemetryFailsFastOnDeviceErrorBeforeMessageSent() async throws { let transport = MockTransport() @@ -456,6 +495,18 @@ private func makeBatteryPacket(level: UInt16) -> Data { return packet } +private func makeMessageSentPacket( + type: UInt8 = 0, + expectedAck: Data, + timeoutMs: UInt32 = 5000 +) -> Data { + var packet = Data([ResponseCode.messageSent.rawValue]) + packet.append(type) + packet.append(expectedAck) + packet.append(contentsOf: withUnsafeBytes(of: timeoutMs.littleEndian) { Array($0) }) + return packet +} + private func makeTelemetryPacket(publicKeyPrefix: Data, lppPayload: Data) -> Data { var packet = Data([ResponseCode.telemetryResponse.rawValue]) packet.append(0x00) @@ -487,6 +538,25 @@ private func makeStatusResponsePacket(publicKeyPrefix: Data, battery: UInt16) -> return packet } +private func makeBinaryStatusResponsePacket( + tag: Data, + battery: UInt16, + roomServerPostedCount: UInt16, + roomServerPostPushCount: UInt16 +) -> Data { + var packet = Data([ResponseCode.binaryResponse.rawValue]) + packet.append(0x00) + packet.append(tag) + + var payload = Data(repeating: 0, count: 52) + payload.replaceSubrange(0..<2, with: withUnsafeBytes(of: battery.littleEndian) { Array($0) }) + payload.replaceSubrange(48..<50, with: withUnsafeBytes(of: roomServerPostedCount.littleEndian) { Array($0) }) + payload.replaceSubrange(50..<52, with: withUnsafeBytes(of: roomServerPostPushCount.littleEndian) { Array($0) }) + + packet.append(payload) + return packet +} + private func makeChannelInfoPacket(index: UInt8, name: String, secret: Data) -> Data { var packet = Data([ResponseCode.channelInfo.rawValue, index]) let nameBytes = Array(name.utf8.prefix(31)) diff --git a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift index 79cf1b453..e21a99ab0 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift @@ -254,6 +254,55 @@ struct ProtocolBugFixTests { #expect(status.receiveErrors == 0, "receiveErrors should default to 0 for 52-byte payload") } + @Test("statusResponse parseFromBinaryResponse room server 52 bytes parses room counters") + func statusResponseParseFromBinaryResponseRoomServer52BytesParsesRoomCounters() { + var payload = Data(repeating: 0, count: 52) + payload[0] = 0x3C + payload[1] = 0x0F // Battery: 3900mV + payload[2] = 0x02 + payload[3] = 0x00 // txQueueLength: 2 + payload[4] = 0x8D + payload[5] = 0xFF // noiseFloor: -115 + payload[6] = 0xA9 + payload[7] = 0xFF // lastRSSI: -87 + payload[8] = 0x78 + payload[12] = 0x2D + payload[16] = 0x10 + payload[17] = 0x0E // airtime: 3600 + payload[20] = 0x20 + payload[21] = 0x1C // uptime: 7200 + payload[24] = 0x0C + payload[28] = 0x08 + payload[32] = 0x0E + payload[36] = 0x0A + payload[40] = 0x03 + payload[42] = 0x18 + payload[44] = 0x01 + payload[46] = 0x02 + payload[48] = 0x11 + payload[49] = 0x00 // roomServerPostedCount: 17 + payload[50] = 0x09 + payload[51] = 0x00 // roomServerPostPushCount: 9 + + let pubkeyPrefix = Data([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]) + let status = Parsers.StatusResponse.parseFromBinaryResponse( + payload, + publicKeyPrefix: pubkeyPrefix, + layout: .roomServer + ) + + guard let status = status else { + Issue.record("Should parse 52-byte room server payload") + return + } + + #expect(status.layout == .roomServer) + #expect(status.roomServerPostedCount == 17) + #expect(status.roomServerPostPushCount == 9) + #expect(status.rxAirtime == 0) + #expect(status.receiveErrors == 0) + } + @Test("statusResponse parseFromBinaryResponse 56 bytes parses receiveErrors") func statusResponseParseFromBinaryResponse56BytesParsesReceiveErrors() { // 56 bytes: has rxAirtime and receiveErrors (v1.12+) From 4751e77935c7475a199158e3ebeb105f76b08095 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 25 Mar 2026 11:21:26 -0500 Subject: [PATCH 16/48] fix(meshcore): handle disabled export private key --- .../MeshCore/Session/MeshCoreSession.swift | 15 +++-- ...shCoreSessionCommandCorrelationTests.swift | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 7d142abd3..8593ecaf1 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -1331,12 +1331,19 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// This is a sensitive operation that exposes the device's cryptographic identity. /// The exported key can be imported into another device to clone its identity. /// - /// - Returns: The 32-byte private key, or `nil` if export is disabled. - /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. + /// - Returns: The 32-byte private key. + /// - Throws: ``MeshCoreError/featureDisabled`` if private key export is disabled on the device, + /// or ``MeshCoreError/timeout`` if the device doesn't respond. public func exportPrivateKey() async throws -> Data { - try await sendAndWait(PacketBuilder.exportPrivateKey()) { event in + try await sendAndWaitWithError( + PacketBuilder.exportPrivateKey() + ) { event in if case .privateKey(let key) = event { return key } - if case .disabled = event { return nil } + return nil + } errorMatcher: { event in + if case .disabled = event { + return MeshCoreError.featureDisabled + } return nil } } diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift index 61aebe77d..0a47eb9b2 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -287,6 +287,64 @@ struct MeshCoreSessionCommandCorrelationTests { await session.stop() } + @Test("exportPrivateKey throws featureDisabled on disabled response") + func exportPrivateKeyThrowsFeatureDisabledOnDisabledResponse() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let exportTask = Task { + try await session.exportPrivateKey() + } + + try await waitUntil("exportPrivateKey should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(Data([ResponseCode.disabled.rawValue])) + + let error = await #expect(throws: MeshCoreError.self) { + try await exportTask.value + } + guard case .featureDisabled? = error else { + Issue.record("Expected featureDisabled, got \(String(describing: error))") + await session.stop() + return + } + + await session.stop() + } + + @Test("disabled responses do not break unrelated requests") + func disabledResponsesDoNotBreakUnrelatedRequests() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let batteryTask = Task { + try await session.getBattery() + } + + try await waitUntil("getBattery should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(Data([ResponseCode.disabled.rawValue])) + await transport.simulateReceive(makeBatteryPacket(level: 4018)) + + let battery = try await batteryTask.value + #expect(battery.level == 4018) + await session.stop() + } + @Test("requestStatus fails fast on device error before messageSent") func requestStatusFailsFastOnDeviceErrorBeforeMessageSent() async throws { let transport = MockTransport() From bd8c38eae0d058e2c3bfe3cb31075075d2d7bfc5 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:43:23 -0700 Subject: [PATCH 17/48] fix(repeater): prioritize query hint over deviceTime heuristic in CLIResponse.parse - Contact info and repeater names containing ":" and "/" were misclassified as deviceTime, silently discarding the GET response - Move freeform text query-hint checks before the broad deviceTime heuristic - Add regression tests for the collision case --- .../Sources/MC1Services/MC1Services.swift | 21 ++++++++++--------- MC1Tests/Protocol/CLIResponseTests.swift | 17 +++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/MC1Services/Sources/MC1Services/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index bd6f78955..1c0ed0cb0 100644 --- a/MC1Services/Sources/MC1Services/MC1Services.swift +++ b/MC1Services/Sources/MC1Services/MC1Services.swift @@ -194,6 +194,17 @@ public enum CLIResponse: Sendable, Equatable { return .version(trimmed) } + // Freeform text fields: query hint takes priority over broad content heuristics + // below (e.g. deviceTime matches any string with ":" and "/", which is common + // in repeater names and contact info like "Contact: KD7ABC / 145.230") + if query == "get name" { + return .name(trimmed) + } + + if query == "get owner.info" { + return .ownerInfo(trimmed) + } + // Clock response: "06:40 - 18/4/2025 UTC" or contains time-like patterns if trimmed.contains("UTC") || (trimmed.contains(":") && trimmed.contains("/")) { return .deviceTime(trimmed) @@ -241,16 +252,6 @@ public enum CLIResponse: Sendable, Equatable { return .floodMax(maxHops) } - // Name is plain text - use query hint - if query == "get name" { - return .name(trimmed) - } - - // Owner info: freeform text with pipe-delimited lines - if query == "get owner.info" { - return .ownerInfo(trimmed) - } - // Latitude: decimal degrees if query == "get lat", let lat = Double(trimmed) { return .latitude(lat) diff --git a/MC1Tests/Protocol/CLIResponseTests.swift b/MC1Tests/Protocol/CLIResponseTests.swift index 2b3c42c33..b094916c4 100644 --- a/MC1Tests/Protocol/CLIResponseTests.swift +++ b/MC1Tests/Protocol/CLIResponseTests.swift @@ -254,6 +254,23 @@ struct CLIResponseTests { #expect(result == .ownerInfo("just a name")) } + @Test func parse_ownerInfo_withColonAndSlash() { + // Contact info with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Contact: KD7ABC / 145.230", forQuery: "get owner.info") + #expect(result == .ownerInfo("Contact: KD7ABC / 145.230")) + } + + @Test func parse_name_withColonAndSlash() { + // Name with ":" and "/" was misclassified as deviceTime + let result = CLIResponse.parse("> Repeater: East / West", forQuery: "get name") + #expect(result == .name("Repeater: East / West")) + } + + @Test func parse_deviceTime_stillWorks_withoutQueryHint() { + let result = CLIResponse.parse("06:40 - 18/4/2025 UTC") + #expect(result == .deviceTime("06:40 - 18/4/2025 UTC")) + } + // MARK: - Edge Cases @Test func parse_greaterThanInContent_notStripped() { From 0457d42363d53b1f35bb1b6426b81db704edb8b2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:12:49 -0700 Subject: [PATCH 18/48] feat(repeater): improve telemetry history views - Persist receiveErrors in NodeStatusSnapshot and display as chart in both logged-in and offline history views - Resolve neighbor names from contacts and discovered nodes in both live and offline history views (previously only hex prefixes offline) - Rename "Saved History" to "Telemetry History" across all locales - Style Telemetry History button with .tint to match adjacent buttons --- MC1/Resources/Generated/L10n.swift | 6 +- .../Localization/de.lproj/Contacts.strings | 2 +- .../Localization/de.lproj/RemoteNodes.strings | 5 +- .../Localization/en.lproj/Contacts.strings | 2 +- .../Localization/en.lproj/RemoteNodes.strings | 5 +- .../Localization/es.lproj/Contacts.strings | 2 +- .../Localization/es.lproj/RemoteNodes.strings | 5 +- .../Localization/fr.lproj/Contacts.strings | 2 +- .../Localization/fr.lproj/RemoteNodes.strings | 5 +- .../Localization/nl.lproj/Contacts.strings | 2 +- .../Localization/nl.lproj/RemoteNodes.strings | 5 +- .../Localization/pl.lproj/Contacts.strings | 2 +- .../Localization/pl.lproj/RemoteNodes.strings | 5 +- .../Localization/ru.lproj/Contacts.strings | 2 +- .../Localization/ru.lproj/RemoteNodes.strings | 5 +- .../Localization/uk.lproj/Contacts.strings | 2 +- .../Localization/uk.lproj/RemoteNodes.strings | 5 +- .../zh-Hans.lproj/Contacts.strings | 2 +- .../zh-Hans.lproj/RemoteNodes.strings | 5 +- MC1/Views/Contacts/ContactDetailView.swift | 3 +- .../RemoteNodes/NodeStatusHistoryView.swift | 7 +++ .../RemoteNodes/RepeaterStatusView.swift | 15 ++++- .../RemoteNodes/RepeaterStatusViewModel.swift | 3 +- .../TelemetryHistoryOverviewView.swift | 14 ++++- .../TelemetryHistoryOverviewViewModel.swift | 15 +++++ .../Models/NodeStatusSnapshot.swift | 9 ++- .../Protocols/PersistenceStoreProtocol.swift | 3 +- .../Services/NodeSnapshotService.swift | 6 +- .../PersistenceStore+Diagnostics.swift | 15 +++-- .../Mocks/MockPersistenceStore.swift | 6 +- .../NodeSnapshotServiceTests.swift | 57 ++++++++++++------- MC1Tests/Services/LinkPreviewCacheTests.swift | 2 +- .../ChatViewModelPaginationTests.swift | 2 +- .../LineOfSightViewModelTests.swift | 2 +- ...lemetryHistoryOverviewViewModelTests.swift | 18 +++--- 35 files changed, 177 insertions(+), 69 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 0272aa4c9..cc83d19c3 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1112,7 +1112,7 @@ public enum L10n { return L10n.tr("Contacts", "contacts.detail.routePrefix", String(describing: p1), fallback: "Route: %@") } /// Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry - public static let savedHistory = L10n.tr("Contacts", "contacts.detail.savedHistory", fallback: "Saved History") + public static let savedHistory = L10n.tr("Contacts", "contacts.detail.savedHistory", fallback: "Telemetry History") /// Location: ContactDetailView.swift - Purpose: Discovery countdown public static func secondsRemaining(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.detail.secondsRemaining", p1, fallback: "Up to %d seconds remaining") @@ -2378,13 +2378,15 @@ public enum L10n { /// Location: NeighborRow - Not seen status public static let notSeen = L10n.tr("RemoteNodes", "remoteNodes.history.notSeen", fallback: "Not seen") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title - public static let overviewTitle = L10n.tr("RemoteNodes", "remoteNodes.history.overviewTitle", fallback: "Saved History") + public static let overviewTitle = L10n.tr("RemoteNodes", "remoteNodes.history.overviewTitle", fallback: "Telemetry History") /// Location: NodeStatusHistoryView.swift - Packets received chart title public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.history.packetsReceived", fallback: "Packets Received") /// Location: NodeStatusHistoryView.swift - Packets sent chart title public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.history.packetsSent", fallback: "Packets Sent") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header public static let radioSection = L10n.tr("RemoteNodes", "remoteNodes.history.radioSection", fallback: "Radio") + /// Location: NodeStatusHistoryView.swift - Receive errors chart title + public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.history.receiveErrors", fallback: "Packet Errors Received") /// Location: NodeStatusHistoryView.swift - Footer about data retention public static let retentionNotice = L10n.tr("RemoteNodes", "remoteNodes.history.retentionNotice", fallback: "History data older than one year is automatically removed.") /// Location: NodeStatusHistoryView.swift - RSSI chart title diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 60821a1b0..4d1d42789 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Telemetrie"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetriezugriff"; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 1b9e83539..b2dfa656c 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Empfangene Pakete"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Empfangene Paketfehler"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Aktiv"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index d77ab3985..8048af9a1 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Telemetry"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetry Access"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 8ee1f8a61..24c0a91ec 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Packets Received"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Packet Errors Received"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Active"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 574d5dcaa..99b45f5fb 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Telemetría"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Acceso a telemetría"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 734bbe4fd..3f0fc9894 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Paquetes recibidos"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Errores de paquetes recibidos"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Activo"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index db112bbbb..db41c7826 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Télémétrie"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Accès à la télémétrie"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 7c5b6d29b..0904d9c11 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Paquets reçus"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Erreurs de paquets reçus"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Actif"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 55df92d88..4627e87a6 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Telemetrie"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetrietoegang"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 560bbd18e..aecea55f9 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Pakketten ontvangen"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Pakketfouten ontvangen"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Actief"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 900bf5e0c..b96567709 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Telemetria"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Dostęp do telemetrii"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 90d1e3fbd..6aff82548 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -469,6 +469,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Pakiety odebrane"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Błędy pakietów odebrane"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Aktywny"; @@ -515,7 +518,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index d8b89b90d..06ae913a4 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Телеметрия"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ к телеметрии"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 35d729ec6..3535dfb2c 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -469,6 +469,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Пакетов получено"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Ошибок пакетов получено"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Активен"; @@ -515,7 +518,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 6c916d31c..ca7ac5c8b 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "Телеметрія"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ до телеметрії"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index f4d8f3468..35b0a866e 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -469,6 +469,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "Пакетів отримано"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "Помилок пакетів отримано"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "Активний"; @@ -515,7 +518,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 39250cb35..6883626aa 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -207,7 +207,7 @@ "contacts.detail.telemetry" = "遥测数据"; /* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ -"contacts.detail.savedHistory" = "Saved History"; +"contacts.detail.savedHistory" = "Telemetry History"; /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "遥测数据访问权限"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 23b33ed5b..8d677676b 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -472,6 +472,9 @@ /* Location: NodeStatusHistoryView.swift - Packets received chart title */ "remoteNodes.history.packetsReceived" = "已接收数据包"; +/* Location: NodeStatusHistoryView.swift - Receive errors chart title */ +"remoteNodes.history.receiveErrors" = "已接收数据包错误"; + /* Location: NeighborHistoryView.swift - Active status */ "remoteNodes.history.active" = "活跃"; @@ -518,7 +521,7 @@ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ -"remoteNodes.history.overviewTitle" = "Saved History"; +"remoteNodes.history.overviewTitle" = "Telemetry History"; // MARK: - Room Conversation diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 5dfa9235f..07275d4fd 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -547,7 +547,7 @@ private struct ContactActionsSection: View { } .radioDisabled(for: appState.connectionState) - // Saved History - offline telemetry charts + // Telemetry History - offline telemetry charts NavigationLink { TelemetryHistoryOverviewView( publicKey: currentContact.publicKey, @@ -555,6 +555,7 @@ private struct ContactActionsSection: View { ) } label: { Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) } // Admin Access - navigates to settings view after auth diff --git a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift index a2a6667bb..a97cca7f5 100644 --- a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift +++ b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift @@ -62,6 +62,13 @@ struct NodeStatusHistoryView: View { } ) + metricSection( + title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, + dataPoints: filteredSnapshots.compactMap { s in + s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + Section { } footer: { Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index 9e3238c21..b07c60d19 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -9,6 +9,7 @@ struct RepeaterStatusView: View { let session: RemoteNodeSessionDTO @State private var viewModel = RepeaterStatusViewModel() @State private var contacts: [ContactDTO] = [] + @State private var discoveredNodes: [DiscoveredNodeDTO] = [] var body: some View { NavigationStack { @@ -64,6 +65,7 @@ struct RepeaterStatusView: View { await viewModel.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) if let dataStore = appState.services?.dataStore { contacts = (try? await dataStore.fetchContacts(deviceID: deviceID)) ?? [] + discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(deviceID: deviceID)) ?? [] } } } @@ -96,7 +98,8 @@ struct RepeaterStatusView: View { NeighborsSection( viewModel: viewModel, session: session, - contacts: contacts + contacts: contacts, + discoveredNodes: discoveredNodes ) } @@ -240,6 +243,7 @@ private struct NeighborsSection: View { @Bindable var viewModel: RepeaterStatusViewModel let session: RemoteNodeSessionDTO let contacts: [ContactDTO] + let discoveredNodes: [DiscoveredNodeDTO] var body: some View { Section { @@ -256,9 +260,11 @@ private struct NeighborsSection: View { } else { ForEach(viewModel.neighbors, id: \.publicKeyPrefix) { neighbor in let contact = contacts.first { $0.publicKeyPrefix.starts(with: neighbor.publicKeyPrefix) } + let resolvedName = contact?.displayName + ?? discoveredNodes.first(where: { $0.publicKey.prefix(6).starts(with: neighbor.publicKeyPrefix) })?.name NavigationLink { NeighborSNRChartView( - name: contact?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, + name: resolvedName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, neighborPrefix: neighbor.publicKeyPrefix, fetchSnapshots: viewModel.fetchHistory ) @@ -280,7 +286,8 @@ private struct NeighborsSection: View { ForEach(disappeared, id: \.publicKeyPrefix) { old in DisappearedNeighborRow( neighbor: old, - contact: contacts.first { $0.publicKeyPrefix.starts(with: old.publicKeyPrefix) } + contact: contacts.first { $0.publicKeyPrefix.starts(with: old.publicKeyPrefix) }, + discoveredName: discoveredNodes.first(where: { $0.publicKey.prefix(6).starts(with: old.publicKeyPrefix) })?.name ) } } @@ -571,6 +578,7 @@ private struct NeighborRow: View { private struct DisappearedNeighborRow: View { let neighbor: NeighborSnapshotEntry let contact: ContactDTO? + var discoveredName: String? var body: some View { HStack { @@ -588,6 +596,7 @@ private struct DisappearedNeighborRow: View { private var displayName: String { contact?.displayName + ?? discoveredName ?? Data(neighbor.publicKeyPrefix.prefix(4)).hexString() } } diff --git a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift index 8edca8c44..357be528a 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -256,7 +256,8 @@ final class RepeaterStatusViewModel { uptimeSeconds: response.uptimeSeconds, rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, packetsSent: response.packetsSent, - packetsReceived: response.packetsReceived + packetsReceived: response.packetsReceived, + receiveErrors: response.receiveErrors ) if let snapshotID { self.currentSnapshotID = snapshotID diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift index 110355fab..fafcf1b10 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -53,7 +53,8 @@ struct TelemetryHistoryOverviewView: View { let hasRadioData = filtered.contains { $0.batteryMillivolts != nil || $0.lastSNR != nil || $0.lastRSSI != nil || $0.noiseFloor != nil || - $0.packetsSent != nil || $0.packetsReceived != nil + $0.packetsSent != nil || $0.packetsReceived != nil || + $0.receiveErrors != nil } if hasRadioData { @@ -112,6 +113,14 @@ struct TelemetryHistoryOverviewView: View { s.packetsReceived.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, + unit: "", color: .red, + dataPoints: filtered.compactMap { s in + s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) } } } @@ -225,9 +234,10 @@ struct TelemetryHistoryOverviewView: View { } else { let hexName = neighbor.publicKeyPrefix .map { String(format: "%02X", $0) }.joined() + let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName charts[neighbor.publicKeyPrefix] = NeighborChart( prefix: neighbor.publicKeyPrefix, - name: hexName, + name: resolvedName, dataPoints: [point] ) } diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift index 5b6fdb547..dc6bf99ba 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift @@ -10,6 +10,8 @@ final class TelemetryHistoryOverviewViewModel { private(set) var snapshots: [NodeStatusSnapshotDTO] = [] private(set) var ocvArray: [Int] = OCVPreset.liIon.ocvArray + private(set) var contacts: [ContactDTO] = [] + private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] var timeRange: HistoryTimeRange = .all // MARK: - Computed @@ -83,6 +85,19 @@ final class TelemetryHistoryOverviewViewModel { } catch { // Keep default liIon } + + contacts = (try? await dataStore.fetchContacts(deviceID: deviceID)) ?? [] + discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(deviceID: deviceID)) ?? [] + } + + func resolveNeighborName(prefix: Data) -> String? { + if let contact = contacts.first(where: { $0.publicKeyPrefix.starts(with: prefix) }) { + return contact.displayName + } + if let node = discoveredNodes.first(where: { $0.publicKey.prefix(6).starts(with: prefix) }) { + return node.name + } + return nil } } diff --git a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift index cdac589ee..2390d4779 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift @@ -43,7 +43,7 @@ public final class NodeStatusSnapshot { // MARK: - Radio metrics // Intentionally excluded: txQueueLength, airtime, sentFlood, sentDirect, - // receivedFlood, receivedDirect, fullEvents, directDuplicates, floodDuplicates, receiveErrors + // receivedFlood, receivedDirect, fullEvents, directDuplicates, floodDuplicates public var batteryMillivolts: UInt16? public var lastSNR: Double? @@ -53,6 +53,7 @@ public final class NodeStatusSnapshot { public var rxAirtimeSeconds: UInt32? public var packetsSent: UInt32? public var packetsReceived: UInt32? + public var receiveErrors: UInt32? // MARK: - Optional neighbor/telemetry data @@ -74,6 +75,7 @@ public final class NodeStatusSnapshot { rxAirtimeSeconds: UInt32? = nil, packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -88,6 +90,7 @@ public final class NodeStatusSnapshot { self.rxAirtimeSeconds = rxAirtimeSeconds self.packetsSent = packetsSent self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } @@ -107,6 +110,7 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { public let rxAirtimeSeconds: UInt32? public let packetsSent: UInt32? public let packetsReceived: UInt32? + public let receiveErrors: UInt32? public let neighborSnapshots: [NeighborSnapshotEntry]? public let telemetryEntries: [TelemetrySnapshotEntry]? @@ -122,6 +126,7 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.rxAirtimeSeconds = model.rxAirtimeSeconds self.packetsSent = model.packetsSent self.packetsReceived = model.packetsReceived + self.receiveErrors = model.receiveErrors self.neighborSnapshots = model.neighborSnapshots self.telemetryEntries = model.telemetryEntries } @@ -138,6 +143,7 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { rxAirtimeSeconds: UInt32? = nil, packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, + receiveErrors: UInt32? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -152,6 +158,7 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.rxAirtimeSeconds = rxAirtimeSeconds self.packetsSent = packetsSent self.packetsReceived = packetsReceived + self.receiveErrors = receiveErrors self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index e4177213d..0b5746eda 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -410,7 +410,8 @@ public protocol PersistenceStoreProtocol: Actor { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32? ) async throws -> UUID /// Fetch the most recent snapshot for a node diff --git a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index a82e512a5..50977c309 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -24,7 +24,8 @@ public actor NodeSnapshotService { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32? ) async -> UUID? { do { if let latest = try await dataStore.fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), @@ -42,7 +43,8 @@ public actor NodeSnapshotService { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors ) logger.info("Saved status snapshot for node") return id diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 38ef9fc89..bde70b7aa 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -376,7 +376,8 @@ extension PersistenceStore { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32? ) throws -> UUID { try saveNodeStatusSnapshot( timestamp: .now, @@ -388,11 +389,13 @@ extension PersistenceStore { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors ) } - /// Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. + // Overload that accepts an explicit timestamp, used by tests to avoid timing-dependent sleeps. + // swiftlint:disable:next function_parameter_count public func saveNodeStatusSnapshot( timestamp: Date, nodePublicKey: Data, @@ -403,7 +406,8 @@ extension PersistenceStore { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32? ) throws -> UUID { let snapshot = NodeStatusSnapshot( timestamp: timestamp, @@ -415,7 +419,8 @@ extension PersistenceStore { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors ) modelContext.insert(snapshot) try modelContext.save() diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 17860d200..6aad0bcbc 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1425,7 +1425,8 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32? ) async throws -> UUID { let dto = NodeStatusSnapshotDTO( nodePublicKey: nodePublicKey, @@ -1436,7 +1437,8 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors ) nodeStatusSnapshots.append(dto) return dto.id diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift index ad11e7480..35b2f36bf 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSnapshotServiceTests.swift @@ -28,7 +28,8 @@ struct NodeSnapshotServiceTests { uptimeSeconds: 3600, rxAirtimeSeconds: 100, packetsSent: 500, - packetsReceived: 1000 + packetsReceived: 1000, + receiveErrors: nil ) #expect(id != nil) @@ -47,7 +48,8 @@ struct NodeSnapshotServiceTests { uptimeSeconds: nil, rxAirtimeSeconds: nil, packetsSent: nil, - packetsReceived: nil + packetsReceived: nil, + receiveErrors: nil ) #expect(first != nil) @@ -60,7 +62,8 @@ struct NodeSnapshotServiceTests { uptimeSeconds: nil, rxAirtimeSeconds: nil, packetsSent: nil, - packetsReceived: nil + packetsReceived: nil, + receiveErrors: nil ) #expect(second == nil, "Second snapshot should be throttled") } @@ -75,7 +78,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3850, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) #expect(first != nil) @@ -84,7 +88,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3700, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) #expect(second != nil, "Different node should not be throttled") } @@ -98,7 +103,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3850, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) guard let snapshotID = id else { Issue.record("Expected snapshot ID") @@ -124,7 +130,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3850, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) guard let snapshotID = id else { Issue.record("Expected snapshot ID") @@ -153,7 +160,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3700, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) _ = try await store.saveNodeStatusSnapshot( timestamp: t2, @@ -161,7 +169,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3850, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) let previous = await service.previousSnapshot(for: testPublicKey, before: .now) @@ -180,7 +189,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3600, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) _ = try await store.saveNodeStatusSnapshot( timestamp: t2, @@ -188,7 +198,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) let snapshots = await service.fetchSnapshots(for: testPublicKey) @@ -211,7 +222,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3600, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) // Save a "recent" snapshot @@ -221,7 +233,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) await service.pruneOldSnapshots(olderThan: cutoff) @@ -239,7 +252,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3850, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) // Prune with a cutoff 1 year ago — recent data should survive @@ -263,7 +277,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3600, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) _ = try await store.saveNodeStatusSnapshot( timestamp: t2, @@ -271,7 +286,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) let snapshots = await service.fetchSnapshots(for: testPublicKey, since: cutoff) @@ -294,7 +310,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3600, lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) let id2 = try await store.saveNodeStatusSnapshot( timestamp: t2, @@ -302,7 +319,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3800, lastSNR: 8.5, lastRSSI: -85, noiseFloor: -118, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) // Enrich both with telemetry @@ -344,7 +362,8 @@ struct NodeSnapshotServiceTests { batteryMillivolts: 3700, lastSNR: 7.0, lastRSSI: -90, noiseFloor: -120, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, + receiveErrors: nil ) guard let snapshotID = id1 else { Issue.record("First snapshot should not be throttled") diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index b787c0e95..a0711a3bc 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -376,7 +376,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { // Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 96ecbbda7..7ddee7ae3 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -386,7 +386,7 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 02c39bdf2..67f94d21b 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -211,7 +211,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots (stubs) // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift index ca79b0d62..3fc320728 100644 --- a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -51,13 +51,13 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: 8.0, lastRSSI: -90, noiseFloor: -120, uptimeSeconds: 3600, rxAirtimeSeconds: 100, - packetsSent: 500, packetsReceived: 1000 + packetsSent: 500, packetsReceived: 1000, receiveErrors: nil ) _ = try await store.saveNodeStatusSnapshot( nodePublicKey: testPublicKey, batteryMillivolts: 3750, lastSNR: 7.5, lastRSSI: -92, noiseFloor: -118, uptimeSeconds: 7200, rxAirtimeSeconds: 200, - packetsSent: 600, packetsReceived: 1100 + packetsSent: 600, packetsReceived: 1100, receiveErrors: nil ) let viewModel = TelemetryHistoryOverviewViewModel() @@ -119,7 +119,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) let viewModel = TelemetryHistoryOverviewViewModel() @@ -142,7 +142,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3600, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) // Save a recent snapshot @@ -150,7 +150,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) let viewModel = TelemetryHistoryOverviewViewModel() @@ -175,7 +175,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) await viewModel.loadData( @@ -193,7 +193,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) let viewModel = TelemetryHistoryOverviewViewModel() @@ -223,7 +223,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) let viewModel = TelemetryHistoryOverviewViewModel() @@ -254,7 +254,7 @@ struct TelemetryHistoryOverviewViewModelTests { nodePublicKey: testPublicKey, batteryMillivolts: 3800, lastSNR: nil, lastRSSI: nil, noiseFloor: nil, uptimeSeconds: nil, rxAirtimeSeconds: nil, - packetsSent: nil, packetsReceived: nil + packetsSent: nil, packetsReceived: nil, receiveErrors: nil ) // Channel 0: Voltage (priority 0) and Temperature (priority 1) From 782c2d69e3f2c9ebf03328cd134e3edd63844acc Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:53:13 -0700 Subject: [PATCH 19/48] fix(meshcore): decode 0x87 push status for room server layout - Add layout param to Parsers.StatusResponse.parse() with switch matching the repeater/roomServer pattern from parseFromBinaryResponse - Re-parse push status responses in handleReceivedData() when contact cache identifies the sender as a room server - Fix routeGenericBinaryResponse to resolve layout from contact cache before calling parseFromBinaryResponse - Add push-path parse tests for room server and default repeater layouts --- .../Sources/MeshCore/Protocol/Parsers.swift | 86 +++++++++++++------ .../MeshCore/Session/MeshCoreSession.swift | 14 ++- .../Validation/ProtocolBugFixTests.swift | 63 ++++++++++++++ 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 25cecdd7a..be73eed82 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -631,8 +631,9 @@ public enum Parsers { /// - Offset 47 (2 bytes): Full events counter /// - Offset 49 (2 bytes): Last SNR scaled by 4 (Int16 LE) /// - Offset 51 (4 bytes): Duplicate counters - /// - Offset 55 (4 bytes): Receive airtime - static func parse(_ data: Data) -> MeshEvent { + /// - Offset 55 (4 bytes): Repeater: Rx airtime (UInt32 LE); Room server: posted count (UInt16 LE) + post-push count (UInt16 LE) + /// - Offset 59 (4 bytes): Repeater only: Receive errors (UInt32 LE, optional) + static func parse(_ data: Data, layout: MeshCore.StatusResponse.Layout = .repeater) -> MeshEvent { guard data.count >= PacketSize.statusResponseMinimum else { return .parseFailure( data: data, @@ -659,30 +660,65 @@ public enum Parsers { let lastSNR = Double(data.readInt16LE(at: offset)) / 4.0; offset += 2 let directDups = Int(data.readUInt16LE(at: offset)); offset += 2 let floodDups = Int(data.readUInt16LE(at: offset)); offset += 2 - let rxAirtime = data.readUInt32LE(at: offset); offset += 4 - let receiveErrors: UInt32 = data.count >= offset + 4 ? data.readUInt32LE(at: offset) : 0 + switch layout { + case .repeater: + let rxAirtime = data.readUInt32LE(at: offset); offset += 4 + let receiveErrors: UInt32 = data.count >= offset + 4 ? data.readUInt32LE(at: offset) : 0 - return .statusResponse(MeshCore.StatusResponse( - publicKeyPrefix: pubkeyPrefix, - battery: battery, - txQueueLength: txQueueLen, - noiseFloor: noiseFloor, - lastRSSI: lastRSSI, - packetsReceived: packetsRecv, - packetsSent: packetsSent, - airtime: airtime, - uptime: uptime, - sentFlood: sentFlood, - sentDirect: sentDirect, - receivedFlood: recvFlood, - receivedDirect: recvDirect, - fullEvents: fullEvents, - lastSNR: lastSNR, - directDuplicates: directDups, - floodDuplicates: floodDups, - rxAirtime: rxAirtime, - receiveErrors: receiveErrors - )) + return .statusResponse(MeshCore.StatusResponse( + layout: .repeater, + publicKeyPrefix: pubkeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: rxAirtime, + receiveErrors: receiveErrors + )) + + case .roomServer: + let postedCount: UInt16? = data.count >= offset + 4 + ? data.readUInt16LE(at: offset) : nil + let postPushCount: UInt16? = data.count >= offset + 4 + ? data.readUInt16LE(at: offset + 2) : nil + + return .statusResponse(MeshCore.StatusResponse( + layout: .roomServer, + publicKeyPrefix: pubkeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: 0, + receiveErrors: 0, + roomServerPostedCount: postedCount, + roomServerPostPushCount: postPushCount + )) + } } /// Parses status data from a BINARY_RESPONSE (0x8C) payload. diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 7eaed6576..48c99aa97 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -2697,6 +2697,14 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { logger.debug("Received event: \(String(describing: event))") } + // Re-parse push status responses with correct layout for room servers + if case .statusResponse(let response) = event, + response.layout == .repeater, + let contact = contactManager.getByKeyPrefix(response.publicKeyPrefix), + contact.type == .room { + event = Parsers.StatusResponse.parse(Data(data.dropFirst()), layout: .roomServer) + } + // Route generic binary response to typed event based on pending request if case .binaryResponse(let tag, let responseData) = event { if let typedEvent = await routeGenericBinaryResponse(tag: tag, data: responseData) { @@ -2731,9 +2739,13 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return .neighboursResponse(response) case .status: + let layout: StatusResponse.Layout = + contactManager.getByKeyPrefix(publicKeyPrefix)?.type == .room + ? .roomServer : .repeater guard let response = Parsers.StatusResponse.parseFromBinaryResponse( data, - publicKeyPrefix: publicKeyPrefix + publicKeyPrefix: publicKeyPrefix, + layout: layout ) else { return nil } diff --git a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift index e21a99ab0..db8309ae0 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift @@ -413,6 +413,69 @@ struct ProtocolBugFixTests { #expect(status.receiveErrors == 99) } + // MARK: - Push Status Parse with Room Server Layout + + @Test("statusResponse parse room server layout decodes post counters") + func statusResponseParseRoomServerLayoutDecodesPostCounters() { + // 59 bytes: room server push payload (1 reserved + 6 pubkey + 52 ServerStats) + var payload = Data() + payload.append(0x00) // Reserved byte + payload.append(contentsOf: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) // Pubkey prefix + payload.append(contentsOf: [0xE8, 0x03]) // Battery: 1000mV + payload.append(contentsOf: [0x05, 0x00]) // txQueue: 5 + payload.append(contentsOf: [0x92, 0xFF]) // noiseFloor: -110 + payload.append(contentsOf: [0xAB, 0xFF]) // lastRSSI: -85 + payload.append(Data(repeating: 0, count: 40)) // Remaining common fields (zero) + // Room-server tail: n_posted = 17 (0x0011), n_post_push = 9 (0x0009) + payload.append(contentsOf: [0x11, 0x00]) // roomServerPostedCount: 17 + payload.append(contentsOf: [0x09, 0x00]) // roomServerPostPushCount: 9 + + #expect(payload.count == 59) + + let event = Parsers.StatusResponse.parse(payload, layout: .roomServer) + + guard case .statusResponse(let status) = event else { + Issue.record("Expected statusResponse event, got \(event)") + return + } + + #expect(status.layout == .roomServer) + #expect(status.roomServerPostedCount == 17) + #expect(status.roomServerPostPushCount == 9) + #expect(status.rxAirtime == 0) + #expect(status.receiveErrors == 0) + #expect(status.battery == 1000) + } + + @Test("statusResponse parse defaults to repeater layout") + func statusResponseParseDefaultsToRepeaterLayout() { + // Same 59-byte payload as above, parsed without explicit layout + var payload = Data() + payload.append(0x00) // Reserved byte + payload.append(contentsOf: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) // Pubkey prefix + payload.append(contentsOf: [0xE8, 0x03]) // Battery: 1000mV + payload.append(contentsOf: [0x05, 0x00]) // txQueue: 5 + payload.append(contentsOf: [0x92, 0xFF]) // noiseFloor: -110 + payload.append(contentsOf: [0xAB, 0xFF]) // lastRSSI: -85 + payload.append(Data(repeating: 0, count: 40)) // Remaining common fields (zero) + // Tail bytes: 0x11 0x00 0x09 0x00 — interpreted as rxAirtime UInt32LE = 0x00090011 = 589841 + payload.append(contentsOf: [0x11, 0x00, 0x09, 0x00]) + + #expect(payload.count == 59) + + let event = Parsers.StatusResponse.parse(payload) + + guard case .statusResponse(let status) = event else { + Issue.record("Expected statusResponse event, got \(event)") + return + } + + #expect(status.layout == .repeater) + #expect(status.rxAirtime == 0x0009_0011) + #expect(status.roomServerPostedCount == nil) + #expect(status.roomServerPostPushCount == nil) + } + // MARK: - Telemetry Request Payload @Test("binaryRequest telemetry includes permission mask payload") From 9ec947da4aa8d6211352909a89358b0020bda324 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:21:17 -0700 Subject: [PATCH 20/48] feat(meshcore): add binary owner info request - Add BinaryRequestType.ownerInfo = 0x07 to PacketCodes - Add OwnerInfoResponse struct (firmwareVersion, nodeName, ownerInfo) to MeshEvent - Implement requestOwnerInfo / performOwnerInfoRequest in MeshCoreSession following the established binary request pattern - Expose requestOwnerInfo through RemoteNodeService and RepeaterAdminService --- .../Services/RemoteNodeService.swift | 20 ++++ .../Services/RepeaterAdminService.swift | 7 ++ .../Sources/MeshCore/Events/MeshEvent.swift | 17 ++- .../MeshCore/Protocol/PacketCodes.swift | 2 + .../MeshCore/Session/MeshCoreSession.swift | 100 +++++++++++++++++- 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index e9148c7e5..94ade4599 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -816,6 +816,26 @@ public actor RemoteNodeService { } } + // MARK: - Owner Info + + /// Request owner info from a repeater using binary protocol. + public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { + throw RemoteNodeError.sessionNotFound + } + + do { + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteOwnerInfo") { + try await self.session.requestOwnerInfo(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout + } catch let error as MeshCoreError { + throw RemoteNodeError.sessionError(error) + } + } + // MARK: - CLI Commands /// Send a CLI command to a remote node and wait for response (admin only). diff --git a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift index de4f296b1..ae4d1d427 100644 --- a/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift +++ b/MC1Services/Sources/MC1Services/Services/RepeaterAdminService.swift @@ -170,6 +170,13 @@ public actor RepeaterAdminService { try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) } + // MARK: - Owner Info + + /// Request owner info from a repeater using binary protocol. + public func requestOwnerInfo(sessionID: UUID, timeout: Duration? = nil) async throws -> OwnerInfoResponse { + try await remoteNodeService.requestOwnerInfo(sessionID: sessionID, timeout: timeout) + } + // MARK: - CLI Commands /// Send a CLI command to a repeater and wait for response (admin only). diff --git a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift index 6dd285e00..7f8bb2b1c 100644 --- a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift +++ b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift @@ -501,8 +501,23 @@ public struct ChannelInfo: Sendable, Equatable { } } +/// Response from a `REQ_TYPE_GET_OWNER_INFO` (0x07) binary request. +/// +/// The firmware responds with a UTF-8 string: `"\n\n"`. +public struct OwnerInfoResponse: Sendable { + public let firmwareVersion: String + public let nodeName: String + public let ownerInfo: String + + public init(firmwareVersion: String, nodeName: String, ownerInfo: String) { + self.firmwareVersion = firmwareVersion + self.nodeName = nodeName + self.ownerInfo = ownerInfo + } +} + /// Represents a status response from a remote node. -/// +/// /// Note on offset logic (per Python parsing.py): /// - Binary request responses: offset=0, fields start immediately after response code /// - Push notification responses: offset=8, pubkey_prefix at bytes 2-8, fields follow diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift b/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift index 1c17a77e0..ed56086ac 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift @@ -218,6 +218,8 @@ public enum BinaryRequestType: UInt8, Sendable { case acl = 0x05 /// Requests the list of visible neighbor nodes. case neighbours = 0x06 + /// Requests owner information from a repeater. + case ownerInfo = 0x07 } /// Defines the types of anonymous requests that can be sent to remote nodes. diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 48c99aa97..f74123020 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -2058,6 +2058,104 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return try await requestTelemetry(from: publicKey) } + // MARK: - Owner Info + + /// Requests owner information from a repeater using binary protocol. + /// + /// - Parameter publicKey: The full 32-byte public key of the repeater. + /// - Returns: An ``OwnerInfoResponse`` containing firmware version, node name, and owner info. + /// - Throws: ``MeshCoreError/timeout`` if no response within timeout period. + public func requestOwnerInfo(from publicKey: Data) async throws -> OwnerInfoResponse { + try requireFullPublicKey(publicKey, operation: "requestOwnerInfo") + return try await binaryRequestSerializer.withSerialization { [self] in + try await performOwnerInfoRequest(from: publicKey) + } + } + + /// Internal implementation of owner info request, called within serialization. + private func performOwnerInfoRequest(from publicKey: Data) async throws -> OwnerInfoResponse { + let data = PacketBuilder.binaryRequest(to: publicKey, type: .ownerInfo) + let publicKeyPrefix = Data(publicKey.prefix(6)) + let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + let startTime = ContinuousClock.now + + logger.info("Owner info request to \(prefixHex): sending") + + // Subscribe BEFORE sending to avoid race condition where binaryResponse + // arrives before we can register the pending request + let events = await dispatcher.subscribe() + + // Send after subscribing + try await transport.send(data) + + // Wait for messageSent (to get expectedAck) then binaryResponse (the actual response) + return try await withThrowingTaskGroup(of: OwnerInfoResponse?.self) { group in + let (timeoutStream, timeoutContinuation) = AsyncStream.makeStream() + + group.addTask { [logger] in + var expectedAck: Data? + + for await event in events { + if Task.isCancelled { return nil } + + switch event { + case .messageSent(let info): + expectedAck = info.expectedAck + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 + logger.info("Owner info request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s") + timeoutContinuation.yield(timeout) + timeoutContinuation.finish() + + case .error(let code): + timeoutContinuation.finish() + throw MeshCoreError.deviceError(code: code ?? 0) + + case .binaryResponse(let tag, let responseData): + guard let expected = expectedAck, tag == expected else { continue } + + // Response is UTF-8: "\n\n" + let text = String(data: responseData, encoding: .utf8) ?? "" + let components = text.split(separator: "\n", maxSplits: 2, omittingEmptySubsequences: false) + let firmwareVersion = components.count >= 1 ? String(components[0]) : "" + let nodeName = components.count >= 2 ? String(components[1]) : "" + let ownerInfo = components.count >= 3 ? String(components[2]) : "" + + let elapsed = ContinuousClock.now - startTime + logger.info("Owner info request to \(prefixHex): response received in \(elapsed)") + return OwnerInfoResponse(firmwareVersion: firmwareVersion, nodeName: nodeName, ownerInfo: ownerInfo) + + default: + continue + } + } + timeoutContinuation.finish() + return nil + } + + group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in + var timeout = defaultTimeout + var usedFirmwareTimeout = false + for await t in timeoutStream { + timeout = t + usedFirmwareTimeout = true + break + } + logger.info("Owner info request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))") + try await clock.sleep(for: .seconds(timeout)) + let elapsed = ContinuousClock.now - startTime + logger.warning("Owner info request to \(prefixHex): timed out after \(elapsed)") + return nil + } + + if let result = try await group.next() ?? nil { + group.cancelAll() + return result + } + group.cancelAll() + throw MeshCoreError.timeout + } + } + /// Requests Min-Max-Average (MMA) data for a time range. /// /// Retrieves aggregated sensor data statistics from a remote node. @@ -2758,7 +2856,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { ) return .telemetryResponse(response) - case .keepAlive: + case .keepAlive, .ownerInfo: return nil } } From 63e0e60940af3d0fb7635e276abb7a0fb8e7ef5e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:21:25 -0700 Subject: [PATCH 21/48] feat(repeater): add guest mode for repeater access - Route non-admin logins from the admin button to RepeaterStatusView instead of RepeaterSettingsView, in both ContactDetailView and MapView - Show "Guest Mode" badge in RepeaterStatusView header when session permission is guest - Add read-only owner info section to RepeaterStatusView using the new binary request path - Add guestMode, ownerInfo, and noOwnerInfo localized strings across all 9 locales --- MC1/Resources/Generated/L10n.swift | 6 ++ .../Localization/de.lproj/RemoteNodes.strings | 9 +++ .../Localization/en.lproj/RemoteNodes.strings | 9 +++ .../Localization/es.lproj/RemoteNodes.strings | 9 +++ .../Localization/fr.lproj/RemoteNodes.strings | 9 +++ .../Localization/nl.lproj/RemoteNodes.strings | 9 +++ .../Localization/pl.lproj/RemoteNodes.strings | 9 +++ .../Localization/ru.lproj/RemoteNodes.strings | 9 +++ .../Localization/uk.lproj/RemoteNodes.strings | 9 +++ .../zh-Hans.lproj/RemoteNodes.strings | 9 +++ MC1/Views/Contacts/ContactDetailView.swift | 8 ++- MC1/Views/Map/MapView.swift | 6 +- .../RemoteNodes/RepeaterStatusView.swift | 68 +++++++++++++++++-- .../RemoteNodes/RepeaterStatusViewModel.swift | 31 +++++++++ 14 files changed, 191 insertions(+), 9 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index cc83d19c3..c918cbdb4 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2675,6 +2675,8 @@ public enum L10n { public static func channel(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.channel", p1, fallback: "Channel %d") } + /// Location: RepeaterStatusView.swift - Guest mode badge in header + public static let guestMode = L10n.tr("RemoteNodes", "remoteNodes.status.guestMode", fallback: "Guest Mode") /// Location: RepeaterStatusView.swift - Hours ago format public static func hoursAgo(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.hoursAgo", p1, fallback: "%dh ago") @@ -2695,6 +2697,8 @@ public enum L10n { public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.status.noiseFloor", fallback: "Noise Floor") /// Location: RepeaterStatusView.swift - No neighbors empty state public static let noNeighbors = L10n.tr("RemoteNodes", "remoteNodes.status.noNeighbors", fallback: "No neighbors discovered") + /// Location: RepeaterStatusView.swift - No owner info empty state + public static let noOwnerInfo = L10n.tr("RemoteNodes", "remoteNodes.status.noOwnerInfo", fallback: "No contact info") /// Location: RepeaterStatusView.swift - No sensor data empty state public static let noSensorData = L10n.tr("RemoteNodes", "remoteNodes.status.noSensorData", fallback: "No sensor data") /// Location: RepeaterStatusView.swift - No telemetry data empty state @@ -2707,6 +2711,8 @@ public enum L10n { } /// Location: RepeaterStatusViewModel.swift - Cannot save OCV error public static let ocvSaveNoContact = L10n.tr("RemoteNodes", "remoteNodes.status.ocvSaveNoContact", fallback: "Cannot save: contact not found") + /// Location: RepeaterStatusView.swift - Owner info section label + public static let ownerInfo = L10n.tr("RemoteNodes", "remoteNodes.status.ownerInfo", fallback: "Contact Info") /// Location: RepeaterStatusView.swift - Packets received label public static let packetsReceived = L10n.tr("RemoteNodes", "remoteNodes.status.packetsReceived", fallback: "Packets Received") /// Location: RepeaterStatusView.swift - Packets sent label diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index b2dfa656c..7a46b9c1b 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Repeater-Status"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Gastmodus"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Kontaktinfo"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Keine Kontaktinfo"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Status"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 24c0a91ec..378d76cee 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Repeater Status"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Guest Mode"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Contact Info"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "No contact info"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Status"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 3f0fc9894..0e3347b22 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Estado del repetidor"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Modo invitado"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Información de contacto"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Sin información de contacto"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Estado"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 0904d9c11..7bfd2f027 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Statut du répéteur"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Mode invité"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Informations de contact"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Aucune information de contact"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Statut"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index aecea55f9..4f6da84ad 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Repeaterstatus"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Gastmodus"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Contactinfo"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Geen contactinfo"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Status"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 6aff82548..14b817341 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -315,6 +315,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Status przekaźnika"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Tryb gościa"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Informacje kontaktowe"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Brak informacji kontaktowych"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Status"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 3535dfb2c..a56af5930 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -315,6 +315,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Статус ретранслятора"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Гостевой режим"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Контактная информация"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Нет контактной информации"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Статус"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 35b0a866e..d251deee6 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -315,6 +315,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "Статус ретранслятора"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Гостьовий режим"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Контактна інформація"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Немає контактної інформації"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "Статус"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 8d677676b..294ce7950 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -318,6 +318,15 @@ /* Location: RepeaterStatusView.swift - Navigation title */ "remoteNodes.status.title" = "转发节点状态"; +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "访客模式"; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "联系信息"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "无联系信息"; + /* Location: RepeaterStatusView.swift - Status section header */ "remoteNodes.status.statusSection" = "状态"; diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 07275d4fd..92e8c6c97 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -268,8 +268,12 @@ struct ContactDetailView: View { } .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { // Trigger navigation after sheet is fully dismissed to avoid race conditions - if adminSession != nil { - navigateToSettings = true + if let session = adminSession { + if session.isAdmin { + navigateToSettings = true + } else { + activeSheet = .repeaterStatus(session) + } } }) { if let role = RemoteNodeRole(contactType: currentContact.type) { diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index e1f3e823d..bc6dbd6c4 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -451,7 +451,11 @@ private struct ContactDetailSheet: View { case .adminAuth: if let role = RemoteNodeRole(contactType: contact.type) { NodeAuthenticationSheet(contact: contact, role: role) { session in - pendingSheet = .adminSettings(session) + if session.isAdmin { + pendingSheet = .adminSettings(session) + } else { + pendingSheet = .telemetryStatus(session) + } activeSheet = nil } .presentationSizing(.page) diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index b07c60d19..b972f6e40 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -15,6 +15,7 @@ struct RepeaterStatusView: View { NavigationStack { List { makeHeaderSection() + makeOwnerInfoSection() makeStatusSection() makeTelemetrySection() makeNeighborsSection() @@ -36,7 +37,7 @@ struct RepeaterStatusView: View { } .radioDisabled( for: appState.connectionState, - or: viewModel.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.isLoadingTelemetry + or: viewModel.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.isLoadingTelemetry || viewModel.isLoadingOwnerInfo ) } @@ -71,6 +72,10 @@ struct RepeaterStatusView: View { } .refreshable { await viewModel.requestStatus(for: session) + // Refresh owner info only if already loaded + if viewModel.ownerInfoLoaded { + await viewModel.requestOwnerInfo(for: session) + } // Refresh telemetry only if already loaded if viewModel.telemetryLoaded { await viewModel.requestTelemetry(for: session) @@ -87,7 +92,11 @@ struct RepeaterStatusView: View { // MARK: - Subviews private func makeHeaderSection() -> some View { - HeaderSection(publicKey: session.publicKey, name: session.name) + HeaderSection(session: session) + } + + private func makeOwnerInfoSection() -> some View { + OwnerInfoSection(viewModel: viewModel, session: session) } private func makeStatusSection() -> some View { @@ -121,6 +130,10 @@ struct RepeaterStatusView: View { private func refresh() { Task { await viewModel.requestStatus(for: session) + // Refresh owner info only if already loaded + if viewModel.ownerInfoLoaded { + await viewModel.requestOwnerInfo(for: session) + } // Refresh telemetry only if already loaded if viewModel.telemetryLoaded { await viewModel.requestTelemetry(for: session) @@ -136,18 +149,23 @@ struct RepeaterStatusView: View { // MARK: - Header Section private struct HeaderSection: View { - let publicKey: Data - let name: String + let session: RemoteNodeSessionDTO var body: some View { Section { HStack { Spacer() VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: .repeater, size: 60) + NodeAvatar(publicKey: session.publicKey, role: .repeater, size: 60) - Text(name) + Text(session.name) .font(.headline) + + if session.permissionLevel == .guest { + Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) + .font(.subheadline) + .foregroundStyle(.secondary) + } } Spacer() } @@ -156,6 +174,44 @@ private struct HeaderSection: View { } } +// MARK: - Owner Info Section + +private struct OwnerInfoSection: View { + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO + + var body: some View { + Section { + DisclosureGroup(isExpanded: $viewModel.ownerInfoExpanded) { + if viewModel.isLoadingOwnerInfo { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.ownerInfoError { + Text(error) + .foregroundStyle(.red) + } else if let info = viewModel.ownerInfo, !info.isEmpty { + Text(info) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noOwnerInfo) + .foregroundStyle(.secondary) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.ownerInfo) + } + .onChange(of: viewModel.ownerInfoExpanded) { _, isExpanded in + if isExpanded && !viewModel.ownerInfoLoaded { + Task { + await viewModel.requestOwnerInfo(for: session) + } + } + } + } + } +} + // MARK: - Status Section private struct StatusSection: View { diff --git a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift index 357be528a..a3bbf411f 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -45,6 +45,15 @@ final class RepeaterStatusViewModel { /// Whether the telemetry disclosure group is expanded var telemetryExpanded = false + /// Owner info text + var ownerInfo: String? + + /// Owner info loading/state + var isLoadingOwnerInfo = false + var ownerInfoLoaded: Bool { ownerInfo != nil } + var ownerInfoExpanded = false + var ownerInfoError: String? + /// Error message if any var errorMessage: String? @@ -357,6 +366,28 @@ final class RepeaterStatusViewModel { } } + // MARK: - Owner Info + + /// Request owner info from the repeater + func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + + ownerInfoError = nil + isLoadingOwnerInfo = true + + do { + let response = try await performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) + } + ownerInfo = response.ownerInfo + } catch RemoteNodeError.timeout { + ownerInfoError = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + } catch { + ownerInfoError = error.localizedDescription + } + isLoadingOwnerInfo = false + } + // MARK: - Telemetry Grouping /// Whether cached data points span multiple channels. From ba12bcb0983db3cbca450caf3b38d767d4dbc99b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:21:32 -0700 Subject: [PATCH 22/48] feat(repeater): pre-fetch node info via binary in admin panel - Call requestOwnerInfo on configure to pre-populate firmware version, node name, and owner info before any section is expanded, replacing three separate CLI round-trips - Remove ver and get name CLI calls from fetchDeviceInfo/fetchIdentity; retry via binary on section expand if data is missing - Add isLoadingNodeInfo flag to deduplicate concurrent binary fetches - Fix ExpandableSettingsSection reload button to only appear when section is expanded - Change deviceInfoLoaded and identityLoaded to check only CLI-fetched data so onLoad still fires for device time and coordinates --- .../ExpandableSettingsSection.swift | 2 +- .../RepeaterSettingsViewModel.swift | 71 ++++++++++--------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index cdd7bf352..aa255e0dd 100644 --- a/MC1/Views/Components/ExpandableSettingsSection.swift +++ b/MC1/Views/Components/ExpandableSettingsSection.swift @@ -67,7 +67,7 @@ struct ExpandableSettingsSection: View { ProgressView() .scaleEffect(0.8) .padding(.trailing) - } else if isLoaded() { + } else if isExpanded && isLoaded() { Button { Task { await onLoad() } } label: { diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift index bc594b4a0..a698b6dc5 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift @@ -15,7 +15,7 @@ final class RepeaterSettingsViewModel { private var deviceTimeUTC: String? var isLoadingDeviceInfo = false var deviceInfoError: String? - var deviceInfoLoaded: Bool { firmwareVersion != nil || deviceTimeUTC != nil } + var deviceInfoLoaded: Bool { deviceTimeUTC != nil } /// Device time converted to user's local timezone and locale var deviceTime: String? { @@ -58,7 +58,7 @@ final class RepeaterSettingsViewModel { private var originalLongitude: Double? var isLoadingIdentity = false var identityError: String? - var identityLoaded: Bool { originalName != nil || originalLatitude != nil || originalLongitude != nil } + var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } // Radio settings (from get radio, get tx) var frequency: Double? @@ -190,6 +190,28 @@ final class RepeaterSettingsViewModel { self?.handleLateResponse(message.text) } } + + // Pre-fetch firmware version, node name, and owner info via binary protocol + await fetchNodeInfo() + } + + private var isLoadingNodeInfo = false + + /// Fetch firmware version, node name, and owner info via a single binary request. + private func fetchNodeInfo() async { + guard !isLoadingNodeInfo, let session, let repeaterAdminService else { return } + isLoadingNodeInfo = true + defer { isLoadingNodeInfo = false } + do { + let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) + firmwareVersion = response.firmwareVersion + name = response.nodeName + originalName = response.nodeName + ownerInfo = response.ownerInfo + originalOwnerInfo = response.ownerInfo + } catch { + logger.warning("Failed to fetch node info via binary: \(error)") + } } /// Handle late CLI responses that arrive after timeout @@ -339,22 +361,13 @@ final class RepeaterSettingsViewModel { // MARK: - Fetch Methods (Pull-to-Load) - /// Fetch device info (firmware version and time) + /// Fetch device info (device time; firmware version is pre-fetched via binary) func fetchDeviceInfo() async { isLoadingDeviceInfo = true deviceInfoError = nil - var hadTimeout = false - // Get firmware version - do { - let response = try await sendAndWait("ver") - if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { - self.firmwareVersion = version - logger.debug("Received firmware version: \(version)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get firmware version: \(error)") + if firmwareVersion == nil { + await fetchNodeInfo() } // Get device time @@ -365,35 +378,23 @@ final class RepeaterSettingsViewModel { logger.debug("Received device time: \(time)") } } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } + if case RemoteNodeError.timeout = error { + deviceInfoError = "error" + } logger.warning("Failed to get device time: \(error)") } - // Show error if any request timed out (even if some succeeded) - if hadTimeout { - deviceInfoError = "error" - } - isLoadingDeviceInfo = false } - /// Fetch identity settings (name, latitude, longitude) + /// Fetch identity settings (latitude, longitude; name is pre-fetched via binary) func fetchIdentity() async { isLoadingIdentity = true identityError = nil var hadTimeout = false - // Get name - do { - let response = try await sendAndWait("get name") - if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { - self.name = n - self.originalName = n - logger.debug("Received name: \(n)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get name: \(error)") + if originalName == nil { + await fetchNodeInfo() } // Get latitude @@ -422,7 +423,6 @@ final class RepeaterSettingsViewModel { logger.warning("Failed to get longitude: \(error)") } - // Show error if any request timed out (even if some succeeded) if hadTimeout { identityError = "error" } @@ -539,6 +539,11 @@ final class RepeaterSettingsViewModel { /// Fetch contact info (owner.info) func fetchContactInfo() async { + if originalOwnerInfo == nil { + await fetchNodeInfo() + } + if originalOwnerInfo != nil { return } + isLoadingContactInfo = true contactInfoError = nil From afc8c6cff38c5b52908cc9bad5eefc092edf3be4 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:31:49 -0700 Subject: [PATCH 23/48] refactor(repeater): rename adminAccess l10n key to management The button supports both admin and guest login, so "adminAccess" no longer reflects its purpose. Renamed key and updated comments across all 9 locales and 3 string tables; updated 3 Swift call sites. --- MC1/Resources/Generated/L10n.swift | 12 ++++++------ MC1/Resources/Localization/de.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/de.lproj/Map.strings | 4 ++-- .../Localization/de.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/en.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/en.lproj/Map.strings | 4 ++-- .../Localization/en.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/es.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/es.lproj/Map.strings | 4 ++-- .../Localization/es.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/fr.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/fr.lproj/Map.strings | 4 ++-- .../Localization/fr.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/nl.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/nl.lproj/Map.strings | 4 ++-- .../Localization/nl.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/pl.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/pl.lproj/Map.strings | 4 ++-- .../Localization/pl.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/ru.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/ru.lproj/Map.strings | 4 ++-- .../Localization/ru.lproj/RemoteNodes.strings | 4 ++-- MC1/Resources/Localization/uk.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/uk.lproj/Map.strings | 4 ++-- .../Localization/uk.lproj/RemoteNodes.strings | 4 ++-- .../Localization/zh-Hans.lproj/Contacts.strings | 4 ++-- MC1/Resources/Localization/zh-Hans.lproj/Map.strings | 4 ++-- .../Localization/zh-Hans.lproj/RemoteNodes.strings | 4 ++-- MC1/Views/Contacts/ContactDetailView.swift | 2 +- MC1/Views/Map/MapView.swift | 2 +- MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift | 2 +- 31 files changed, 63 insertions(+), 63 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index c918cbdb4..5fb238f5a 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1029,8 +1029,6 @@ public enum L10n { public enum Detail { /// Location: ContactDetailView.swift - Purpose: Add to favorites button public static let addToFavorites = L10n.tr("Contacts", "contacts.detail.addToFavorites", fallback: "Add to Favorites") - /// Location: ContactDetailView.swift - Purpose: Admin access button - public static let adminAccess = L10n.tr("Contacts", "contacts.detail.adminAccess", fallback: "Admin Access") /// Location: ContactDetailView.swift - Purpose: Block contact button public static let blockContact = L10n.tr("Contacts", "contacts.detail.blockContact", fallback: "Block Contact") /// Location: ContactDetailView.swift - Purpose: Blocked status indicator @@ -1065,6 +1063,8 @@ public enum L10n { public static let lastAdvert = L10n.tr("Contacts", "contacts.detail.lastAdvert", fallback: "Last Advert") /// Location: ContactDetailView.swift - Purpose: Location section header public static let location = L10n.tr("Contacts", "contacts.detail.location", fallback: "Location") + /// Location: ContactDetailView.swift - Purpose: Management button + public static let management = L10n.tr("Contacts", "contacts.detail.management", fallback: "Management") /// Location: ContactDetailView.swift - Purpose: Name label public static let name = L10n.tr("Contacts", "contacts.detail.name", fallback: "Name") /// Location: ContactDetailView.swift - Purpose: Network path section header @@ -2058,10 +2058,10 @@ public enum L10n { /// Location: MapView.swift ContactDetailSheet - Purpose: Label for contact type public static let type = L10n.tr("Map", "map.detail.type", fallback: "Type") public enum Action { - /// Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings - public static let adminAccess = L10n.tr("Map", "map.detail.action.adminAccess", fallback: "Admin Access") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room public static let joinRoom = L10n.tr("Map", "map.detail.action.joinRoom", fallback: "Join Room") + /// Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater + public static let management = L10n.tr("Map", "map.detail.action.management", fallback: "Management") /// Location: MapView.swift - Purpose: Saved History button for offline telemetry public static let savedHistory = L10n.tr("Map", "map.detail.action.savedHistory", fallback: "Saved History") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to send a message @@ -2303,8 +2303,6 @@ public enum L10n { /// Location: Multiple files - Name label public static let name = L10n.tr("RemoteNodes", "remoteNodes.name", fallback: "Name") public enum Auth { - /// Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access - public static let adminAccess = L10n.tr("RemoteNodes", "remoteNodes.auth.adminAccess", fallback: "Admin Access") /// Location: NodeAuthenticationSheet.swift - Authentication section header public static let authentication = L10n.tr("RemoteNodes", "remoteNodes.auth.authentication", fallback: "Authentication") /// Location: NodeAuthenticationSheet.swift - Cancel button @@ -2317,6 +2315,8 @@ public enum L10n { } /// Location: NodeAuthenticationSheet.swift - Navigation title for room authentication public static let joinRoom = L10n.tr("RemoteNodes", "remoteNodes.auth.joinRoom", fallback: "Join Room") + /// Location: NodeAuthenticationSheet.swift - Navigation title for repeater management + public static let management = L10n.tr("RemoteNodes", "remoteNodes.auth.management", fallback: "Management") /// Location: NodeAuthenticationSheet.swift - Name label public static let name = L10n.tr("RemoteNodes", "remoteNodes.auth.name", fallback: "Name") /// Location: NodeAuthenticationSheet.swift - Node details section header diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 4d1d42789..0af64b20b 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetriezugriff"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Admin-Zugang"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Verwaltung"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Nachricht senden"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index ee865aa92..b50ba436e 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetriezugriff"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Admin-Zugang"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Verwaltung"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Raum beitreten"; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 7a46b9c1b..ad0d9e50e 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Raum beitreten"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Admin-Zugang"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Verwaltung"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Abbrechen"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 8048af9a1..37c5aaa2e 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetry Access"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Admin Access"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Management"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Send Message"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index 20b83e7fd..b4575e2a6 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetry Access"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Admin Access"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Management"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Join Room"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 378d76cee..c9d14e63f 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Join Room"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Admin Access"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Management"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Cancel"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 99b45f5fb..8ac337b4a 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Acceso a telemetría"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Acceso de administrador"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Gestión"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Enviar mensaje"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 51bf03198..608148012 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Acceso a telemetría"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Acceso de administrador"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Gestión"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Unirse a sala"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 0e3347b22..401efffff 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Unirse a sala"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Acceso de administrador"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Gestión"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Cancelar"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index db41c7826..439cfc1fb 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Accès à la télémétrie"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Accès administrateur"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Gestion"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Envoyer un message"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 7513d0546..e7ac361ef 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Accès à la télémétrie"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Accès administrateur"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Gestion"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Rejoindre le salon"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 7bfd2f027..44654e032 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Rejoindre le salon"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Accès administrateur"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Gestion"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Annuler"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 4627e87a6..3a6c3c509 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Telemetrietoegang"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Beheerderstoegang"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Beheer"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Bericht sturen"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index 26a6e5026..c14cbe175 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Telemetrietoegang"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Beheerderstoegang"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Beheer"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Deelnemen aan kamer"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 4f6da84ad..7fd47ec26 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Deelnemen aan kamer"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Beheerderstoegang"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Beheer"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Annuleren"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index b96567709..17cea10c4 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Dostęp do telemetrii"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Dostęp administratora"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Zarządzanie"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Wyślij wiadomość"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 5125d121d..8424914f3 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Dostęp do telemetrii"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Dostęp administratora"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Zarządzanie"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Dołącz do pokoju"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 14b817341..63df831b4 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Dołącz do pokoju"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Dostęp administratora"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Zarządzanie"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Anuluj"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 06ae913a4..60ee27037 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ к телеметрии"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Доступ администратора"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Управление"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Отправить сообщение"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index afe6dace3..725c4dc60 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Доступ к телеметрии"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Доступ администратора"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Управление"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Присоединиться к комнате"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index a56af5930..8146ab9a5 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Присоединиться к комнате"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Доступ администратора"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Управление"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Отмена"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index ca7ac5c8b..8fa4ff044 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "Доступ до телеметрії"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "Адміністративний доступ"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Керування"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "Надіслати повідомлення"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 3f8e01e01..4741c2db9 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "Доступ до телеметрії"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "Доступ адміністратора"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Керування"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "Приєднатися до кімнати"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index d251deee6..3dc98f952 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "Приєднатися до кімнати"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "Доступ адміністратора"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Керування"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "Скасувати"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 6883626aa..6152c7e30 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -212,8 +212,8 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ "contacts.detail.telemetryAccess" = "遥测数据访问权限"; -/* Location: ContactDetailView.swift - Purpose: Admin access button */ -"contacts.detail.adminAccess" = "管理员访问权限"; +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "管理"; /* Location: ContactDetailView.swift - Purpose: Send message button */ "contacts.detail.sendMessage" = "发送消息"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index 183b867a8..a2129c897 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -108,8 +108,8 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ "map.detail.action.telemetryAccessTitle" = "遥测数据访问权限"; -/* Location: MapView.swift ContactDetailSheet - Purpose: Button to access repeater admin settings */ -"map.detail.action.adminAccess" = "管理员访问权限"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "管理"; /* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ "map.detail.action.joinRoom" = "加入房间"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 294ce7950..deb83df6d 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -11,8 +11,8 @@ /* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ "remoteNodes.auth.joinRoom" = "加入房间"; -/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater admin access */ -"remoteNodes.auth.adminAccess" = "管理员访问权限"; +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "管理"; /* Location: NodeAuthenticationSheet.swift - Cancel button */ "remoteNodes.auth.cancel" = "取消"; diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 92e8c6c97..f4b19ab1c 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -564,7 +564,7 @@ private struct ContactActionsSection: View { // Admin Access - navigates to settings view after auth Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.adminAccess, systemImage: "gearshape.2") + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") } .radioDisabled(for: appState.connectionState) diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index bc6dbd6c4..c31d58d1b 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -400,7 +400,7 @@ private struct ContactDetailSheet: View { Button { activeSheet = .adminAuth } label: { - Label(L10n.Map.Map.Detail.Action.adminAccess, systemImage: "gearshape.2") + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") } case .room: diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 42c6802a5..1b8d33624 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -55,7 +55,7 @@ struct NodeAuthenticationSheet: View { makeAuthenticationSection() makeConnectButton() } - .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.adminAccess)) + .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.management)) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { From 707bbeb233b83e4c014f7eda1fef4cfd5f450783 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:15:17 -0700 Subject: [PATCH 24/48] fix(notifications): suppress low battery alerts for batteryless devices - Add `isBatteryPresent` to BatteryInfo (false when level == 0mV) - Guard all three BatteryMonitor threshold methods on isBatteryPresent - Mains-powered devices that report 0mV no longer trigger spurious low battery notifications --- MC1/Extensions/BatteryInfo+Display.swift | 4 ++++ MC1/State/BatteryMonitor.swift | 8 ++++++++ .../Extensions/BatteryInfoDisplayTests.swift | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/MC1/Extensions/BatteryInfo+Display.swift b/MC1/Extensions/BatteryInfo+Display.swift index ff2e5a1da..e366bd16b 100644 --- a/MC1/Extensions/BatteryInfo+Display.swift +++ b/MC1/Extensions/BatteryInfo+Display.swift @@ -5,6 +5,10 @@ import SwiftUI /// Consolidates LiPo voltage-to-percentage calculation previously duplicated in /// BLEStatusIndicatorView and DeviceInfoView. extension BatteryInfo { + /// Whether this reading represents a real battery. + /// 0mV indicates no battery hardware (e.g., mains-powered device with no ADC pin). + var isBatteryPresent: Bool { level > 0 } + /// Battery voltage in volts (converted from millivolts) var voltage: Double { Double(level) / 1000.0 diff --git a/MC1/State/BatteryMonitor.swift b/MC1/State/BatteryMonitor.swift index 8c4d2cc12..2ec18a16f 100644 --- a/MC1/State/BatteryMonitor.swift +++ b/MC1/State/BatteryMonitor.swift @@ -118,6 +118,7 @@ public final class BatteryMonitor { } guard let battery = deviceBattery else { return } + guard battery.isBatteryPresent else { return } let percentage = battery.percentage(using: device.activeOCVArray) let missedThresholds = batteryWarningThresholds.filter { threshold in @@ -160,6 +161,11 @@ public final class BatteryMonitor { return } + guard battery.isBatteryPresent else { + notifiedBatteryThresholds = [] + return + } + let percentage = battery.percentage(using: device.activeOCVArray) let crossedThresholds = batteryWarningThresholds.filter { percentage <= $0 } @@ -180,6 +186,8 @@ public final class BatteryMonitor { let device, let notificationService = services?.notificationService else { return } + guard battery.isBatteryPresent else { return } + let percentage = battery.percentage(using: device.activeOCVArray) for threshold in batteryWarningThresholds { diff --git a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift index 707cab7f4..704922b3d 100644 --- a/MC1Tests/Extensions/BatteryInfoDisplayTests.swift +++ b/MC1Tests/Extensions/BatteryInfoDisplayTests.swift @@ -86,4 +86,21 @@ struct BatteryInfoDisplayTests { let battery = BatteryInfo(level: 3060) // ~5% #expect(battery.levelColor == .red) } + + // MARK: - Battery Presence Tests + + @Test func isBatteryPresent_zeroMillivolts_returnsFalse() { + let battery = BatteryInfo(level: 0) + #expect(!battery.isBatteryPresent) + } + + @Test func isBatteryPresent_normalVoltage_returnsTrue() { + let battery = BatteryInfo(level: 3700) + #expect(battery.isBatteryPresent) + } + + @Test func isBatteryPresent_minimumValidVoltage_returnsTrue() { + let battery = BatteryInfo(level: 1) + #expect(battery.isBatteryPresent) + } } From fc78111ee644efd674279bb7b23856ad29b159a5 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:46:34 -0700 Subject: [PATCH 25/48] fix(chats): keep keyboard visible after send and clear ghost-text reliably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the .id(textFieldID) TextField re-creation trick introduced in b4ee628e, which was destroying the focused view on every send and dismissing the keyboard as a side effect - Add InlinePredictionFix (UIViewRepresentable) that locates the backing UITextView via view hierarchy introspection and sets inlinePredictionType to .no — preventing the ghost-text race (Apple FB13727682) without destroying view identity or dismissing the keyboard --- MC1/Views/Chats/Components/ChatInputBar.swift | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index dde553cec..feb0da46b 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit import MC1Services /// Reusable chat input bar with configurable styling @@ -12,7 +13,6 @@ struct ChatInputBar: View { let onSend: (String) -> Void @State private var isCoolingDown = false - @State private var textFieldID = UUID() private var byteCount: Int { text.utf8.count @@ -30,7 +30,6 @@ struct ChatInputBar: View { var body: some View { HStack(alignment: .bottom, spacing: 12) { ChatInputTextField(text: $text, placeholder: placeholder, isFocused: $isFocused, isEncrypted: isEncrypted) - .id(textFieldID) ChatSendButtonWithCounter( canSend: canSend, isOverLimit: isOverLimit, @@ -78,8 +77,6 @@ struct ChatInputBar: View { guard !captured.isEmpty else { return } isCoolingDown = true text = "" - textFieldID = UUID() - isFocused = true onSend(captured) Task { try? await Task.sleep(for: .seconds(1)) @@ -98,6 +95,7 @@ private struct ChatInputTextField: View { var body: some View { TextField(placeholder, text: $text, axis: .vertical) + .background(InlinePredictionFix()) .textFieldStyle(.plain) .padding(.leading, 12) .padding(.trailing, 28) @@ -184,6 +182,52 @@ private struct ChatSendButton: View { } } +// MARK: - Inline Prediction Fix (FB13727682) + +/// Finds the backing UITextView of a `TextField(axis: .vertical)` and disables +/// inline predictions, which leave ghost-text that survives binding clears. +private struct InlinePredictionFix: UIViewRepresentable { + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + view.isHidden = true + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + guard !context.coordinator.applied else { return } + DispatchQueue.main.async { + if let textView = Self.findTextView(from: uiView) { + textView.inlinePredictionType = .no + context.coordinator.applied = true + } + } + } + + private static func findTextView(from view: UIView) -> UITextView? { + var ancestor: UIView? = view.superview + while let parent = ancestor { + if let found = firstTextView(in: parent) { return found } + ancestor = parent.superview + } + return nil + } + + private static func firstTextView(in view: UIView) -> UITextView? { + if let textView = view as? UITextView { return textView } + for subview in view.subviews { + if let found = firstTextView(in: subview) { return found } + } + return nil + } + + final class Coordinator { + var applied = false + } +} + // MARK: - Platform-Conditional Styling private extension View { From 0e07b65b8cdd3b77c7562c986f694b6f49cf1a0d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:20:38 -0700 Subject: [PATCH 26/48] fix(chats): keep keyboard visible when sending first message - Replace three-way Group { if/else } branch with messagesTable always rendered and empty state overlaid on top - The previous branch swap tore down and recreated ChatTableView on first message, disrupting SwiftUI focus tracking and dismissing the keyboard --- .../ChatConversationMessagesContent.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 964413ff2..31d1e92cd 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -59,15 +59,17 @@ struct ChatConversationMessagesContent: View { // MARK: - Body var body: some View { - Group { - if !viewModel.hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.messages.isEmpty { - emptyState - } else { - messagesTable - } + if !viewModel.hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + messagesTable + .overlay { + if viewModel.messages.isEmpty { + emptyState + .allowsHitTesting(false) + } + } } } From 0419bd1aefb64d307581ae11befbda7d6a489c62 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:19:33 -0700 Subject: [PATCH 27/48] feat(repeater): add region configuration to management view - Add Regions section to repeater settings with ExpandableSettingsSection - Fetch region list via bare `region` CLI command (tree output parser) - Support flood allow/deny toggles, home region picker, add/remove regions - Explicit Save to Repeater step with unsaved-changes tracking - Uses FIFO matching for region/home commands whose responses don't fit CLIResponse patterns - Add 19 L10n keys across all 9 languages --- MC1/Resources/Generated/L10n.swift | 40 +++ .../Localization/de.lproj/RemoteNodes.strings | 59 ++++ .../Localization/en.lproj/RemoteNodes.strings | 59 ++++ .../Localization/es.lproj/RemoteNodes.strings | 59 ++++ .../Localization/fr.lproj/RemoteNodes.strings | 59 ++++ .../Localization/nl.lproj/RemoteNodes.strings | 59 ++++ .../Localization/pl.lproj/RemoteNodes.strings | 59 ++++ .../Localization/ru.lproj/RemoteNodes.strings | 59 ++++ .../Localization/uk.lproj/RemoteNodes.strings | 59 ++++ .../zh-Hans.lproj/RemoteNodes.strings | 59 ++++ .../RemoteNodes/RepeaterSettingsView.swift | 135 +++++++++ .../RepeaterSettingsViewModel.swift | 258 +++++++++++++++++- 12 files changed, 961 insertions(+), 3 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 5fb238f5a..97f87b183 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2615,6 +2615,10 @@ public enum L10n { public static let rebootMessage = L10n.tr("RemoteNodes", "remoteNodes.settings.rebootMessage", fallback: "The repeater will restart and be temporarily unavailable.") /// Location: RepeaterSettingsViewModel.swift - Reboot sent success public static let rebootSent = L10n.tr("RemoteNodes", "remoteNodes.settings.rebootSent", fallback: "Reboot command sent") + /// Location: RepeaterSettingsView.swift - Regions section title + public static let regions = L10n.tr("RemoteNodes", "remoteNodes.settings.regions", fallback: "Regions") + /// Location: RepeaterSettingsView.swift - Regions section footer + public static let regionsFooter = L10n.tr("RemoteNodes", "remoteNodes.settings.regionsFooter", fallback: "Save to Repeater to keep changes across restarts.") /// Location: RepeaterSettingsView.swift - Repeater mode toggle public static let repeaterMode = L10n.tr("RemoteNodes", "remoteNodes.settings.repeaterMode", fallback: "Repeater Mode") /// Location: RepeaterSettingsView.swift - Security section title @@ -2663,6 +2667,42 @@ public enum L10n { return L10n.tr("RemoteNodes", "remoteNodes.settings.accessibility.spreadingFactorLabel", p1, fallback: "Spreading factor %d") } } + public enum Regions { + /// Location: RepeaterSettingsViewModel.swift - Region add failure + public static let addFailed = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addFailed", fallback: "Failed to add region") + /// Location: RepeaterSettingsView.swift - Add region button + public static let addRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegion", fallback: "Add Region") + /// Location: RepeaterSettingsView.swift - Add region alert title + public static let addRegionTitle = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegionTitle", fallback: "Add Region") + /// Location: RepeaterSettingsView.swift - Toggle label for flood allow per region + public static let allowFlood = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allowFlood", fallback: "Allow Flood Traffic") + /// Location: RepeaterSettingsView.swift - Wildcard region display name + public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "All Traffic") + /// Location: RepeaterSettingsView.swift - Wildcard with asterisk display + public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (All Traffic)") + /// Location: RepeaterSettingsViewModel.swift - No regions on device + public static let empty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.empty", fallback: "No regions configured") + /// Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle + public static let floodToggleHint = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.floodToggleHint", fallback: "When off, flood packets from this region are dropped") + /// Location: RepeaterSettingsView.swift - Home region picker label + public static let homeRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.homeRegion", fallback: "Home Region") + /// Location: RepeaterSettingsView.swift - No home region set + public static let noHome = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.noHome", fallback: "None") + /// Location: RepeaterSettingsViewModel.swift - Region has children error + public static let notEmpty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.notEmpty", fallback: "Remove child regions first") + /// Location: RepeaterSettingsView.swift - Region name placeholder + public static let regionName = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.regionName", fallback: "Region name") + /// Location: RepeaterSettingsViewModel.swift - Region remove failure + public static let removeFailed = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.removeFailed", fallback: "Failed to remove region") + /// Location: RepeaterSettingsViewModel.swift - Region save failure + public static let saveFailed = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.saveFailed", fallback: "Failed to save regions") + /// Location: RepeaterSettingsViewModel.swift - Region save success + public static let saveSuccess = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.saveSuccess", fallback: "Regions saved to device") + /// Location: RepeaterSettingsView.swift - Save regions to device button + public static let saveToDevice = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.saveToDevice", fallback: "Save to Repeater") + /// Location: RepeaterSettingsViewModel.swift - Region not found error + public static let unknownRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.unknownRegion", fallback: "Unknown region") + } } public enum Status { /// Location: RepeaterStatusView.swift - Battery label diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index ad0d9e50e..099ce29e5 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akzeptiert 0-64 Sprünge"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index c9d14e63f..c31e5c0bc 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepts 0-64 hops"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 401efffff..8c175c8cd 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Acepta 0-64 saltos"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 44654e032..f37dff9ff 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepte 0-64 sauts"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 7fd47ec26..957e2c459 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepteert 0-64 sprongen"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 63df831b4..60a94b5f4 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -310,6 +310,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akceptuje 0-64 skoków"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 8146ab9a5..1e198243b 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -310,6 +310,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимые значения: 0-64 переходов"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 3dc98f952..e77de19f2 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -310,6 +310,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимі значення: 0–64 переходів"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index deb83df6d..21e68442a 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -313,6 +313,65 @@ /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "接受 0-64 跳"; +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regions"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; + +/* Location: RepeaterSettingsView.swift - Wildcard region display name */ +"remoteNodes.settings.regions.allTraffic" = "All Traffic"; + +/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Home Region"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +"remoteNodes.settings.regions.noHome" = "None"; + +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +"remoteNodes.settings.regions.allowFlood" = "Allow Flood Traffic"; + +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "When off, flood packets from this region are dropped"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Add Region"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Region name"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Save to Repeater"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +"remoteNodes.settings.regions.saveSuccess" = "Regions saved to device"; + +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Failed to save regions"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Unknown region"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remove child regions first"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Failed to add region"; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "No regions configured"; + // MARK: - Repeater Status /* Location: RepeaterStatusView.swift - Navigation title */ diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift index 3f97d2245..aaf5334de 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift @@ -34,6 +34,7 @@ struct RepeaterSettingsView: View { makeIdentitySection() makeContactInfoSection() makeBehaviorSection() + makeRegionsSection() makeSecuritySection() makeDeviceInfoSection() makeActionsSection() @@ -106,6 +107,10 @@ struct RepeaterSettingsView: View { BehaviorSection(viewModel: viewModel, focusedField: $focusedField) } + private func makeRegionsSection() -> some View { + RegionsSection(viewModel: viewModel) + } + private func makeSecuritySection() -> some View { SecuritySection(viewModel: viewModel) } @@ -646,6 +651,136 @@ private struct BehaviorSection: View { } } +// MARK: - Regions Section + +private struct RegionsSection: View { + @Bindable var viewModel: RepeaterSettingsViewModel + + /// Regions sorted: wildcard first, then alphabetical + private var sortedRegions: [RepeaterRegionEntry] { + viewModel.regions.sorted { lhs, rhs in + if lhs.isWildcard { return true } + if rhs.isWildcard { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + /// Display name for a region entry + private func displayName(for region: RepeaterRegionEntry) -> String { + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTrafficWildcard + : region.name + } + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.regions, + icon: "globe", + isExpanded: $viewModel.isRegionsExpanded, + isLoaded: { viewModel.regionsLoaded }, + isLoading: $viewModel.isLoadingRegions, + error: $viewModel.regionsError, + onLoad: { await viewModel.fetchRegions() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter + ) { + if viewModel.regionsLoaded && viewModel.regions.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.empty) + .foregroundStyle(.secondary) + } + + // Home region picker + if !viewModel.regions.isEmpty { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.Regions.homeRegion, selection: Binding( + get: { + viewModel.regions.first(where: \.isHome)?.name + ?? RepeaterSettingsViewModel.wildcardName + }, + set: { newValue in + Task { await viewModel.setHomeRegion(name: newValue) } + } + )) { + ForEach(sortedRegions) { region in + Text(displayName(for: region)) + .tag(region.name) + } + } + .pickerStyle(.menu) + .tint(.primary) + } + + // Region list with flood toggles + ForEach(sortedRegions) { region in + Toggle( + displayName(for: region), + isOn: Binding( + get: { region.floodAllowed }, + set: { _ in + Task { await viewModel.toggleRegionFlood(name: region.name) } + } + ) + ) + .accessibilityLabel( + region.isWildcard + ? L10n.RemoteNodes.RemoteNodes.Settings.Regions.allTraffic + : region.name + ) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) + .disabled(viewModel.isApplying) + } + .onDelete { offsets in + let sorted = sortedRegions + for offset in offsets { + let region = sorted[offset] + guard !region.isWildcard else { continue } + Task { await viewModel.removeRegion(name: region.name) } + } + } + + // Add region button + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { + viewModel.isAddingRegion = true + } + .disabled(viewModel.isApplying) + + // Save to device button + if viewModel.regionsLoaded { + Button { + Task { await viewModel.saveRegions() } + } label: { + HStack { + Spacer() + if viewModel.isApplying { + ProgressView() + } else if viewModel.regionsSaveSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveToDevice) + .foregroundStyle(viewModel.hasUnsavedRegionChanges ? Color.accentColor : .secondary) + .transition(.opacity) + } + Spacer() + } + .animation(.default, value: viewModel.regionsSaveSuccess) + } + .disabled(viewModel.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { + Task { await viewModel.addRegion(name: viewModel.newRegionName) } + } + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { + viewModel.newRegionName = "" + } + } + } +} + // MARK: - Security Section private struct SecuritySection: View { diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift index a698b6dc5..9d5fb321c 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift @@ -105,6 +105,18 @@ final class RepeaterSettingsViewModel { var floodAdvertIntervalError: String? var floodMaxHopsError: String? + // Region settings (from bare "region" CLI command) + nonisolated static let wildcardName = "*" + var regions: [RepeaterRegionEntry] = [] + private var originalRegions: [RepeaterRegionEntry]? + var isLoadingRegions = false + var regionsError: String? + var regionsLoaded: Bool { originalRegions != nil } + var hasUnsavedRegionChanges = false + var isAddingRegion = false + var newRegionName = "" + var regionsSaveSuccess = false + // Password change (no query available) var newPassword: String = "" var confirmPassword: String = "" @@ -115,6 +127,7 @@ final class RepeaterSettingsViewModel { var isIdentityExpanded = false var isContactInfoExpanded = false var isBehaviorExpanded = false + var isRegionsExpanded = false var isSecurityExpanded = false // State @@ -164,15 +177,26 @@ final class RepeaterSettingsViewModel { /// - Parameters: /// - command: The CLI command to send (e.g., "get name", "ver") /// - timeout: Maximum time to wait for response (default 5 seconds) + /// - rawMatching: Use FIFO matching instead of content-based matching. + /// Required for commands whose responses don't match any CLIResponse pattern + /// (e.g., bare `region` tree output, `region home` responses). /// - Returns: The raw response text from the repeater /// - Throws: RepeaterSettingsError.timeout if no response received - private func sendAndWait(_ command: String, timeout: Duration = .seconds(5)) async throws -> String { + private func sendAndWait( + _ command: String, + timeout: Duration = .seconds(5), + rawMatching: Bool = false + ) async throws -> String { guard let session, let service = repeaterAdminService else { throw RepeaterSettingsError.noService } - // Service now handles response collection and returns directly - let response = try await service.sendCommand(sessionID: session.id, command: command, timeout: timeout) + let response: String + if rawMatching { + response = try await service.sendRawCommand(sessionID: session.id, command: command, timeout: timeout) + } else { + response = try await service.sendCommand(sessionID: session.id, command: command, timeout: timeout) + } logger.debug("Command '\(command)' response: \(response.prefix(50))") return response } @@ -357,6 +381,20 @@ final class RepeaterSettingsViewModel { } } } + + // Regions - only process if finished loading with error + if !isLoadingRegions && regionsError != nil { + if originalRegions == nil { + let parsed = Self.parseRegionTree(response) + if !parsed.isEmpty { + self.regions = parsed + self.originalRegions = parsed + self.regionsError = nil + logger.info("Late response: received region tree (\(parsed.count) regions)") + return + } + } + } } // MARK: - Fetch Methods (Pull-to-Load) @@ -885,6 +923,220 @@ final class RepeaterSettingsViewModel { isApplying = false } + + // MARK: - Region Methods + + /// Fetch regions from the repeater using bare `region` command (tree output) + func fetchRegions() async { + isLoadingRegions = true + regionsError = nil + + do { + let treeResponse = try await sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let parsed = Self.parseRegionTree(treeResponse) + self.regions = parsed + self.originalRegions = parsed + logger.debug("Fetched \(parsed.count) regions from tree output") + } catch { + if case RemoteNodeError.timeout = error { + regionsError = "error" + } + logger.warning("Failed to fetch regions: \(error)") + } + + isLoadingRegions = false + } + + /// Parse the tree output from bare `region` command into region entries. + /// + /// Format per line: `{spaces}{name}{^?}{ F?}` + /// - Leading spaces = hierarchy depth + /// - `^` suffix = home region + /// - ` F` suffix (with trailing newline stripped) = flood allowed + /// - `*` = wildcard root + static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { + var entries: [RepeaterRegionEntry] = [] + let lines = response.split(separator: "\n", omittingEmptySubsequences: true) + + for line in lines { + var text = String(line) + + // Strip leading spaces + text = String(text.drop(while: { $0 == " " })) + guard !text.isEmpty else { continue } + + // Check for " F" suffix (flood allowed) + let floodAllowed: Bool + if text.hasSuffix(" F") { + floodAllowed = true + text = String(text.dropLast(2)) + } else { + floodAllowed = false + } + + // Check for "^" suffix (home region) + let isHome: Bool + if text.hasSuffix("^") { + isHome = true + text = String(text.dropLast(1)) + } else { + isHome = false + } + + guard !text.isEmpty else { continue } + + entries.append(RepeaterRegionEntry( + name: text, + floodAllowed: floodAllowed, + isHome: isHome + )) + } + + return entries + } + + /// Toggle flood allow/deny for a region + func toggleRegionFlood(name: String) async { + guard let index = regions.firstIndex(where: { $0.name == name }) else { return } + let currentlyAllowed = regions[index].floodAllowed + let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" + + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait(command) + if case .ok = CLIResponse.parse(response) { + regions[index].floodAllowed = !currentlyAllowed + hasUnsavedRegionChanges = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + /// Set the home region + func setHomeRegion(name: String) async { + let command = "region home \(name)" + + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait(command, rawMatching: true) + if response.contains("home is now") { + // Clear old home, set new + for i in regions.indices { + regions[i].isHome = (regions[i].name == name) + } + hasUnsavedRegionChanges = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + /// Add a new region to the repeater + func addRegion(name: String) async { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { + switch validationError { + case .empty: return + case .invalidCharacters, .invalidPrefix, .duplicate: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + return + } + + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("region put \(trimmed)") + if case .ok = CLIResponse.parse(response) { + // New regions default to flood-denied on the firmware + regions.append(RepeaterRegionEntry( + name: trimmed, + floodAllowed: false, + isHome: false + )) + hasUnsavedRegionChanges = true + newRegionName = "" + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + /// Remove a region from the repeater + func removeRegion(name: String) async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("region remove \(name)") + if case .ok = CLIResponse.parse(response) { + regions.removeAll { $0.name == name } + hasUnsavedRegionChanges = true + } else if response.contains("not empty") { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + /// Save region configuration to device flash + func saveRegions() async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("region save") + if case .ok = CLIResponse.parse(response) { + hasUnsavedRegionChanges = false + withAnimation { + isApplying = false + regionsSaveSuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { regionsSaveSuccess = false } + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } +} + +// MARK: - Region Entry + +struct RepeaterRegionEntry: Identifiable, Equatable { + var id: String { name } + let name: String + var floodAllowed: Bool + var isHome: Bool + var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } } // MARK: - Error Types From 91cc07292dce30f5f993a6feecee227624d555fe Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:41:08 -0700 Subject: [PATCH 28/48] feat(contacts): add paste URL option to Add Contact sheet - Add MeshCoreURLParser utility for parsing meshcore:// URLs - Add paste button to AddContactSheet that reads clipboard and auto-fills name, key, and type fields - Add localized strings for paste URL button, footer, and error across all 9 locales --- MC1/Resources/Generated/L10n.swift | 52 +++++++++++++- .../Localization/de.lproj/Contacts.strings | 12 ++++ .../Localization/en.lproj/Contacts.strings | 12 ++++ .../Localization/es.lproj/Contacts.strings | 12 ++++ .../Localization/fr.lproj/Contacts.strings | 12 ++++ .../Localization/nl.lproj/Contacts.strings | 12 ++++ .../Localization/pl.lproj/Contacts.strings | 12 ++++ .../Localization/ru.lproj/Contacts.strings | 12 ++++ .../Localization/uk.lproj/Contacts.strings | 12 ++++ .../zh-Hans.lproj/Contacts.strings | 12 ++++ MC1/Utilities/MeshCoreURLParser.swift | 72 +++++++++++++++++++ MC1/Views/Contacts/AddContactSheet.swift | 39 +++++++++- 12 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 MC1/Utilities/MeshCoreURLParser.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 97f87b183..f8856f482 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -949,6 +949,10 @@ public enum L10n { } /// Location: AddContactSheet.swift - Purpose: Name section header public static let name = L10n.tr("Contacts", "contacts.add.name", fallback: "Name") + /// Location: AddContactSheet.swift - Purpose: Paste URL button label + public static let pasteURL = L10n.tr("Contacts", "contacts.add.pasteURL", fallback: "Paste Contact URL") + /// Location: AddContactSheet.swift - Purpose: Paste URL section footer + public static let pasteURLFooter = L10n.tr("Contacts", "contacts.add.pasteURLFooter", fallback: "Paste a meshcore:// contact link to auto-fill the fields above") /// Location: AddContactSheet.swift - Purpose: Public key section header public static let publicKey = L10n.tr("Contacts", "contacts.add.publicKey", fallback: "Public Key") /// Location: AddContactSheet.swift - Purpose: Public key footer @@ -970,6 +974,8 @@ public enum L10n { public static func invalidSize(_ p1: Int, _ p2: Int) -> String { return L10n.tr("Contacts", "contacts.add.error.invalidSize", p1, p2, fallback: "Public key must be %d bytes (%d hex characters)") } + /// Location: AddContactSheet.swift - Purpose: Invalid paste URL error + public static let invalidURL = L10n.tr("Contacts", "contacts.add.error.invalidURL", fallback: "Clipboard does not contain a valid contact URL") /// Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error with max count public static func nodeListFull(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.add.error.nodeListFull", p1, fallback: "Node list is full (max %d nodes)") @@ -1077,6 +1083,8 @@ public enum L10n { public static let openInMaps = L10n.tr("Contacts", "contacts.detail.openInMaps", fallback: "Open in Maps") /// Location: ContactDetailView.swift - Purpose: Footer for path routing public static let pathFooter = L10n.tr("Contacts", "contacts.detail.pathFooter", fallback: "Messages route through the path shown. Reset Path to use flood routing instead.") + /// Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes + public static let ping = L10n.tr("Contacts", "contacts.detail.ping", fallback: "Ping") /// Location: ContactDetailView.swift - Purpose: Ping failure VoiceOver announcement public static let pingFailureAnnouncement = L10n.tr("Contacts", "contacts.detail.pingFailureAnnouncement", fallback: "Ping failed") /// Location: ContactDetailView.swift - Purpose: Ping failure accessibility label @@ -2374,7 +2382,7 @@ public enum L10n { /// Location: NodeStatusHistoryView.swift - Noise floor chart title public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.history.noiseFloor", fallback: "Noise Floor") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist - public static let noSnapshotsMessage = L10n.tr("RemoteNodes", "remoteNodes.history.noSnapshotsMessage", fallback: "Connect to this repeater at least once to see history.") + public static let noSnapshotsMessage = L10n.tr("RemoteNodes", "remoteNodes.history.noSnapshotsMessage", fallback: "Connect to this node at least once to see history.") /// Location: NeighborRow - Not seen status public static let notSeen = L10n.tr("RemoteNodes", "remoteNodes.history.notSeen", fallback: "Not seen") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title @@ -2455,6 +2463,8 @@ public enum L10n { public static let infoTitle = L10n.tr("RemoteNodes", "remoteNodes.room.infoTitle", fallback: "Room Info") /// Location: RoomConversationView.swift - Last connected label public static let lastConnected = L10n.tr("RemoteNodes", "remoteNodes.room.lastConnected", fallback: "Last Connected") + /// Location: RoomInfoSheet.swift - Management button + public static let management = L10n.tr("RemoteNodes", "remoteNodes.room.management", fallback: "Management") /// Location: RoomConversationView.swift - Empty state title public static let noMessagesYet = L10n.tr("RemoteNodes", "remoteNodes.room.noMessagesYet", fallback: "No public messages yet") /// Location: RoomConversationView.swift - Permission label @@ -2467,6 +2477,8 @@ public enum L10n { public static let reconnected = L10n.tr("RemoteNodes", "remoteNodes.room.reconnected", fallback: "Room reconnected") /// Location: RoomConversationView.swift - Status label public static let status = L10n.tr("RemoteNodes", "remoteNodes.room.status", fallback: "Status") + /// Location: RoomInfoSheet.swift - Telemetry button + public static let telemetry = L10n.tr("RemoteNodes", "remoteNodes.room.telemetry", fallback: "Telemetry") /// Location: RoomConversationView.swift - Read-only banner public static let viewOnlyBanner = L10n.tr("RemoteNodes", "remoteNodes.room.viewOnlyBanner", fallback: "View only - join as member to post") /// Location: RoomConversationView.swift - Hint text for read-only banner @@ -2484,6 +2496,44 @@ public enum L10n { } } } + public enum RoomSettings { + /// Location: RoomSettingsView.swift - Allow read-only toggle label + public static let allowReadOnly = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.allowReadOnly", fallback: "Allow Read-Only") + /// Location: RoomSettingsView.swift - Allow read-only footer + public static let allowReadOnlyFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.allowReadOnlyFooter", fallback: "Allow users without a password to connect in read-only mode.") + /// Location: RoomSettingsView.swift - Apply room settings button + public static let applyRoomSettings = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.applyRoomSettings", fallback: "Apply Room Settings") + /// Location: RoomSettingsView.swift - Clock ahead error + public static let clockAheadError = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.clockAheadError", fallback: "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again.") + /// Location: RoomSettingsView.swift - Guest password label + public static let guestPassword = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.guestPassword", fallback: "Guest Password") + /// Location: RoomSettingsView.swift - Identity section footer + public static let identityFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.identityFooter", fallback: "Room name and GPS coordinates for map display.") + /// Location: RoomSettingsView.swift - No service error + public static let noService = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.noService", fallback: "Room service not available") + /// Location: RoomSettingsView.swift - Not connected error + public static let notConnected = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.notConnected", fallback: "Not connected to room") + /// Location: RoomSettingsView.swift - Radio restart warning + public static let radioRestartWarning = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.radioRestartWarning", fallback: "Applying these changes will restart the room") + /// Location: RoomSettingsView.swift - Reboot confirmation title + public static let rebootConfirmTitle = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.rebootConfirmTitle", fallback: "Reboot Room?") + /// Location: RoomSettingsView.swift - Reboot confirmation message + public static let rebootMessage = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.rebootMessage", fallback: "The room will restart and be temporarily unavailable.") + /// Location: RoomSettingsView.swift - Room settings section footer + public static let roomSettingsFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.roomSettingsFooter", fallback: "Guest access, advertisement intervals, and flood hops.") + /// Location: RoomSettingsView.swift - Room settings section header + public static let roomSettingsSection = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.roomSettingsSection", fallback: "Room Settings") + /// Location: RoomSettingsView.swift - Navigation title + public static let title = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.title", fallback: "Room Settings") + } + public enum RoomStatus { + /// Location: RoomStatusView.swift - Posts pushed label + public static let postsPushed = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.postsPushed", fallback: "Posts Pushed") + /// Location: RoomStatusView.swift - Posts received label + public static let postsReceived = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.postsReceived", fallback: "Posts Received") + /// Location: RoomStatusView.swift - Navigation title + public static let title = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.title", fallback: "Room Status") + } public enum Settings { /// Location: RepeaterSettingsView.swift - Advert interval (0-hop) label public static let advertInterval0Hop = L10n.tr("RemoteNodes", "remoteNodes.settings.advertInterval0Hop", fallback: "Advert Interval (0-hop)") diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 0af64b20b..98d0214fd 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Repeater anpingen"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Keine Antwort"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 37c5aaa2e..5e7dc7704 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Ping Repeater"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "No response"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 8ac337b4a..26764c831 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Ping al repetidor"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Sin respuesta"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 439cfc1fb..bbbbfc454 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Ping du répéteur"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Pas de réponse"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 3a6c3c509..827b2281f 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Ping repeater"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Geen reactie"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 17cea10c4..e88734cdb 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Pinguj przekaźnik"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Brak odpowiedzi"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 60ee27037..eed626315 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Пинг ретранслятора"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Нет ответа"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 8fa4ff044..1ac2da435 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Пінг ретранслятора"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Немає відповіді"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "Node list is full"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 6152c7e30..b7d327e70 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -233,6 +233,9 @@ /* Location: ContactDetailView.swift - Purpose: Ping repeater button */ "contacts.detail.pingRepeater" = "Ping 转发节点"; +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "无响应"; @@ -429,6 +432,15 @@ /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ "contacts.add.error.nodeListFullSimple" = "节点列表已满"; +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Paste Contact URL"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Paste a meshcore:// contact link to auto-fill the fields above"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "Clipboard does not contain a valid contact URL"; + // MARK: - Contact QR Share Sheet /* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ diff --git a/MC1/Utilities/MeshCoreURLParser.swift b/MC1/Utilities/MeshCoreURLParser.swift new file mode 100644 index 000000000..e98948e51 --- /dev/null +++ b/MC1/Utilities/MeshCoreURLParser.swift @@ -0,0 +1,72 @@ +import Foundation +import MC1Services + +/// Parses meshcore:// deep link URLs for channel and contact imports. +enum MeshCoreURLParser { + + /// Parsed channel data from a meshcore://channel/add URL + struct ChannelResult { + let name: String + let secret: Data + } + + /// Parsed contact data from a meshcore://contact/add URL + struct ContactResult { + let name: String + let publicKey: Data + let contactType: ContactType + } + + /// Parses a meshcore://channel/add URL string. + /// Returns nil if the string is not a valid channel URL. + static func parseChannelURL(_ string: String) -> ChannelResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "channel", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil + } + + let name = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let secretHex = queryItems.first(where: { $0.name == "secret" })?.value ?? "" + + guard !name.isEmpty, + let secretData = Data(hexString: secretHex), + secretData.count == 16 else { + return nil + } + + return ChannelResult(name: name, secret: secretData) + } + + /// Parses a meshcore://contact/add URL string. + /// Returns nil if the string is not a valid contact URL. + static func parseContactURL(_ string: String) -> ContactResult? { + guard let url = URL(string: string), + url.scheme == "meshcore", + url.host() == "contact", + url.path() == "/add", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return nil + } + + // URLQueryItem decodes %20 but not + (form-urlencoded spaces) + let rawName = queryItems.first(where: { $0.name == "name" })?.value ?? "" + let name = rawName.replacing("+", with: " ") + let publicKeyHex = queryItems.first(where: { $0.name == "public_key" })?.value ?? "" + + guard !name.isEmpty, + let keyData = Data(hexString: publicKeyHex), + keyData.count == ProtocolLimits.publicKeySize else { + return nil + } + + let typeValue = queryItems.first(where: { $0.name == "type" })?.value.flatMap { Int($0) } ?? 1 + let contactType = ContactType(rawValue: UInt8(typeValue)) ?? .chat + + return ContactResult(name: name, publicKey: keyData, contactType: contactType) + } +} diff --git a/MC1/Views/Contacts/AddContactSheet.swift b/MC1/Views/Contacts/AddContactSheet.swift index 8f6028d9e..f6a32d4a1 100644 --- a/MC1/Views/Contacts/AddContactSheet.swift +++ b/MC1/Views/Contacts/AddContactSheet.swift @@ -47,6 +47,13 @@ struct AddContactSheet: View { isValid: isValidPublicKey ) + PasteURLSection { result in + contactName = result.name + publicKeyHex = result.publicKey.hex + selectedType = result.contactType + errorMessage = nil + } + if let errorMessage { ErrorSection(message: errorMessage) } @@ -111,7 +118,7 @@ struct AddContactSheet: View { errorMessage = nil do { - let currentTimestamp = UInt32(Date().timeIntervalSince1970) + let currentTimestamp = UInt32(Date.now.timeIntervalSince1970) let contactFrame = ContactFrame( publicKey: publicKeyData, @@ -263,6 +270,36 @@ private struct ErrorSection: View { } } +// MARK: - Paste URL Section + +private struct PasteURLSection: View { + let onParsed: (MeshCoreURLParser.ContactResult) -> Void + + @State private var showError = false + + var body: some View { + Section { + Button(L10n.Contacts.Contacts.Add.pasteURL, systemImage: "doc.on.clipboard") { + guard let clipboard = UIPasteboard.general.string, + let result = MeshCoreURLParser.parseContactURL(clipboard) else { + showError = true + return + } + showError = false + onParsed(result) + } + + if showError { + Text(L10n.Contacts.Contacts.Add.Error.invalidURL) + .foregroundStyle(.red) + .font(.caption) + } + } footer: { + Text(L10n.Contacts.Contacts.Add.pasteURLFooter) + } + } +} + #Preview { AddContactSheet() .environment(\.appState, AppState()) From c82533715b836e34bbf7c88a908da1909ae5b760 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:36:05 -0700 Subject: [PATCH 29/48] fix(chats): decode pathLength before displaying hop count and direct state - Add hopCount and isDirect computed properties to MessageDTO - 0-hop messages with 2-byte hashes (pathLength 0x40) were shown as "64 hops" because the encoded byte was displayed raw instead of decoded - Update isDirect checks, hop count display, path formatter, and action availability to use the decoded values --- MC1/Views/Chats/Components/MessagePathFormatter.swift | 4 ++-- MC1/Views/Chats/Components/UnifiedMessageBubble.swift | 10 +++++----- .../Chats/Reactions/MessageActionAvailability.swift | 3 +-- MC1/Views/Chats/Reactions/MessageActionsSheet.swift | 10 ++++------ MC1Services/Sources/MC1Services/Models/Message.swift | 10 ++++++++++ 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/MC1/Views/Chats/Components/MessagePathFormatter.swift b/MC1/Views/Chats/Components/MessagePathFormatter.swift index ac2646a7c..e0ffbb8c0 100644 --- a/MC1/Views/Chats/Components/MessagePathFormatter.swift +++ b/MC1/Views/Chats/Components/MessagePathFormatter.swift @@ -7,8 +7,8 @@ enum MessagePathFormatter { /// - Parameter message: The message DTO containing path information /// - Returns: Formatted path string (e.g., "Direct", "A3,7F,42", or "A3,7F…B2,C1") static func format(_ message: MessageDTO) -> String { - // Direct or unknown path - if message.pathLength == 0 || message.pathLength == 0xFF { + // Direct or flood path + if message.isDirect { return L10n.Chats.Chats.Message.Path.direct } diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index d0ce208c8..f79e57265 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -181,7 +181,7 @@ private struct BubbleContent: View { } private var isDirect: Bool { - message.pathLength == 0 || message.pathLength == 0xFF + message.isDirect } var body: some View { @@ -192,7 +192,7 @@ private struct BubbleContent: View { if !message.isOutgoing && (displayState.showIncomingHopCount && !isDirect || displayState.showIncomingPath) { HStack(spacing: 4) { if displayState.showIncomingHopCount && !isDirect { - BubbleHopCountFooter(pathLength: message.pathLength) + BubbleHopCountFooter(hopCount: message.hopCount) } if displayState.showIncomingPath { BubblePathFooter(message: message) @@ -425,17 +425,17 @@ private struct BubblePathFooter: View { } private struct BubbleHopCountFooter: View { - let pathLength: UInt8 + let hopCount: Int var body: some View { HStack(spacing: 4) { Image(systemName: "arrowshape.bounce.right") - Text("\(pathLength)") + Text("\(hopCount)") } .font(.caption2) // Not monospaced - only hex paths need alignment .foregroundStyle(.secondary) .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(Int(pathLength))) + .accessibilityLabel(L10n.Chats.Chats.Message.HopCount.accessibilityLabel(hopCount)) } } diff --git a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift index 7100b6a0d..1465b206b 100644 --- a/MC1/Views/Chats/Reactions/MessageActionAvailability.swift +++ b/MC1/Views/Chats/Reactions/MessageActionAvailability.swift @@ -19,8 +19,7 @@ struct MessageActionAvailability { canShowRepeatDetails = message.isOutgoing && message.heardRepeats > 0 canViewPath = !message.isOutgoing && message.pathNodes != nil - && message.pathLength != 0 - && message.pathLength != 0xFF + && !message.isDirect canDelete = true } } diff --git a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift index 865ad9b29..316d04fa6 100644 --- a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift @@ -434,7 +434,7 @@ private struct ActionsIncomingDetailsRows: View { var body: some View { ActionInfoRow( - text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message.pathLength)), + text: L10n.Chats.Chats.Message.Info.hops(hopCountFormatted(message)), icon: "arrowshape.bounce.right" ) @@ -468,13 +468,11 @@ private struct ActionsIncomingDetailsRows: View { return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" } - private func hopCountFormatted(_ pathLength: UInt8) -> String { - switch pathLength { - case 0, 0xFF: + private func hopCountFormatted(_ message: MessageDTO) -> String { + if message.isDirect { return L10n.Chats.Chats.Message.Hops.direct - default: - return "\(pathLength)" } + return "\(message.hopCount)" } } diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index a9eb3f5ca..ed7407c95 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -445,6 +445,16 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable { Date(timeIntervalSince1970: TimeInterval(timestamp)) } + /// Hop count decoded from pathLength (lower 6 bits) + public var hopCount: Int { + decodePathLen(pathLength)?.hopCount ?? Int(pathLength & 63) + } + + /// Whether this message was received directly (0 hops) or via flood routing + public var isDirect: Bool { + pathLength == 0xFF || hopCount == 0 + } + /// Hash size per hop in bytes (1, 2, or 3), derived from pathLength upper 2 bits public var pathHashSize: Int { decodePathLen(pathLength)?.hashSize ?? 1 From 404b4e7b20bdfbe8687a33d0241ef279e297a621 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:48:49 -0700 Subject: [PATCH 30/48] feat(location): use device location as fallback when phone location unavailable - Add DeviceDTO.hasLocation (with CLLocationCoordinate2DIsValid, matching ContactDTO) - Add AppState.bestAvailableLocation: phone GPS first, device location if set - Fix TracePathMapView onChange guard to skip rebuildOverlays when coordinates unchanged - Update all proximity/sorting call sites to use bestAvailableLocation --- MC1/State/AppState.swift | 12 ++++++++++++ MC1/Views/Chats/BlockSenderSheet.swift | 2 +- .../Chats/Reactions/MessageActionsSheet.swift | 4 ++-- MC1/Views/Contacts/ContactsCompactList.swift | 2 +- MC1/Views/Contacts/ContactsListView.swift | 4 ++-- MC1/Views/Contacts/ContactsSplitList.swift | 2 +- MC1/Views/Contacts/DiscoveryView.swift | 6 +++--- .../Contacts/TracePathMap/TracePathMapView.swift | 10 ++++++---- MC1/Views/Contacts/TracePathViewModel.swift | 16 ++++------------ MC1/Views/Map/MapView.swift | 2 +- .../Sources/MC1Services/Models/Device.swift | 9 +++++++++ 11 files changed, 42 insertions(+), 27 deletions(-) diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index dccc78799..726e09212 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -1,3 +1,4 @@ +import CoreLocation import SwiftUI import SwiftData import UserNotifications @@ -23,6 +24,17 @@ public final class AppState { /// App-wide location service for permission management public let locationService = LocationService() + /// Best available location for proximity-based disambiguation. + public var bestAvailableLocation: CLLocation? { + if let phoneLocation = locationService.currentLocation { + return phoneLocation + } + guard let device = connectedDevice, device.hasLocation else { + return nil + } + return CLLocation(latitude: device.latitude, longitude: device.longitude) + } + // MARK: - Connection (via ConnectionManager) /// The connection manager for device lifecycle diff --git a/MC1/Views/Chats/BlockSenderSheet.swift b/MC1/Views/Chats/BlockSenderSheet.swift index 5fdab15c6..6665e827d 100644 --- a/MC1/Views/Chats/BlockSenderSheet.swift +++ b/MC1/Views/Chats/BlockSenderSheet.swift @@ -30,7 +30,7 @@ struct BlockSenderSheet: View { ContactMatchSection( contacts: matchingContacts, selectedIDs: $selectedContactIDs, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) } } diff --git a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift index 316d04fa6..e6c8a2acf 100644 --- a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift @@ -396,14 +396,14 @@ private struct ActionsExpandedContent: View { repeats: repeats, contacts: contacts, discoveredNodes: discoveredNodes, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) } else if availability.canViewPath { MessagePathContent( message: message, viewModel: pathViewModel, receiverName: appState.connectedDevice?.nodeName ?? L10n.Chats.Chats.Path.Receiver.you, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) } } diff --git a/MC1/Views/Contacts/ContactsCompactList.swift b/MC1/Views/Contacts/ContactsCompactList.swift index 4a1dc2f5c..451dfa5b0 100644 --- a/MC1/Views/Contacts/ContactsCompactList.swift +++ b/MC1/Views/Contacts/ContactsCompactList.swift @@ -24,7 +24,7 @@ struct ContactsCompactList: View { ContactRowView( contact: contact, showTypeLabel: isSearching, - userLocation: appState.locationService.currentLocation, + userLocation: appState.bestAvailableLocation, index: index, isTogglingFavorite: viewModel.togglingFavoriteID == contact.id ) diff --git a/MC1/Views/Contacts/ContactsListView.swift b/MC1/Views/Contacts/ContactsListView.swift index b85233e9a..af5d411d3 100644 --- a/MC1/Views/Contacts/ContactsListView.swift +++ b/MC1/Views/Contacts/ContactsListView.swift @@ -24,7 +24,7 @@ struct ContactsListView: View { private var filteredContacts: [ContactDTO] { // Fall back to lastHeard sort when distance is selected but location unavailable - let effectiveSortOrder = (sortOrder == .distance && appState.locationService.currentLocation == nil) + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) ? .lastHeard : sortOrder @@ -32,7 +32,7 @@ struct ContactsListView: View { searchText: searchText, segment: selectedSegment, sortOrder: effectiveSortOrder, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) } diff --git a/MC1/Views/Contacts/ContactsSplitList.swift b/MC1/Views/Contacts/ContactsSplitList.swift index e27538d20..05115d386 100644 --- a/MC1/Views/Contacts/ContactsSplitList.swift +++ b/MC1/Views/Contacts/ContactsSplitList.swift @@ -24,7 +24,7 @@ struct ContactsSplitList: View { ContactRowView( contact: contact, showTypeLabel: isSearching, - userLocation: appState.locationService.currentLocation, + userLocation: appState.bestAvailableLocation, index: index, isTogglingFavorite: viewModel.togglingFavoriteID == contact.id ) diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index 884db0300..a9bdd9bcf 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -13,7 +13,7 @@ struct DiscoveryView: View { @State private var showClearConfirmation = false private var filteredNodes: [DiscoveredNodeDTO] { - let effectiveSortOrder = (sortOrder == .distance && appState.locationService.currentLocation == nil) + let effectiveSortOrder = (sortOrder == .distance && appState.bestAvailableLocation == nil) ? .lastHeard : sortOrder @@ -21,7 +21,7 @@ struct DiscoveryView: View { searchText: searchText, segment: selectedSegment, sortOrder: effectiveSortOrder, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) } @@ -351,7 +351,7 @@ private struct DiscoveryNodeRow: View { } private var distanceToNode: String? { - guard let userLocation = appState.locationService.currentLocation, + guard let userLocation = appState.bestAvailableLocation, node.hasLocation else { return nil } let nodeLocation = CLLocation( diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 0b8dabc68..56963689b 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -45,13 +45,15 @@ struct TracePathMapView: View { .onAppear { mapViewModel.configure( traceViewModel: traceViewModel, - userLocation: appState.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) mapViewModel.rebuildOverlays() mapViewModel.performInitialCentering() } - .onChange(of: appState.locationService.currentLocation) { _, newLocation in - mapViewModel.updateUserLocation(newLocation) + .onChange(of: appState.bestAvailableLocation) { old, new in + guard old?.coordinate.latitude != new?.coordinate.latitude + || old?.coordinate.longitude != new?.coordinate.longitude else { return } + mapViewModel.updateUserLocation(new) } .onChange(of: traceViewModel.availableNodes) { _, _ in if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { @@ -236,7 +238,7 @@ struct TracePathMapView: View { Spacer() MapControlsToolbar( onLocationTap: { - if let location = appState.locationService.currentLocation { + if let location = appState.bestAvailableLocation { mapViewModel.cameraRegion = MKCoordinateRegion( center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) diff --git a/MC1/Views/Contacts/TracePathViewModel.swift b/MC1/Views/Contacts/TracePathViewModel.swift index b800bd975..ce67a1528 100644 --- a/MC1/Views/Contacts/TracePathViewModel.swift +++ b/MC1/Views/Contacts/TracePathViewModel.swift @@ -326,7 +326,7 @@ final class TracePathViewModel { } private var currentUserLocation: CLLocation? { - appState?.locationService.currentLocation + appState?.bestAvailableLocation } /// Try contacts first, then discovered nodes. Returns the best match from either source. @@ -996,17 +996,9 @@ final class TracePathViewModel { let deviceName = appState?.connectedDevice?.nodeName ?? L10n.Contacts.Contacts.Results.Hop.myDevice let path = traceInfo.path - // Resolve device location: GPS first, then device's set location, treat (0,0) as nil - var deviceLat: Double? - var deviceLon: Double? - if let gpsLocation = appState?.locationService.currentLocation { - deviceLat = gpsLocation.coordinate.latitude - deviceLon = gpsLocation.coordinate.longitude - } else if let device = appState?.connectedDevice, - device.latitude != 0 || device.longitude != 0 { - deviceLat = device.latitude - deviceLon = device.longitude - } + let deviceLocation = appState?.bestAvailableLocation + let deviceLat = deviceLocation?.coordinate.latitude + let deviceLon = deviceLocation?.coordinate.longitude // Start node has no SNR (it transmitted first, didn't receive anything) hops.append(TraceHop( diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index c31d58d1b..c03c3ded8 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -298,7 +298,7 @@ struct MapView: View { } private func centerOnUserLocation() { - guard let location = appState.locationService.currentLocation else { return } + guard let location = appState.bestAvailableLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) viewModel.cameraRegion = MKCoordinateRegion(center: location.coordinate, span: span) } diff --git a/MC1Services/Sources/MC1Services/Models/Device.swift b/MC1Services/Sources/MC1Services/Models/Device.swift index 2c7791882..9fc0372d0 100644 --- a/MC1Services/Sources/MC1Services/Models/Device.swift +++ b/MC1Services/Sources/MC1Services/Models/Device.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import SwiftData @@ -275,6 +276,14 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { /// Trace protocol uses power-of-2 encoding: `1 << pathHashMode`. public var traceHashSize: Int { 1 << Int(pathHashMode) } + public var hasLocation: Bool { + let hasNonZero = latitude != 0 || longitude != 0 + guard hasNonZero else { return false } + return CLLocationCoordinate2DIsValid( + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + ) + } + public var preRepeatFrequency: UInt32? public var preRepeatBandwidth: UInt32? public var preRepeatSpreadingFactor: UInt8? From dff5db5538d0313376723210e1b4de6c5abe1b75 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:55:17 -0700 Subject: [PATCH 31/48] refactor(signal): unify LoRa SNR quality tiers - Replace 3 divergent SNR classification systems with a single SNRQuality enum - Remove veryPoor tier, update colors (green/yellow/red) - Add SNRQuality.localizedLabel to eliminate 3 duplicate L10n switches - Add SNRQuality.uiColor for MapKit renderers - Remove PathLineOverlay.SignalQuality in favor of SNRQuality? --- MC1/Extensions/SNRQuality+Color.swift | 25 +++++++++++++++- .../Chats/Components/PathHopRowView.swift | 11 +------ .../Chats/Components/RepeatRowView.swift | 11 +------ .../Chats/Reactions/MessageActionsSheet.swift | 14 +-------- MC1/Views/Contacts/TraceHop.swift | 26 ++--------------- .../TracePathMap/PathLineOverlay.swift | 27 ++++------------- .../TracePathMap/PathLineRenderer.swift | 29 ++++++++++--------- .../TracePathMap/TracePathMapViewModel.swift | 2 +- MC1/Views/Contacts/TraceResultsSheet.swift | 10 +++---- .../MC1Services/Models/SNRQuality.swift | 12 ++------ 10 files changed, 59 insertions(+), 108 deletions(-) diff --git a/MC1/Extensions/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 2fb432baf..544cfd90e 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -1,5 +1,6 @@ import MC1Services import SwiftUI +import UIKit extension SNRQuality { /// SwiftUI color for signal quality indicators. @@ -7,8 +8,30 @@ extension SNRQuality { switch self { case .excellent: .green case .good: .yellow - case .fair, .poor, .veryPoor: .red + case .fair, .poor: .red case .unknown: .secondary } } + + /// UIKit color for MapKit renderers. + var uiColor: UIColor { + switch self { + case .excellent: .systemGreen + case .good: .systemYellow + case .fair: .systemOrange + case .poor: .systemRed + case .unknown: .systemGray + } + } + + /// Localized display label for signal quality. + var localizedLabel: String { + switch self { + case .excellent: L10n.Chats.Chats.Signal.excellent + case .good: L10n.Chats.Chats.Signal.good + case .fair: L10n.Chats.Chats.Signal.fair + case .poor: L10n.Chats.Chats.Signal.poor + case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown + } + } } diff --git a/MC1/Views/Chats/Components/PathHopRowView.swift b/MC1/Views/Chats/Components/PathHopRowView.swift index 10d919304..da3645fc7 100644 --- a/MC1/Views/Chats/Components/PathHopRowView.swift +++ b/MC1/Views/Chats/Components/PathHopRowView.swift @@ -80,16 +80,7 @@ struct PathHopRowView: View { private var snrQuality: SNRQuality { SNRQuality(snr: snr) } - private var signalQualityText: String { - switch snrQuality { - case .excellent: L10n.Chats.Chats.Signal.excellent - case .good: L10n.Chats.Chats.Signal.good - case .fair: L10n.Chats.Chats.Signal.fair - case .poor: L10n.Chats.Chats.Signal.poor - case .veryPoor: L10n.Chats.Chats.Signal.veryPoor - case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown - } - } + private var signalQualityText: String { snrQuality.localizedLabel } } #Preview { diff --git a/MC1/Views/Chats/Components/RepeatRowView.swift b/MC1/Views/Chats/Components/RepeatRowView.swift index 3e8a6fd86..2c789d1dd 100644 --- a/MC1/Views/Chats/Components/RepeatRowView.swift +++ b/MC1/Views/Chats/Components/RepeatRowView.swift @@ -57,16 +57,7 @@ struct RepeatRowView: View { private var signalColor: Color { snrQuality.color } /// Signal quality description for accessibility - private var signalQuality: String { - switch snrQuality { - case .excellent: L10n.Chats.Chats.Signal.excellent - case .good: L10n.Chats.Chats.Signal.good - case .fair: L10n.Chats.Chats.Signal.fair - case .poor: L10n.Chats.Chats.Signal.poor - case .veryPoor: L10n.Chats.Chats.Signal.veryPoor - case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown - } - } + private var signalQuality: String { snrQuality.localizedLabel } /// Hop count text with proper pluralization private var hopCountText: String { diff --git a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift index e6c8a2acf..03bc49aba 100644 --- a/MC1/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/MC1/Views/Chats/Reactions/MessageActionsSheet.swift @@ -452,19 +452,7 @@ private struct ActionsIncomingDetailsRows: View { } private func snrFormatted(_ snr: Double) -> String { - let quality: String - switch snr { - case 10...: - quality = L10n.Chats.Chats.Signal.excellent - case 5..<10: - quality = L10n.Chats.Chats.Signal.good - case 0..<5: - quality = L10n.Chats.Chats.Signal.fair - case -10..<0: - quality = L10n.Chats.Chats.Signal.poor - default: - quality = L10n.Chats.Chats.Signal.veryPoor - } + let quality = SNRQuality(snr: snr).localizedLabel return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB (\(quality))" } diff --git a/MC1/Views/Contacts/TraceHop.swift b/MC1/Views/Contacts/TraceHop.swift index 35350e0dd..5705bc21f 100644 --- a/MC1/Views/Contacts/TraceHop.swift +++ b/MC1/Views/Contacts/TraceHop.swift @@ -1,4 +1,5 @@ -import SwiftUI +import Foundation +import MC1Services /// Represents a single hop in a trace result struct TraceHop: Identifiable { @@ -24,26 +25,5 @@ struct TraceHop: Identifiable { return lat != 0 || lon != 0 } - /// Map SNR to 0-1 range for cellularbars variableValue - var signalLevel: Double { - Self.signalLevel(for: snr) - } - - var signalColor: Color { - Self.signalColor(for: snr) - } - - /// Shared signal level calculation for any SNR value - static func signalLevel(for snr: Double) -> Double { - if snr >= 5 { return 1.0 } - if snr >= -5 { return 0.66 } - return 0.33 - } - - /// Shared signal color calculation for any SNR value - static func signalColor(for snr: Double) -> Color { - if snr >= 5 { return .green } - if snr >= -5 { return .yellow } - return .red - } + var snrQuality: SNRQuality { SNRQuality(snr: snr) } } diff --git a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift b/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift index b414ff6c1..3880da2f5 100644 --- a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift +++ b/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift @@ -1,29 +1,12 @@ import MapKit +import MC1Services /// Custom polyline overlay that carries signal quality data for styling /// Note: All properties are immutable - create new overlay instances when signal quality changes final class PathLineOverlay: MKPolyline { - /// Signal quality determines line color after trace - enum SignalQuality { - case untraced // Dashed gray (before trace) - case good // Solid green (SNR >= 5) - case medium // Solid yellow (SNR -5 to 5) - case weak // Solid red (SNR < -5) - - init(snr: Double) { - if snr >= 5 { - self = .good - } else if snr >= -5 { - self = .medium - } else { - self = .weak - } - } - } - - /// Signal quality - immutable after creation - private(set) var signalQuality: SignalQuality = .untraced + /// Signal quality - nil means untraced (pre-trace dashed gray line) + private(set) var signalQuality: SNRQuality? /// Distance in meters between the two endpoints - immutable after creation private(set) var distanceMeters: Double = 0 @@ -45,7 +28,7 @@ final class PathLineOverlay: MKPolyline { from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D, segmentIndex: Int, - signalQuality: SignalQuality = .untraced, + signalQuality: SNRQuality? = nil, snr: Double = 0 ) -> PathLineOverlay { var coords = [start, end] @@ -65,7 +48,7 @@ final class PathLineOverlay: MKPolyline { } /// Create a new overlay with updated signal quality (immutable pattern) - func withSignalQuality(_ quality: SignalQuality, snr: Double) -> PathLineOverlay { + func withSignalQuality(_ quality: SNRQuality, snr: Double) -> PathLineOverlay { PathLineOverlay.line( from: startCoordinate, to: endCoordinate, diff --git a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift b/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift index 9c233e7aa..ff6d4a8f8 100644 --- a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift +++ b/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift @@ -14,26 +14,29 @@ final class PathLineRenderer: MKPolylineRenderer { private func configureAppearance() { guard let pathOverlay = overlay as? PathLineOverlay else { return } - switch pathOverlay.signalQuality { - case .untraced: + guard let quality = pathOverlay.signalQuality else { + // Untraced — dashed gray strokeColor = UIColor.systemGray lineWidth = 2 lineDashPattern = [8, 6] + return + } - case .good: - strokeColor = UIColor.systemGreen - lineWidth = 4 // Thicker for accessibility (color-blind users) - lineDashPattern = nil + strokeColor = quality.uiColor - case .medium: - strokeColor = UIColor.systemYellow + switch quality { + case .excellent, .good: + lineWidth = 4 + lineDashPattern = nil + case .fair: lineWidth = 3 - lineDashPattern = [12, 4] // Different pattern for accessibility - - case .weak: - strokeColor = UIColor.systemRed + lineDashPattern = [12, 4] + case .poor: lineWidth = 3 - lineDashPattern = [4, 4] // Different pattern for accessibility + lineDashPattern = [4, 4] + case .unknown: + lineWidth = 2 + lineDashPattern = [8, 6] } } } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 459e5f3a1..31d1d5ce4 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -266,7 +266,7 @@ final class TracePathMapViewModel { let hopIndex = index + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let quality = PathLineOverlay.SignalQuality(snr: hop.snr) + let quality = SNRQuality(snr: hop.snr) // Create new overlay with signal quality let updatedOverlay = overlay.withSignalQuality(quality, snr: hop.snr) diff --git a/MC1/Views/Contacts/TraceResultsSheet.swift b/MC1/Views/Contacts/TraceResultsSheet.swift index b0ce8bbfd..6e9bcc53c 100644 --- a/MC1/Views/Contacts/TraceResultsSheet.swift +++ b/MC1/Views/Contacts/TraceResultsSheet.swift @@ -371,13 +371,11 @@ struct TraceResultHopRow: View { } } - private var signalLevel: Double { - TraceHop.signalLevel(for: displaySNR) - } + private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } - private var signalColor: Color { - TraceHop.signalColor(for: displaySNR) - } + private var signalLevel: Double { snrQuality.barLevel } + + private var signalColor: Color { snrQuality.color } var body: some View { HStack { diff --git a/MC1Services/Sources/MC1Services/Models/SNRQuality.swift b/MC1Services/Sources/MC1Services/Models/SNRQuality.swift index f048747c9..96944fd5d 100644 --- a/MC1Services/Sources/MC1Services/Models/SNRQuality.swift +++ b/MC1Services/Sources/MC1Services/Models/SNRQuality.swift @@ -1,14 +1,11 @@ /// Signal quality classification based on LoRa SNR (Signal-to-Noise Ratio) in dB. /// -/// Standard 5-tier scale for individual packet reception quality. -/// Note: Trace path views (PathLineOverlay, TraceHop) intentionally use a different -/// 3-tier scale with wider thresholds (±5 dB) since path segments span longer distances. +/// Standard 4-tier scale for signal quality indicators across the app. public enum SNRQuality: Sendable, Equatable { case excellent // SNR > 10 dB case good // SNR > 5 dB case fair // SNR > 0 dB - case poor // SNR > -10 dB - case veryPoor // SNR <= -10 dB + case poor // SNR <= 0 dB case unknown // nil SNR public init(snr: Double?) { @@ -19,8 +16,7 @@ public enum SNRQuality: Sendable, Equatable { if snr > 10 { self = .excellent } else if snr > 5 { self = .good } else if snr > 0 { self = .fair } - else if snr > -10 { self = .poor } - else { self = .veryPoor } + else { self = .poor } } /// Bar level for SF Symbol `cellularbars` variableValue (0–1). @@ -30,7 +26,6 @@ public enum SNRQuality: Sendable, Equatable { case .good: 0.75 case .fair: 0.5 case .poor: 0.25 - case .veryPoor: 0.1 case .unknown: 0 } } @@ -42,7 +37,6 @@ public enum SNRQuality: Sendable, Equatable { case .good: "Good" case .fair: "Fair" case .poor: "Weak" - case .veryPoor: "Marginal" case .unknown: "Unknown" } } From 692c2ca92253d4aaedf373b2191dd779c98a0f3d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:58:16 -0700 Subject: [PATCH 32/48] ui(ble-menu): cap dynamic type size at xLarge --- .../Components/BLEStatusIndicatorView.swift | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index 3cb2132a4..b21d6df20 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -238,9 +238,15 @@ private struct ConnectedMenu: View { } Button { - onAdvancedSettings() + onChangeDevice() } label: { - Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") + Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") + } + + Button(role: .destructive) { + onDisconnect() + } label: { + Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") } } } @@ -265,21 +271,16 @@ private struct ConnectedMenu: View { Section { Button { - onChangeDevice() - } label: { - Label(L10n.Settings.BleStatus.changeDevice, systemImage: "antenna.radiowaves.left.and.right") - } - - Button(role: .destructive) { - onDisconnect() + onAdvancedSettings() } label: { - Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") + Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } } } label: { StatusIcon(iconName: iconName, iconColor: iconColor, isAnimating: isAnimating) } .popoverTip(deviceMenuTip) + .dynamicTypeSize(...DynamicTypeSize.xLarge) .sensoryFeedback(.success, trigger: successFeedbackTrigger) .sensoryFeedback(.error, trigger: errorFeedbackTrigger) .accessibilityLabel(L10n.Settings.BleStatus.accessibilityLabel) From 007c4d2e722a2e68b1e54f9f0c1231d98abd8883 Mon Sep 17 00:00:00 2001 From: Neil Alexander Date: Fri, 27 Mar 2026 17:14:31 +0000 Subject: [PATCH 33/48] Adjust SNR thresholds Signed-off-by: Neil Alexander --- MC1/Extensions/SNRQuality+Color.swift | 6 +++--- .../Sources/MC1Services/Models/SNRQuality.swift | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/MC1/Extensions/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 544cfd90e..2826f04e7 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -6,9 +6,9 @@ extension SNRQuality { /// SwiftUI color for signal quality indicators. var color: Color { switch self { - case .excellent: .green - case .good: .yellow - case .fair, .poor: .red + case .excellent, .good: .green + case .fair: .yellow + case .poor: .red case .unknown: .secondary } } diff --git a/MC1Services/Sources/MC1Services/Models/SNRQuality.swift b/MC1Services/Sources/MC1Services/Models/SNRQuality.swift index 96944fd5d..bdf30b6f2 100644 --- a/MC1Services/Sources/MC1Services/Models/SNRQuality.swift +++ b/MC1Services/Sources/MC1Services/Models/SNRQuality.swift @@ -2,10 +2,10 @@ /// /// Standard 4-tier scale for signal quality indicators across the app. public enum SNRQuality: Sendable, Equatable { - case excellent // SNR > 10 dB - case good // SNR > 5 dB - case fair // SNR > 0 dB - case poor // SNR <= 0 dB + case excellent // SNR > +6 dB + case good // SNR > +0 dB + case fair // SNR > -6 dB + case poor // SNR <= -6 dB case unknown // nil SNR public init(snr: Double?) { @@ -13,9 +13,9 @@ public enum SNRQuality: Sendable, Equatable { self = .unknown return } - if snr > 10 { self = .excellent } - else if snr > 5 { self = .good } - else if snr > 0 { self = .fair } + if snr > 6 { self = .excellent } + else if snr > 0 { self = .good } + else if snr > -6 { self = .fair } else { self = .poor } } From 68ca214fec48e5eb283ffd4339e23027f644060e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:17:47 -0700 Subject: [PATCH 34/48] fix(ui): fix inconsistent x-axis date format in telemetry charts - Specify explicit month/day format on chart x-axis labels to prevent Swift Charts from auto-appending truncated time components --- MC1/Views/RemoteNodes/MetricChartView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MC1/Views/RemoteNodes/MetricChartView.swift b/MC1/Views/RemoteNodes/MetricChartView.swift index 951b31db9..f89dbeb48 100644 --- a/MC1/Views/RemoteNodes/MetricChartView.swift +++ b/MC1/Views/RemoteNodes/MetricChartView.swift @@ -90,7 +90,11 @@ private struct MetricChartContent: View { AxisMarks(position: .leading) } .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisTick() + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + } } .accessibilityLabel(title) .frame(height: 180) From 06c59a859d2571dac72c08c922dba37599797cd8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:14:08 -0700 Subject: [PATCH 35/48] Extract shared node helpers, add Room management UI (#270) - Extract shared node management logic from Repeater-specific views into reusable helpers (NodeSettingsHelper, NodeStatusHelper, SharedNodeViews), reducing duplication across Room and Repeater management - Add full Room management UI (RoomSettingsView, RoomStatusView) with RoomAdminService, following existing Repeater patterns - Fix two pre-existing MeshCore spec alignment bugs validated against firmware source (CommonCLI.cpp) --- MC1/Resources/Generated/L10n.swift | 8 +- .../Localization/de.lproj/RemoteNodes.strings | 74 +- .../Localization/en.lproj/RemoteNodes.strings | 74 +- .../Localization/es.lproj/RemoteNodes.strings | 74 +- .../Localization/fr.lproj/RemoteNodes.strings | 74 +- .../Localization/nl.lproj/RemoteNodes.strings | 74 +- .../Localization/pl.lproj/RemoteNodes.strings | 74 +- .../Localization/ru.lproj/RemoteNodes.strings | 74 +- .../Localization/uk.lproj/RemoteNodes.strings | 74 +- .../zh-Hans.lproj/RemoteNodes.strings | 74 +- MC1/Services/MessageEventBroadcaster.swift | 26 - MC1/Views/Chats/RoomInfoSheet.swift | 24 +- .../ExpandableSettingsSection.swift | 12 +- MC1/Views/Contacts/ContactDetailView.swift | 142 ++- .../RemoteNodes/NodeAuthenticationSheet.swift | 6 +- .../RemoteNodes/NodeSettingsHelper.swift | 729 ++++++++++++++ MC1/Views/RemoteNodes/NodeStatusHelper.swift | 423 ++++++++ .../RemoteNodes/NodeStatusHistoryView.swift | 29 +- .../RemoteNodes/RepeaterSettingsView.swift | 533 +--------- .../RepeaterSettingsViewModel.swift | 913 +++--------------- .../RemoteNodes/RepeaterStatusView.swift | 279 +----- .../RemoteNodes/RepeaterStatusViewModel.swift | 500 +--------- MC1/Views/RemoteNodes/RoomSettingsView.swift | 249 +++++ .../RemoteNodes/RoomSettingsViewModel.swift | 284 ++++++ MC1/Views/RemoteNodes/RoomStatusView.swift | 152 +++ .../RemoteNodes/RoomStatusViewModel.swift | 108 +++ MC1/Views/RemoteNodes/SharedNodeViews.swift | 662 +++++++++++++ .../TelemetryHistoryOverviewView.swift | 19 +- .../Sources/MC1Services/MC1Services.swift | 3 + .../Models/NodeStatusSnapshot.swift | 17 + .../MC1Services/Models/OCVPreset.swift | 4 +- .../Protocols/PersistenceStoreProtocol.swift | 4 +- .../MC1Services/ServiceContainer.swift | 7 + .../Services/NodeSnapshotService.swift | 8 +- .../PersistenceStore+Diagnostics.swift | 16 +- .../Services/RoomAdminService.swift | 156 +++ .../SyncCoordinator+MessageHandlers.swift | 6 +- .../Mocks/MockPersistenceStore.swift | 14 +- MC1Tests/Services/LinkPreviewCacheTests.swift | 2 +- .../MessageEventBroadcasterTests.swift | 2 - .../ChatViewModelPaginationTests.swift | 2 +- .../LineOfSightViewModelTests.swift | 2 +- .../RepeaterStatusViewModelTests.swift | 43 +- 43 files changed, 3894 insertions(+), 2156 deletions(-) create mode 100644 MC1/Views/RemoteNodes/NodeSettingsHelper.swift create mode 100644 MC1/Views/RemoteNodes/NodeStatusHelper.swift create mode 100644 MC1/Views/RemoteNodes/RoomSettingsView.swift create mode 100644 MC1/Views/RemoteNodes/RoomSettingsViewModel.swift create mode 100644 MC1/Views/RemoteNodes/RoomStatusView.swift create mode 100644 MC1/Views/RemoteNodes/RoomStatusViewModel.swift create mode 100644 MC1/Views/RemoteNodes/SharedNodeViews.swift create mode 100644 MC1Services/Sources/MC1Services/Services/RoomAdminService.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f8856f482..a028aec5e 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2596,7 +2596,7 @@ public enum L10n { /// Location: RepeaterSettingsView.swift - Firmware label public static let firmware = L10n.tr("RemoteNodes", "remoteNodes.settings.firmware", fallback: "Firmware") /// Location: RepeaterSettingsViewModel.swift - Flood interval validation error - public static let floodIntervalValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodIntervalValidation", fallback: "Accepts 3-48 hours") + public static let floodIntervalValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodIntervalValidation", fallback: "Accepts 0 (off) or 3-168 hours") /// Location: RepeaterSettingsViewModel.swift - Flood max hops validation error public static let floodMaxValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodMaxValidation", fallback: "Accepts 0-64 hops") /// Location: RepeaterSettingsView.swift - Frequency label @@ -2627,8 +2627,8 @@ public enum L10n { public static let min = L10n.tr("RemoteNodes", "remoteNodes.settings.min", fallback: "min") /// Location: RepeaterSettingsView.swift - New password placeholder public static let newPassword = L10n.tr("RemoteNodes", "remoteNodes.settings.newPassword", fallback: "New Password") - /// Location: RepeaterSettingsViewModel.swift - No service error - public static let noService = L10n.tr("RemoteNodes", "remoteNodes.settings.noService", fallback: "Repeater service not available") + /// Location: NodeSettingsHelper.swift - No service error + public static let noService = L10n.tr("RemoteNodes", "remoteNodes.settings.noService", fallback: "Service not available") /// Location: RepeaterSettingsViewModel.swift - Not connected error public static let notConnected = L10n.tr("RemoteNodes", "remoteNodes.settings.notConnected", fallback: "Not connected to repeater") /// Location: RepeaterSettingsView.swift - OK button @@ -2809,6 +2809,8 @@ public enum L10n { public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.status.packetsSent", fallback: "Packets Sent") /// Location: RepeaterStatusView.swift - Receive errors label public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.status.receiveErrors", fallback: "Packet Errors Received") + /// Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label + public static let refresh = L10n.tr("RemoteNodes", "remoteNodes.status.refresh", fallback: "Refresh") /// Location: RepeaterStatusViewModel.swift - Request timed out public static let requestTimedOut = L10n.tr("RemoteNodes", "remoteNodes.status.requestTimedOut", fallback: "Request timed out") /// Location: RepeaterStatusView.swift - Seconds ago format diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 099ce29e5..27273cdc6 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Befehl-Zeitüberschreitung"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeater-Dienst nicht verfügbar"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Dienst nicht verfügbar"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Akzeptiert 0 (deaktiviert) oder 60-240 Min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Akzeptiert 3-48 Stunden"; +"remoteNodes.settings.floodIntervalValidation" = "Akzeptiert 0 (aus) oder 3–168 Stunden"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akzeptiert 0-64 Sprünge"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spannungs-Prozent-Zuordnung zur Schätzung des Batteriestands."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Aktualisieren"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Nachricht zugestellt"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index c31e5c0bc..523adbc7f 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Command timed out"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeater service not available"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service not available"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepts 0 (disabled) or 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepts 3-48 hours"; +"remoteNodes.settings.floodIntervalValidation" = "Accepts 0 (off) or 3-168 hours"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepts 0-64 hops"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Voltage-to-percentage mapping used for battery level estimation."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Refresh"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Message delivered"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 8c175c8cd..c020c1c34 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Tiempo de espera del comando agotado"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Servicio de repetidor no disponible"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Servicio no disponible"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Acepta 0 (desactivado) o 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Acepta 3-48 horas"; +"remoteNodes.settings.floodIntervalValidation" = "Acepta 0 (desactivado) o 3–168 horas"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Acepta 0-64 saltos"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapeo de voltaje a porcentaje para estimar el nivel de batería."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualizar"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Mensaje entregado"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index f37dff9ff..de4723a6b 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "La commande a expiré"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Service répéteur non disponible"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service non disponible"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepte 0 (désactivé) ou 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepte 3-48 heures"; +"remoteNodes.settings.floodIntervalValidation" = "Accepte 0 (désactivé) ou 3–168 heures"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepte 0-64 sauts"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Correspondance tension-pourcentage pour l'estimation du niveau de batterie."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualiser"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Message distribué"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 957e2c459..4c8ee5bc4 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Opdracht time-out"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeaterservice niet beschikbaar"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service niet beschikbaar"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepteert 0 (uitgeschakeld) of 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepteert 3-48 uur"; +"remoteNodes.settings.floodIntervalValidation" = "Accepteert 0 (uit) of 3–168 uur"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepteert 0-64 sprongen"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spanning-naar-percentage-toewijzing voor schatting van het batterijniveau."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Vernieuwen"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Bericht afgeleverd"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 60a94b5f4..aed514d45 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Upłynął limit czasu polecenia"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Usługa przekaźnika niedostępna"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Usługa niedostępna"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Akceptuje 0 (wyłączone) lub 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Akceptuje 3-48 godzin"; +"remoteNodes.settings.floodIntervalValidation" = "Akceptuje 0 (wył.) lub 3–168 godzin"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akceptuje 0-64 skoków"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapowanie napięcia na procent do szacowania poziomu baterii."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Odśwież"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Wiadomość dostarczona"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 1e198243b..e33efcb5a 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Истекло время ожидания команды"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Сервис ретранслятора недоступен"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Сервис недоступен"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Допустимые значения: 0 (отключено) или 60-240 мин"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Допустимые значения: 3-48 часов"; +"remoteNodes.settings.floodIntervalValidation" = "Допустимые значения: 0 (откл.) или 3–168 часов"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимые значения: 0-64 переходов"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Соответствие напряжения и процента для оценки уровня заряда батареи."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Обновить"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Сообщение доставлено"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index e77de19f2..c26768d45 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Час очікування команди вичерпано"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Сервіс ретранслятора недоступний"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Сервіс недоступний"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Допустимі значення: 0 (вимкнено) або 60–240 хв"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Допустимі значення: 3–48 годин"; +"remoteNodes.settings.floodIntervalValidation" = "Допустимі значення: 0 (вимк.) або 3–168 годин"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимі значення: 0–64 переходів"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Відповідність напруги та відсотка для оцінки рівня заряду батареї."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Оновити"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Повідомлення доставлено"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 21e68442a..8b1d7de92 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "命令超时"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "转发节点服务不可用"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "服务不可用"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "接受 0(禁用)或 60-240 分钟"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "接受 3-48 小时"; +"remoteNodes.settings.floodIntervalValidation" = "接受 0(关闭)或 3–168 小时"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "接受 0-64 跳"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "用于估算电池电量的电压百分比"; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "刷新"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "消息已送达"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Services/MessageEventBroadcaster.swift b/MC1/Services/MessageEventBroadcaster.swift index 0027d5bfd..1b491cb70 100644 --- a/MC1/Services/MessageEventBroadcaster.swift +++ b/MC1/Services/MessageEventBroadcaster.swift @@ -62,9 +62,6 @@ public final class MessageEventBroadcaster { /// Reference to binary protocol service for handling binary responses var binaryProtocolService: BinaryProtocolService? - /// Reference to repeater admin service for telemetry and CLI handling - var repeaterAdminService: RepeaterAdminService? - // MARK: - Initialization public init() {} @@ -204,7 +201,6 @@ public final class MessageEventBroadcaster { dataStore = services.dataStore roomServerService = services.roomServerService binaryProtocolService = services.binaryProtocolService - repeaterAdminService = services.repeaterAdminService // Wire message event callbacks for real-time chat updates await services.syncCoordinator.setMessageEventCallbacks( @@ -293,26 +289,4 @@ public final class MessageEventBroadcaster { } } - // MARK: - Status Response Handling - - /// Handle status response from remote node - func handleStatusResponse(_ status: StatusResponse) async { - await repeaterAdminService?.invokeStatusHandler(status) - - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("Received status response from node: \(prefixHex)") - } - - // Note: Login results and binary responses are handled internally by - // MC1Services via MeshCore event monitoring. No external handlers needed. - - /// Handle telemetry response - func handleTelemetryResponse(_ response: TelemetryResponse) async { - await repeaterAdminService?.invokeTelemetryHandler(response) - } - - /// Handle CLI response - func handleCLIResponse(_ message: ContactMessage, fromContact contact: ContactDTO) async { - await repeaterAdminService?.invokeCLIHandler(message, fromContact: contact) - } } diff --git a/MC1/Views/Chats/RoomInfoSheet.swift b/MC1/Views/Chats/RoomInfoSheet.swift index 96cbf748d..57c302510 100644 --- a/MC1/Views/Chats/RoomInfoSheet.swift +++ b/MC1/Views/Chats/RoomInfoSheet.swift @@ -13,6 +13,8 @@ struct RoomInfoSheet: View { @State private var isFavorite: Bool @State private var notificationTask: Task? @State private var favoriteTask: Task? + @State private var showTelemetry = false + @State private var showSettings = false init(session: RemoteNodeSessionDTO) { self.session = session @@ -54,6 +56,19 @@ struct RoomInfoSheet: View { favoriteTask?.cancel() } + if session.isConnected { + Section { + Button { showTelemetry = true } label: { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + if session.isAdmin { + Button { showSettings = true } label: { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + } + } + } + Section(Strings.details) { LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) LabeledContent(Strings.permission, value: session.permissionLevel.displayName) @@ -89,6 +104,13 @@ struct RoomInfoSheet: View { } } } - + .sheet(isPresented: $showTelemetry) { + RoomStatusView(session: session) + } + .sheet(isPresented: $showSettings) { + NavigationStack { + RoomSettingsView(session: session) + } + } } } diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index aa255e0dd..a0722f629 100644 --- a/MC1/Views/Components/ExpandableSettingsSection.swift +++ b/MC1/Views/Components/ExpandableSettingsSection.swift @@ -9,7 +9,7 @@ struct ExpandableSettingsSection: View { @Binding var isExpanded: Bool let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) @Binding var isLoading: Bool - @Binding var error: String? + @Binding var hasError: Bool let onLoad: () async -> Void let footer: String? @@ -21,7 +21,7 @@ struct ExpandableSettingsSection: View { isExpanded: Binding, isLoaded: @escaping () -> Bool, isLoading: Binding, - error: Binding, + hasError: Binding, onLoad: @escaping () async -> Void, footer: String? = nil, @ViewBuilder content: @escaping () -> Content @@ -31,7 +31,7 @@ struct ExpandableSettingsSection: View { self._isExpanded = isExpanded self.isLoaded = isLoaded self._isLoading = isLoading - self._error = error + self._hasError = hasError self.onLoad = onLoad self.footer = footer self.content = content @@ -45,7 +45,7 @@ struct ExpandableSettingsSection: View { content() // Show error banner if something failed - if let error, !isLoaded() { + if hasError && !isLoaded() { VStack(spacing: 12) { Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) @@ -102,7 +102,7 @@ struct ExpandableSettingsSection: View { #Preview { @Previewable @State var isExpanded = false @Previewable @State var isLoading = false - @Previewable @State var error: String? + @Previewable @State var hasError = false @Previewable @State var data: String? Form { @@ -112,7 +112,7 @@ struct ExpandableSettingsSection: View { isExpanded: $isExpanded, isLoaded: { data != nil }, isLoading: $isLoading, - error: $error, + hasError: $hasError, onLoad: { isLoading = true try? await Task.sleep(for: .seconds(1)) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index f4b19ab1c..b19b6dc45 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -57,13 +57,15 @@ struct ContactDetailView: View { /// Sheet types for the contact detail view private enum ActiveSheet: Identifiable, Hashable { - case repeaterAuth + case nodeAuth case repeaterStatus(RemoteNodeSessionDTO) + case roomStatus(RemoteNodeSessionDTO) var id: String { switch self { - case .repeaterAuth: return "auth" + case .nodeAuth: return "auth" case .repeaterStatus(let session): return "status-\(session.id)" + case .roomStatus(let session): return "room-status-\(session.id)" } } } @@ -80,8 +82,6 @@ struct ContactDetailView: View { @State private var showRoomJoinSheet = false @State private var activeSheet: ActiveSheet? @State private var pendingSheet: ActiveSheet? - @State private var showRoomConversation = false - @State private var connectedRoomSession: RemoteNodeSessionDTO? // Admin access navigation state (separate from telemetry sheet flow) @State private var showRepeaterAdminAuth = false @State private var adminSession: RemoteNodeSessionDTO? @@ -116,7 +116,7 @@ struct ContactDetailView: View { isTogglingFavorite: isTogglingFavorite, pingResult: pingResult, onJoinRoom: { showRoomJoinSheet = true }, - onShowTelemetry: { activeSheet = .repeaterAuth }, + onShowTelemetry: { activeSheet = .nodeAuth }, onShowAdminAccess: { adminSession = nil showRepeaterAdminAuth = true @@ -245,25 +245,26 @@ struct ContactDetailView: View { } .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in switch sheet { - case .repeaterAuth: + case .nodeAuth: if let role = RemoteNodeRole(contactType: currentContact.type) { NodeAuthenticationSheet( contact: currentContact, role: role, customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess ) { session in - pendingSheet = .repeaterStatus(session) + if currentContact.type == .room { + pendingSheet = .roomStatus(session) + } else { + pendingSheet = .repeaterStatus(session) + } activeSheet = nil // Triggers dismissal, then onDismiss fires } .presentationSizing(.page) } case .repeaterStatus(let session): RepeaterStatusView(session: session) - } - } - .navigationDestination(isPresented: $showRoomConversation) { - if let session = connectedRoomSession { - RoomConversationView(session: session) + case .roomStatus(let session): + RoomStatusView(session: session) } } .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { @@ -271,6 +272,8 @@ struct ContactDetailView: View { if let session = adminSession { if session.isAdmin { navigateToSettings = true + } else if session.isRoom { + activeSheet = .roomStatus(session) } else { activeSheet = .repeaterStatus(session) } @@ -296,7 +299,11 @@ struct ContactDetailView: View { } .navigationDestination(isPresented: $navigateToSettings) { if let session = adminSession { - RepeaterSettingsView(session: session) + if session.isRoom { + RoomSettingsView(session: session) + } else { + RepeaterSettingsView(session: session) + } } } } @@ -544,46 +551,28 @@ private struct ContactActionsSection: View { } .radioDisabled(for: appState.connectionState) - case .repeater: - // Telemetry button - shows read-only status sheet after auth - Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - // Telemetry History - offline telemetry charts - NavigationLink { - TelemetryHistoryOverviewView( - publicKey: currentContact.publicKey, - deviceID: currentContact.deviceID - ) - } label: { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) - } - - // Admin Access - navigates to settings view after auth - Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") - } - .radioDisabled(for: appState.connectionState) - - // Ping Repeater - Button(action: onPingRepeater) { - HStack { - Label(L10n.Contacts.Contacts.Detail.pingRepeater, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() - } - } - } - .disabled(isPinging) - .radioDisabled(for: appState.connectionState) + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - if let result = pingResult { - PingResultRow(result: result) - } + case .repeater: + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.pingRepeater, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) case .chat: // Send message - only show when NOT from direct chat and NOT blocked @@ -627,6 +616,55 @@ private struct ContactActionsSection: View { } } +private struct NodeActionRows: View { + let contact: ContactDTO + let pingLabel: String + let isPinging: Bool + let pingResult: PingResult? + let connectionState: ConnectionState + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPing: () -> Void + + var body: some View { + Button(action: onShowTelemetry) { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: connectionState) + + NavigationLink { + TelemetryHistoryOverviewView( + publicKey: contact.publicKey, + deviceID: contact.deviceID + ) + } label: { + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) + } + + Button(action: onShowAdminAccess) { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + .radioDisabled(for: connectionState) + + Button(action: onPing) { + HStack { + Label(pingLabel, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() + } + } + } + .disabled(isPinging) + .radioDisabled(for: connectionState) + + if let result = pingResult { + PingResultRow(result: result) + } + } +} + private struct ContactInfoSection: View { let currentContact: ContactDTO @Binding var nickname: String diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 1b8d33624..de12a701c 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -302,6 +302,10 @@ private struct ConnectButton: View { let isAuthenticating: Bool let onAuthenticate: () -> Void + private var buttonLabel: String { + role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect + } + var body: some View { Section { Button { @@ -311,7 +315,7 @@ private struct ConnectButton: View { ProgressView() .frame(maxWidth: .infinity) } else { - Text(role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect) + Text(buttonLabel) .frame(maxWidth: .infinity) } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsHelper.swift b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift new file mode 100644 index 000000000..4cf46ee3c --- /dev/null +++ b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift @@ -0,0 +1,729 @@ +import SwiftUI +import MC1Services +import OSLog + +private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsHelper") + +/// Shared logic for repeater and room settings view models. +/// Owns CLI transport, device info, radio, identity, contact info, +/// security, and device action methods. +@Observable +@MainActor +final class NodeSettingsHelper { + + // MARK: - Session + + var session: RemoteNodeSessionDTO? + + // MARK: - Device Info + + var firmwareVersion: String? + private var deviceTimeUTC: String? + var isLoadingDeviceInfo = false + var deviceInfoError = false + var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + + var deviceTime: String? { + guard let utcString = deviceTimeUTC else { return nil } + return Self.convertUTCToLocal(utcString) + } + + // swiftlint:disable:next force_try + private static let utcDateRegex = try! Regex(#"(\d{1,2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) UTC"#) + + private static let utcInputFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm d/M/yyyy" + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + static func convertUTCToLocal(_ utcString: String) -> String { + guard let match = utcString.firstMatch(of: utcDateRegex), + match.count >= 3 else { + return utcString + } + + let timeStr = String(match[1].substring ?? "") + let dateStr = String(match[2].substring ?? "") + + guard let date = utcInputFormatter.date(from: "\(timeStr) \(dateStr)") else { + return utcString + } + + let timeString = date.formatted(date: .omitted, time: .shortened) + let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) + return "\(timeString) - \(dateString)" + } + + // MARK: - Identity + + var name: String? + var latitude: Double? + var longitude: Double? + private(set) var originalName: String? + private(set) var originalLatitude: Double? + private(set) var originalLongitude: Double? + var isLoadingIdentity = false + var identityError = false + var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } + + var identitySettingsModified: Bool { + (name != nil && name != originalName) || + (latitude != nil && latitude != originalLatitude) || + (longitude != nil && longitude != originalLongitude) + } + + // MARK: - Radio + + var frequency: Double? + var bandwidth: Double? + var spreadingFactor: Int? + var codingRate: Int? + var txPower: Int? + var isLoadingRadio = false + var radioError = false + var radioLoaded: Bool { frequency != nil || txPower != nil } + var radioSettingsModified = false + + // MARK: - Contact Info + + var ownerInfo: String? + private(set) var originalOwnerInfo: String? + var isLoadingContactInfo = false + var contactInfoError = false + var contactInfoLoaded: Bool { originalOwnerInfo != nil } + + var contactInfoSettingsModified: Bool { + ownerInfo != originalOwnerInfo + } + + var ownerInfoCharCount: Int { + (ownerInfo ?? "").count + } + + // MARK: - Security + + var newPassword: String = "" + var confirmPassword: String = "" + + // MARK: - Expansion State + + var isDeviceInfoExpanded = false + var isRadioExpanded = false + var isIdentityExpanded = false + var isContactInfoExpanded = false + var isSecurityExpanded = false + + // MARK: - Global State + + var isApplying = false + var isRebooting = false + var errorMessage: String? + var successMessage: String? + var showSuccessAlert = false + var identityApplySuccess = false + var contactInfoApplySuccess = false + + // MARK: - Service Closures + + private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? + private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? + + /// Called when firmware version or node info needs pre-fetching. + /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. + var onPreFetchNodeInfo: (() async -> Void)? + + // MARK: - Configuration + + func configure( + session: RemoteNodeSessionDTO, + sendCommand: @escaping (UUID, String, Duration) async throws -> String, + sendRawCommand: @escaping (UUID, String, Duration) async throws -> String + ) { + self.session = session + self.sendCommandClosure = sendCommand + self.sendRawCommandClosure = sendRawCommand + } + + /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) + func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { + if let firmwareVersion { self.firmwareVersion = firmwareVersion } + if let name { + self.name = name + self.originalName = name + } + if let ownerInfo { + self.ownerInfo = ownerInfo + self.originalOwnerInfo = ownerInfo + } + } + + func cleanup() { + sendCommandClosure = nil + sendRawCommandClosure = nil + onPreFetchNodeInfo = nil + } + + // MARK: - CLI Transport + + func sendAndWait( + _ command: String, + timeout: Duration = .seconds(5), + rawMatching: Bool = false + ) async throws -> String { + guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { + throw NodeSettingsError.noService + } + + let response = try await sendCmd(session.id, command, timeout) + logger.debug("Command '\(command)' response: \(response.prefix(50))") + return response + } + + // MARK: - Fetch Methods + + func fetchDeviceInfo() async { + isLoadingDeviceInfo = true + deviceInfoError = false + + if firmwareVersion == nil { + await onPreFetchNodeInfo?() + } + + if firmwareVersion == nil { + do { + let response = try await sendAndWait("ver") + if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { + self.firmwareVersion = version + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get firmware version: \(error)") + } + } + + do { + let response = try await sendAndWait("clock") + if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { + self.deviceTimeUTC = time + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get device time: \(error)") + } + + isLoadingDeviceInfo = false + } + + func fetchIdentity() async { + isLoadingIdentity = true + identityError = false + var hadTimeout = false + + if originalName == nil { + await onPreFetchNodeInfo?() + } + + if originalName == nil { + do { + let response = try await sendAndWait("get name") + if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { + self.name = n + self.originalName = n + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get name: \(error)") + } + } + + do { + let response = try await sendAndWait("get lat") + if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { + self.latitude = lat + self.originalLatitude = lat + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get latitude: \(error)") + } + + do { + let response = try await sendAndWait("get lon") + if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { + self.longitude = lon + self.originalLongitude = lon + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get longitude: \(error)") + } + + if hadTimeout { + identityError = true + } + + isLoadingIdentity = false + } + + func fetchRadioSettings() async { + isLoadingRadio = true + radioError = false + var hadTimeout = false + + do { + let response = try await sendAndWait("get tx") + if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { + self.txPower = power + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get TX power: \(error)") + } + + do { + let response = try await sendAndWait("get radio") + if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { + self.frequency = freq + self.bandwidth = bw + self.spreadingFactor = sf + self.codingRate = cr + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get radio settings: \(error)") + } + + if hadTimeout { + radioError = true + } + + isLoadingRadio = false + } + + func fetchContactInfo() async { + if originalOwnerInfo == nil { + await onPreFetchNodeInfo?() + } + if originalOwnerInfo != nil { return } + + isLoadingContactInfo = true + contactInfoError = false + + do { + let response = try await sendAndWait("get owner.info") + if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = info.replacing("|", with: "\n") + self.ownerInfo = displayText + self.originalOwnerInfo = displayText + } + } catch { + if case RemoteNodeError.timeout = error { + contactInfoError = true + } + logger.warning("Failed to get owner info: \(error)") + } + + isLoadingContactInfo = false + } + + // MARK: - Apply Methods + + func applyRadioSettings() async { + guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded + return + } + + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" + let radioResponse = try await sendAndWait(radioCommand) + if case .ok = CLIResponse.parse(radioResponse) { + } else { + allSucceeded = false + } + + let txCommand = "set tx \(txPower)" + let txResponse = try await sendAndWait(txCommand) + if case .ok = CLIResponse.parse(txResponse) { + } else { + allSucceeded = false + } + + if allSucceeded { + radioSettingsModified = false + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess + showSuccessAlert = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + func applyIdentitySettings() async { + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + if let name, name != originalName { + let response = try await sendAndWait("set name \(name)") + if case .ok = CLIResponse.parse(response) { + originalName = name + } else { + allSucceeded = false + } + } + + if let latitude, latitude != originalLatitude { + let response = try await sendAndWait("set lat \(latitude)") + if case .ok = CLIResponse.parse(response) { + originalLatitude = latitude + } else { + allSucceeded = false + } + } + + if let longitude, longitude != originalLongitude { + let response = try await sendAndWait("set lon \(longitude)") + if case .ok = CLIResponse.parse(response) { + originalLongitude = longitude + } else { + allSucceeded = false + } + } + + if allSucceeded { + withAnimation { + isApplying = false + identityApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { identityApplySuccess = false } + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + func applyContactInfoSettings() async { + isApplying = true + errorMessage = nil + + do { + let pipeText = (ownerInfo ?? "").replacing("\n", with: "|") + let response = try await sendAndWait("set owner.info \(pipeText)") + if case .ok = CLIResponse.parse(response) { + originalOwnerInfo = ownerInfo + withAnimation { + isApplying = false + contactInfoApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { contactInfoApplySuccess = false } + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Location Picker + + func setLocationFromPicker(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + // MARK: - Security + + func changePassword() async { + guard !newPassword.isEmpty else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty + return + } + guard newPassword == confirmPassword else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch + return + } + + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("password \(newPassword)", rawMatching: true) + let parsed = CLIResponse.parse(response) + // Firmware echoes "password now: {pw}" on success, not "OK" + let isSuccess: Bool = switch parsed { + case .ok: true + case .raw(let text) where text.hasPrefix("password now:"): true + default: false + } + if isSuccess { + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangedSuccess + showSuccessAlert = true + newPassword = "" + confirmPassword = "" + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Device Actions + + func reboot() async { + guard session != nil else { return } + + isRebooting = true + errorMessage = nil + + do { + _ = try await sendAndWait("reboot") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + + isRebooting = false + } + + func forceAdvert() async { + do { + _ = try await sendAndWait("advert") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + } + + func syncTime() async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("clock sync") + switch CLIResponse.parse(response) { + case .ok: + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced + showSuccessAlert = true + case .error(let message): + if message.contains("clock cannot go backwards") { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError + } else { + let cleanMessage = message.replacing("ERR: ", with: "") + errorMessage = cleanMessage.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : cleanMessage + } + default: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Shared Validation + + struct BehaviorValidationErrors { + var advertInterval: String? + var floodInterval: String? + var floodMaxHops: String? + var hasErrors: Bool { advertInterval != nil || floodInterval != nil || floodMaxHops != nil } + } + + static func validateBehaviorFields( + advertInterval: Int?, + floodInterval: Int?, + floodMaxHops: Int? + ) -> BehaviorValidationErrors { + var errors = BehaviorValidationErrors() + if let interval = advertInterval, interval != 0 && (interval < 60 || interval > 240) { + errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation + } + if let interval = floodInterval, interval != 0 && (interval < 3 || interval > 168) { + errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation + } + if let hops = floodMaxHops, hops < 0 || hops > 64 { + errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation + } + return errors + } + + // MARK: - Shared Late Response Parsing + + enum BehaviorLateResponse { + case advertInterval(Int) + case floodAdvertInterval(Int) + case floodMax(Int) + } + + /// Try to parse a late response as one of the shared behavior fields. + /// Returns `nil` if the response didn't match any field that's still missing. + static func parseBehaviorLateResponse( + _ response: String, + hasAdvertInterval: Bool, + hasFloodInterval: Bool, + hasFloodMaxHops: Bool + ) -> BehaviorLateResponse? { + if !hasAdvertInterval { + if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + return .advertInterval(interval) + } + } + if !hasFloodInterval { + if case .floodAdvertInterval(let interval) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + return .floodAdvertInterval(interval) + } + } + if !hasFloodMaxHops { + if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + return .floodMax(hops) + } + } + return nil + } + + // MARK: - Late Response Handling + + /// Handle late CLI responses for shared sections. + /// Returns `true` if the response was consumed. + func handleCommonLateResponse(_ response: String) -> Bool { + // Radio settings + if !isLoadingRadio && radioError { + if frequency == nil { + if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { + self.frequency = freq + self.bandwidth = bw + self.spreadingFactor = sf + self.codingRate = cr + self.radioError = false + logger.info("Late response: received radio settings") + return true + } + } + + if txPower == nil { + if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { + self.txPower = power + self.radioError = false + logger.info("Late response: received TX power") + return true + } + } + } + + // Device info + if !isLoadingDeviceInfo && deviceInfoError { + if firmwareVersion == nil { + if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { + self.firmwareVersion = version + self.deviceInfoError = false + logger.info("Late response: received firmware version") + return true + } + } + + if deviceTimeUTC == nil { + if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { + self.deviceTimeUTC = time + self.deviceInfoError = false + logger.info("Late response: received device time") + return true + } + } + } + + // Identity settings (lat/lon before name to avoid numeric capture) + if !isLoadingIdentity && identityError { + if originalLatitude == nil { + if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { + self.latitude = lat + self.originalLatitude = lat + self.identityError = false + logger.info("Late response: received latitude") + return true + } + } + + if originalLongitude == nil { + if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { + self.longitude = lon + self.originalLongitude = lon + self.identityError = false + logger.info("Late response: received longitude") + return true + } + } + + if originalName == nil { + if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { + self.name = n + self.originalName = n + self.identityError = false + logger.info("Late response: received name") + return true + } + } + } + + // Contact info + if !isLoadingContactInfo && contactInfoError { + if originalOwnerInfo == nil { + if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = info.replacing("|", with: "\n") + self.ownerInfo = displayText + self.originalOwnerInfo = displayText + self.contactInfoError = false + logger.info("Late response: received owner info") + return true + } + } + } + + return false + } +} + +// MARK: - Shared Error Type + +enum NodeSettingsError: LocalizedError { + case noService + + var errorDescription: String? { + switch self { + case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService + } + } +} diff --git a/MC1/Views/RemoteNodes/NodeStatusHelper.swift b/MC1/Views/RemoteNodes/NodeStatusHelper.swift new file mode 100644 index 000000000..87079b901 --- /dev/null +++ b/MC1/Views/RemoteNodes/NodeStatusHelper.swift @@ -0,0 +1,423 @@ +import OSLog +import MC1Services +import SwiftUI + +private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusHelper") + +/// Shared logic for repeater and room status view models. +/// Owns retry machinery, display formatters, delta properties, OCV settings, +/// telemetry handling, and snapshot persistence. +@Observable +@MainActor +final class NodeStatusHelper { + + // MARK: - Properties + + /// Current session + var session: RemoteNodeSessionDTO? + + /// Last received status + var status: RemoteNodeStatus? + + /// Last received telemetry + var telemetry: TelemetryResponse? + + /// Cached decoded data points to avoid repeated LPP decoding. + private(set) var cachedDataPoints: [LPPDataPoint] = [] + + /// Loading states + var isLoadingStatus = false + var isLoadingTelemetry = false + + /// Whether telemetry has been loaded at least once (for refresh logic) + var telemetryLoaded = false + + /// Whether the telemetry disclosure group is expanded + var telemetryExpanded = false + + /// Error message if any + var errorMessage: String? + + // MARK: - OCV Curve Properties + + var isBatteryCurveExpanded = false + var selectedOCVPreset: OCVPreset = .liIon + var ocvValues: [Int] = OCVPreset.liIon.ocvArray + var ocvError: String? + private var contactID: UUID? + + // MARK: - Dependencies + + private var contactService: ContactService? + private(set) var nodeSnapshotService: NodeSnapshotService? + + // MARK: - Snapshot State + + /// ID of the current session's snapshot (for enrichment). + /// Because `handleStatusResponse` suspends while saving the snapshot, + /// telemetry handlers may fire before this is set. + /// In that case, enrichment data is buffered in `pendingTelemetryEntries` + /// and flushed once the ID is available. + private var currentSnapshotID: UUID? + + /// Buffered enrichment data received before `currentSnapshotID` was set. + private var pendingTelemetryEntries: [TelemetrySnapshotEntry]? + + /// Previous snapshot for delta display + private(set) var previousSnapshot: NodeStatusSnapshotDTO? + + // MARK: - Initialization + + func configure(contactService: ContactService?, nodeSnapshotService: NodeSnapshotService?) { + self.contactService = contactService + self.nodeSnapshotService = nodeSnapshotService + } + + // MARK: - Transient Retry Machinery + + private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum + + private static let transientRetryDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + ] + + func isTransientError(_ error: Error) -> Bool { + guard let remoteError = error as? RemoteNodeError, + case .sessionError(let meshError) = remoteError, + case .deviceError(let code) = meshError else { + return false + } + return code == 10 + } + + private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { + let remaining = deadline - .now + return remaining > .zero ? remaining : nil + } + + private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { + guard let remaining = remainingBudget(until: deadline) else { + throw RemoteNodeError.timeout + } + try await Task.sleep(for: min(delay, remaining)) + } + + func performWithTransientRetries( + operationName: String, + operation: @escaping @Sendable (Duration) async throws -> T + ) async throws -> T { + let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) + var delayIterator = Self.transientRetryDelays.makeIterator() + + while true { + guard let timeout = remainingBudget(until: deadline) else { + logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") + throw RemoteNodeError.timeout + } + + do { + return try await operation(timeout) + } catch { + guard isTransientError(error), let delay = delayIterator.next() else { + throw error + } + try await waitForRetry(delay: delay, until: deadline) + } + } + } + + // MARK: - Status Response Handling + + /// Handle a status response, saving a snapshot with role-specific fields. + /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames + /// but rooms pass `nil` to skip persistence of repeater-specific metrics. + func handleStatusResponse( + _ response: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) async { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + self.status = response + self.isLoadingStatus = false + + guard let nodeSnapshotService, let session else { return } + + let prev = await nodeSnapshotService.previousSnapshot( + for: session.publicKey, + before: .now + ) + self.previousSnapshot = prev + + let snapshotID = await nodeSnapshotService.saveStatusSnapshot( + nodePublicKey: session.publicKey, + batteryMillivolts: response.batteryMillivolts, + lastSNR: response.lastSNR, + lastRSSI: Int16(clamping: response.lastRSSI), + noiseFloor: Int16(clamping: response.noiseFloor), + uptimeSeconds: response.uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: response.packetsSent, + packetsReceived: response.packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + if let snapshotID { + self.currentSnapshotID = snapshotID + } else if let prevID = prev?.id { + self.currentSnapshotID = prevID + } + + if let enrichmentTarget = self.currentSnapshotID { + if let pending = pendingTelemetryEntries { + pendingTelemetryEntries = nil + Task { await nodeSnapshotService.enrichWithTelemetry(pending, snapshotID: enrichmentTarget) } + } + } + } + + /// Flush buffered neighbor enrichment data. Called by repeater VM after + /// status response sets `currentSnapshotID`. + func flushPendingNeighborEntries(_ entries: [NeighborSnapshotEntry]) { + guard let snapshotID = currentSnapshotID else { return } + Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } + } + + /// Enrich the current snapshot with neighbor data, or return `false` if + /// the snapshot ID isn't ready yet (caller should buffer). + func enrichWithNeighbors(_ entries: [NeighborSnapshotEntry]) -> Bool { + guard let snapshotID = currentSnapshotID else { return false } + Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } + return true + } + + // MARK: - Telemetry Response Handling + + func handleTelemetryResponse(_ response: TelemetryResponse) { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + self.telemetry = response + self.cachedDataPoints = response.dataPoints + self.isLoadingTelemetry = false + self.telemetryLoaded = true + + let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in + let numericValue: Double? + switch dp.value { + case .float(let value): + numericValue = value + case .integer(let value): + numericValue = Double(value) + default: + numericValue = nil + } + guard let value = numericValue else { return nil } + return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) + } + if !entries.isEmpty { + if let snapshotID = currentSnapshotID { + Task { await nodeSnapshotService?.enrichWithTelemetry(entries, snapshotID: snapshotID) } + } else { + pendingTelemetryEntries = entries + } + } + } + + // MARK: - Telemetry Grouping + + var hasMultipleChannels: Bool { + let channels = Set(cachedDataPoints.map(\.channel)) + return channels.count > 1 + } + + var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { + Dictionary(grouping: cachedDataPoints, by: \.channel) + .sorted { $0.key < $1.key } + .map { (channel: $0.key, dataPoints: $0.value) } + } + + // MARK: - Display Formatters + + static let emDash = "—" + private static let secondsPerMinute: UInt32 = 60 + private static let secondsPerHour: UInt32 = 3_600 + private static let secondsPerDay: UInt32 = 86_400 + + var uptimeDisplay: String { + guard let uptime = status?.uptimeSeconds else { return Self.emDash } + let days = Int(uptime / Self.secondsPerDay) + let hours = Int((uptime % Self.secondsPerDay) / Self.secondsPerHour) + let minutes = Int((uptime % Self.secondsPerHour) / Self.secondsPerMinute) + + if days > 0 { + if days == 1 { + return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) + } + } else if hours > 0 { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) + } + return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) + } + + var batteryDisplay: String { + guard let mv = status?.batteryMillivolts else { return Self.emDash } + let volts = Double(mv) / 1000.0 + let battery = BatteryInfo(level: Int(mv)) + let percent = battery.percentage(using: ocvValues) + return "\(volts.formatted(.number.precision(.fractionLength(2))))V (\(percent)%)" + } + + var lastRSSIDisplay: String { + guard let rssi = status?.lastRSSI else { return Self.emDash } + return "\(rssi) dBm" + } + + var lastSNRDisplay: String { + guard let snr = status?.lastSNR else { return Self.emDash } + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" + } + + var noiseFloorDisplay: String { + guard let nf = status?.noiseFloor else { return Self.emDash } + return "\(nf) dBm" + } + + var packetsSentDisplay: String { + guard let count = status?.packetsSent else { return Self.emDash } + return count.formatted() + } + + var packetsReceivedDisplay: String { + guard let count = status?.packetsReceived else { return Self.emDash } + return count.formatted() + } + + // MARK: - Delta Display + + var previousSnapshotTimestamp: String? { + guard let prev = previousSnapshot else { return nil } + let interval = prev.timestamp.distance(to: .now) + let secondsPerHour = TimeInterval(Self.secondsPerHour) + let secondsPerDay = TimeInterval(Self.secondsPerDay) + if interval < secondsPerHour { + return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) + } else if interval < secondsPerDay { + return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) + } else { + return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) + } + } + + var batteryDeltaMV: Int? { + guard let current = status?.batteryMillivolts, + let previous = previousSnapshot?.batteryMillivolts else { return nil } + return Int(current) - Int(previous) + } + + var snrDelta: Double? { + guard let current = status?.lastSNR, + let previous = previousSnapshot?.lastSNR else { return nil } + return current - previous + } + + var rssiDelta: Int? { + guard let current = status?.lastRSSI, + let previous = previousSnapshot?.lastRSSI else { return nil } + return Int(current) - Int(previous) + } + + var noiseFloorDelta: Int? { + guard let current = status?.noiseFloor, + let previous = previousSnapshot?.noiseFloor else { return nil } + return Int(current) - Int(previous) + } + + // MARK: - History + + func fetchHistory() async -> [NodeStatusSnapshotDTO] { + guard let nodeSnapshotService, let session else { + logger.warning("fetchHistory: nodeSnapshotService or session is nil") + return [] + } + return await nodeSnapshotService.fetchSnapshots(for: session.publicKey) + } + + // MARK: - OCV Settings + + /// Load OCV settings for a contact by public key. Skips reload if already loaded. + func loadOCVSettings(publicKey: Data, deviceID: UUID) async { + guard contactID == nil else { return } + guard let contactService else { return } + + do { + if let contact = try await contactService.getContact(deviceID: deviceID, publicKey: publicKey) { + contactID = contact.id + + if let presetName = contact.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return + } + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray + return + } + } + + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed + } + } + + func saveOCVSettings(preset: OCVPreset, values: [Int]) async { + guard let contactService, + let contactID else { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact + return + } + + ocvError = nil + + do { + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: OCVPreset.custom.rawValue, + customArray: customString + ) + } else { + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: preset.rawValue, + customArray: nil + ) + } + + selectedOCVPreset = preset + ocvValues = values + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.localizedDescription) + } + } +} diff --git a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift index a97cca7f5..489f39679 100644 --- a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift +++ b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift @@ -16,12 +16,13 @@ struct NodeStatusHistoryView: View { } var body: some View { + let filtered = filteredSnapshots List { HistoryTimeRangePicker(selection: $timeRange) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.batteryMillivolts.map { .init(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) } }, yAxisDomain: ocvArray.voltageChartDomain() @@ -29,46 +30,60 @@ struct NodeStatusHistoryView: View { metricSection( title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, unit: "", color: .green, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.packetsSent.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, unit: "", color: .orange, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.packetsReceived.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) + metricSection( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, + dataPoints: filtered.compactMap { s in + s.postedCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricSection( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .mint, + dataPoints: filtered.compactMap { s in + s.postPushCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + Section { } footer: { Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift index aaf5334de..65462d725 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift @@ -2,39 +2,24 @@ import SwiftUI import MC1Services import CoreLocation -private enum SettingsField: Hashable { - case frequency - case txPower - case advertInterval - case floodAdvertInterval - case floodMaxHops - case identityName - case contactInfo -} - struct RepeaterSettingsView: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss - @FocusState private var focusedField: SettingsField? + @FocusState private var focusedField: NodeSettingsField? let session: RemoteNodeSessionDTO @State private var viewModel = RepeaterSettingsViewModel() @State private var showRebootConfirmation = false @State private var showingLocationPicker = false - /// Bandwidth options in kHz for CLI protocol (derived from RadioOptions.bandwidthsHz) - private var bandwidthOptionsKHz: [Double] { - RadioOptions.bandwidthsHz.map { Double($0) / 1000.0 } - } - var body: some View { Form { - SettingsHeaderSection(publicKey: session.publicKey, name: session.name) + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) makeRadioSettingsSection() - makeIdentitySection() - makeContactInfoSection() makeBehaviorSection() makeRegionsSection() + makeIdentitySection() + makeContactInfoSection() makeSecuritySection() makeDeviceInfoSection() makeActionsSection() @@ -57,19 +42,19 @@ struct RepeaterSettingsView: View { await viewModel.cleanup() } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.showSuccessAlert) { + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } } message: { - Text(viewModel.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) } .sheet(isPresented: $showingLocationPicker) { LocationPickerView( initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.latitude ?? 0, - longitude: viewModel.longitude ?? 0 + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 ) ) { coordinate in - viewModel.setLocationFromPicker( + viewModel.helper.setLocationFromPicker( latitude: coordinate.latitude, longitude: coordinate.longitude ) @@ -80,27 +65,26 @@ struct RepeaterSettingsView: View { // MARK: - Subviews private func makeDeviceInfoSection() -> some View { - DeviceInfoSection(viewModel: viewModel) + NodeDeviceInfoSection(settings: viewModel.helper) } private func makeRadioSettingsSection() -> some View { - RadioSettingsSection( - viewModel: viewModel, - focusedField: $focusedField, - bandwidthOptionsKHz: bandwidthOptionsKHz + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField ) } private func makeIdentitySection() -> some View { - IdentitySection( - viewModel: viewModel, + RemoteNodeIdentitySection( + settings: viewModel.helper, focusedField: $focusedField, onPickLocation: { showingLocationPicker = true } ) } private func makeContactInfoSection() -> some View { - ContactInfoSection(viewModel: viewModel, focusedField: $focusedField) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) } private func makeBehaviorSection() -> some View { @@ -112,414 +96,22 @@ struct RepeaterSettingsView: View { } private func makeSecuritySection() -> some View { - SecuritySection(viewModel: viewModel) + NodeSecuritySection(settings: viewModel.helper) } private func makeActionsSection() -> some View { - ActionsSection( - viewModel: viewModel, + NodeActionsSection( + settings: viewModel.helper, showRebootConfirmation: $showRebootConfirmation ) } } -// MARK: - Settings Header Section - -private struct SettingsHeaderSection: View { - let publicKey: Data - let name: String - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: .repeater, size: 60) - Text(name) - .font(.headline) - } - Spacer() - } - .listRowBackground(Color.clear) - } - } -} - -// MARK: - Device Info Section - -private struct DeviceInfoSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, - icon: "info.circle", - isExpanded: $viewModel.isDeviceInfoExpanded, - isLoaded: { viewModel.deviceInfoLoaded }, - isLoading: $viewModel.isLoadingDeviceInfo, - error: $viewModel.deviceInfoError, - onLoad: { await viewModel.fetchDeviceInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter - ) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: viewModel.firmwareVersion ?? "\u{2014}") - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: viewModel.deviceTime ?? "\u{2014}") - } - } -} - -// MARK: - Radio Settings Section - -private struct RadioSettingsSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - let bandwidthOptionsKHz: [Double] - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, - icon: "antenna.radiowaves.left.and.right", - isExpanded: $viewModel.isRadioExpanded, - isLoaded: { viewModel.radioLoaded }, - isLoading: $viewModel.isLoadingRadio, - error: $viewModel.radioError, - onLoad: { await viewModel.fetchRadioSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter - ) { - if viewModel.radioSettingsModified { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning) - .font(.subheadline) - } - .padding() - .frame(maxWidth: .infinity) - .background(.yellow.opacity(0.1)) - .clipShape(.rect(cornerRadius: 8)) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) - Spacer() - if let frequency = viewModel.frequency { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( - get: { frequency }, - set: { viewModel.frequency = $0 } - ), format: .number.precision(.fractionLength(3)).locale(.posix)) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused(focusedField, equals: .frequency) - .onChange(of: viewModel.frequency) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 100, alignment: .trailing) - } - } - - if let bandwidth = viewModel.bandwidth { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( - get: { bandwidth }, - set: { viewModel.bandwidth = $0 } - )) { - ForEach(bandwidthOptionsKHz, id: \.self) { bwKHz in - Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) - .tag(bwKHz) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) - .onChange(of: viewModel.bandwidth) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let spreadingFactor = viewModel.spreadingFactor { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( - get: { spreadingFactor }, - set: { viewModel.spreadingFactor = $0 } - )) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in - Text(sf, format: .number) - .tag(sf) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) - .onChange(of: viewModel.spreadingFactor) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let codingRate = viewModel.codingRate { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( - get: { codingRate }, - set: { viewModel.codingRate = $0 } - )) { - ForEach(RadioOptions.codingRates, id: \.self) { cr in - Text("\(cr)") - .tag(cr) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) - .onChange(of: viewModel.codingRate) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) - Spacer() - if let txPower = viewModel.txPower { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( - get: { txPower }, - set: { viewModel.txPower = $0 } - ), format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .txPower) - .onChange(of: viewModel.txPower) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 60, alignment: .trailing) - } - } - - Button { - Task { await viewModel.applyRadioSettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) - .foregroundStyle(viewModel.radioSettingsModified ? Color.accentColor : .secondary) - } - Spacer() - } - } - .disabled(viewModel.isApplying || !viewModel.radioSettingsModified) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - } -} - -// MARK: - Identity Section - -private struct IdentitySection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - let onPickLocation: () -> Void - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, - icon: "person.text.rectangle", - isExpanded: $viewModel.isIdentityExpanded, - isLoaded: { viewModel.identityLoaded }, - isLoading: $viewModel.isLoadingIdentity, - error: $viewModel.identityError, - onLoad: { await viewModel.fetchIdentity() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter - ) { - if viewModel.isLoadingIdentity && viewModel.name == nil { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.name) - .foregroundStyle(.secondary) - Spacer() - Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) - .font(.caption) - .foregroundStyle(.secondary) - } - } else { - TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( - get: { viewModel.name ?? "" }, - set: { viewModel.name = $0 } - )) - .textContentType(.name) - .submitLabel(.done) - .focused(focusedField, equals: .identityName) - .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) - Spacer() - if let latitude = viewModel.latitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.lat, value: Binding( - get: { latitude }, - set: { viewModel.latitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 120) - } else { - Text(viewModel.isLoadingIdentity ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.identityError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) - Spacer() - if let longitude = viewModel.longitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.lon, value: Binding( - get: { longitude }, - set: { viewModel.longitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 120) - } else { - Text(viewModel.isLoadingIdentity ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.identityError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) - } - } - - Button { - onPickLocation() - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "mappin.and.ellipse") - } - .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } - - Button { - Task { await viewModel.applyIdentitySettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else if viewModel.identityApplySuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) - .foregroundStyle(viewModel.identitySettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - Spacer() - } - .animation(.default, value: viewModel.identityApplySuccess) - } - .disabled(viewModel.isApplying || viewModel.identityApplySuccess || !viewModel.identitySettingsModified) - } - } -} - -// MARK: - Contact Info Section - -private struct ContactInfoSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - - private static let maxCharacters = 119 - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, - icon: "person.crop.rectangle", - isExpanded: $viewModel.isContactInfoExpanded, - isLoaded: { viewModel.contactInfoLoaded }, - isLoading: $viewModel.isLoadingContactInfo, - error: $viewModel.contactInfoError, - onLoad: { await viewModel.fetchContactInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter - ) { - TextField( - L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, - text: Binding( - get: { viewModel.ownerInfo ?? "" }, - set: { viewModel.ownerInfo = $0 } - ), - axis: .vertical - ) - .lineLimit(3...6) - .focused(focusedField, equals: .contactInfo) - .overlay(alignment: .bottomTrailing) { - let count = viewModel.ownerInfoCharCount - Text(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoCharCount(count)) - .font(.caption2) - .foregroundStyle(count > Self.maxCharacters ? Color.red : Color.secondary.opacity(0.6)) - .padding(4) - } - - Button { - Task { await viewModel.applyContactInfoSettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else if viewModel.contactInfoApplySuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) - .foregroundStyle(viewModel.contactInfoSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - Spacer() - } - .animation(.default, value: viewModel.contactInfoApplySuccess) - } - .disabled(viewModel.isApplying || viewModel.contactInfoApplySuccess || !viewModel.contactInfoSettingsModified || viewModel.ownerInfoCharCount > Self.maxCharacters) - } - } -} - // MARK: - Behavior Section private struct BehaviorSection: View { @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding + var focusedField: FocusState.Binding var body: some View { ExpandableSettingsSection( @@ -528,7 +120,7 @@ private struct BehaviorSection: View { isExpanded: $viewModel.isBehaviorExpanded, isLoaded: { viewModel.behaviorLoaded }, isLoading: $viewModel.isLoadingBehavior, - error: $viewModel.behaviorError, + hasError: $viewModel.behaviorError, onLoad: { await viewModel.fetchBehaviorSettings() }, footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter ) { @@ -560,7 +152,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.min) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -587,7 +179,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -614,7 +206,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -631,7 +223,7 @@ private struct BehaviorSection: View { } label: { HStack { Spacer() - if viewModel.isApplying { + if viewModel.helper.isApplying { ProgressView() } else if viewModel.behaviorApplySuccess { Image(systemName: "checkmark.circle.fill") @@ -646,7 +238,7 @@ private struct BehaviorSection: View { } .animation(.default, value: viewModel.behaviorApplySuccess) } - .disabled(viewModel.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) + .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) } } } @@ -679,7 +271,7 @@ private struct RegionsSection: View { isExpanded: $viewModel.isRegionsExpanded, isLoaded: { viewModel.regionsLoaded }, isLoading: $viewModel.isLoadingRegions, - error: $viewModel.regionsError, + hasError: $viewModel.regionsError, onLoad: { await viewModel.fetchRegions() }, footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter ) { @@ -725,7 +317,7 @@ private struct RegionsSection: View { : region.name ) .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) - .disabled(viewModel.isApplying) + .disabled(viewModel.helper.isApplying) } .onDelete { offsets in let sorted = sortedRegions @@ -740,7 +332,7 @@ private struct RegionsSection: View { Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { viewModel.isAddingRegion = true } - .disabled(viewModel.isApplying) + .disabled(viewModel.helper.isApplying) // Save to device button if viewModel.regionsLoaded { @@ -749,7 +341,7 @@ private struct RegionsSection: View { } label: { HStack { Spacer() - if viewModel.isApplying { + if viewModel.helper.isApplying { ProgressView() } else if viewModel.regionsSaveSuccess { Image(systemName: "checkmark.circle.fill") @@ -764,7 +356,7 @@ private struct RegionsSection: View { } .animation(.default, value: viewModel.regionsSaveSuccess) } - .disabled(viewModel.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) + .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) } } .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { @@ -781,69 +373,6 @@ private struct RegionsSection: View { } } -// MARK: - Security Section - -private struct SecuritySection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.isSecurityExpanded) { - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $viewModel.newPassword) - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $viewModel.confirmPassword) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) { - Task { await viewModel.changePassword() } - } - .disabled(viewModel.isApplying || viewModel.newPassword.isEmpty || viewModel.newPassword != viewModel.confirmPassword) - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) - } - } -} - -// MARK: - Actions Section - -private struct ActionsSection: View { - let viewModel: RepeaterSettingsViewModel - @Binding var showRebootConfirmation: Bool - - var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) { - Task { await viewModel.forceAdvert() } - } - - Button(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) { - Task { await viewModel.syncTime() } - } - .disabled(viewModel.isApplying) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { - showRebootConfirmation = true - } - .disabled(viewModel.isRebooting) - .confirmationDialog(L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle, isPresented: $showRebootConfirmation) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { - Task { await viewModel.reboot() } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } - } message: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage) - } - - if let error = viewModel.errorMessage { - Text(error) - .foregroundStyle(.red) - .font(.caption) - } - } - } -} - #Preview { NavigationStack { RepeaterSettingsView( diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift index 9d5fb321c..a9335d062 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift @@ -6,88 +6,12 @@ import OSLog @MainActor final class RepeaterSettingsViewModel { - // MARK: - Properties + // MARK: - Shared Helper - var session: RemoteNodeSessionDTO? + var helper = NodeSettingsHelper() - // Device info (read-only from ver/clock) - var firmwareVersion: String? - private var deviceTimeUTC: String? - var isLoadingDeviceInfo = false - var deviceInfoError: String? - var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + // MARK: - Repeater-Only: Behavior Settings - /// Device time converted to user's local timezone and locale - var deviceTime: String? { - guard let utcString = deviceTimeUTC else { return nil } - return Self.convertUTCToLocal(utcString) - } - - /// Convert UTC time string (e.g., "06:40 - 18/4/2025 UTC") to local time using user's locale - private static func convertUTCToLocal(_ utcString: String) -> String { - // Format: "HH:mm - d/M/yyyy UTC" - let pattern = #"(\d{1,2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) UTC"# - guard let regex = try? Regex(pattern), - let match = utcString.firstMatch(of: regex), - match.count >= 3 else { - return utcString - } - - let timeStr = String(match[1].substring ?? "") - let dateStr = String(match[2].substring ?? "") - - let inputFormatter = DateFormatter() - inputFormatter.dateFormat = "HH:mm d/M/yyyy" - inputFormatter.timeZone = TimeZone(identifier: "UTC") - - guard let date = inputFormatter.date(from: "\(timeStr) \(dateStr)") else { - return utcString - } - - let timeString = date.formatted(date: .omitted, time: .shortened) - let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) - return "\(timeString) - \(dateString)" - } - - // Identity settings (from get name, get lat, get lon) - var name: String? - var latitude: Double? - var longitude: Double? - private var originalName: String? - private var originalLatitude: Double? - private var originalLongitude: Double? - var isLoadingIdentity = false - var identityError: String? - var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } - - // Radio settings (from get radio, get tx) - var frequency: Double? - var bandwidth: Double? - var spreadingFactor: Int? - var codingRate: Int? - var txPower: Int? - var isLoadingRadio = false - var radioError: String? - var radioLoaded: Bool { frequency != nil || txPower != nil } - - // Contact info settings (from get owner.info) - var ownerInfo: String? - private var originalOwnerInfo: String? - var isLoadingContactInfo = false - var contactInfoError: String? - var contactInfoLoaded: Bool { originalOwnerInfo != nil } - - /// Track if contact info has been modified - var contactInfoSettingsModified: Bool { - ownerInfo != originalOwnerInfo - } - - /// Character count (newlines and pipes are both single characters, so count is the same) - var ownerInfoCharCount: Int { - (ownerInfo ?? "").count - } - - // Behavior settings (from get repeat, get advert.interval, get flood.max) var advertIntervalMinutes: Int? var floodAdvertIntervalHours: Int? var floodMaxHops: Int? @@ -97,66 +21,39 @@ final class RepeaterSettingsViewModel { private var originalFloodMaxHops: Int? private var originalRepeaterEnabled: Bool? var isLoadingBehavior = false - var behaviorError: String? + var behaviorError = false var behaviorLoaded: Bool { repeaterEnabled != nil || advertIntervalMinutes != nil } - // Validation errors for behavior fields var advertIntervalError: String? var floodAdvertIntervalError: String? var floodMaxHopsError: String? - // Region settings (from bare "region" CLI command) + var behaviorApplySuccess = false + + var behaviorSettingsModified: Bool { + (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Repeater-Only: Region Settings + nonisolated static let wildcardName = "*" var regions: [RepeaterRegionEntry] = [] private var originalRegions: [RepeaterRegionEntry]? var isLoadingRegions = false - var regionsError: String? + var regionsError = false var regionsLoaded: Bool { originalRegions != nil } var hasUnsavedRegionChanges = false var isAddingRegion = false var newRegionName = "" var regionsSaveSuccess = false - // Password change (no query available) - var newPassword: String = "" - var confirmPassword: String = "" + // MARK: - Expansion State (repeater-only sections) - // Expansion state for DisclosureGroups - var isDeviceInfoExpanded = false - var isRadioExpanded = false - var isIdentityExpanded = false - var isContactInfoExpanded = false var isBehaviorExpanded = false var isRegionsExpanded = false - var isSecurityExpanded = false - - // State - var isApplying = false - var isRebooting = false - var errorMessage: String? - var successMessage: String? - var showSuccessAlert = false - var identityApplySuccess = false - var behaviorApplySuccess = false - var contactInfoApplySuccess = false - - /// Track if radio settings have been modified (requires restart) - var radioSettingsModified = false - - /// Track if identity settings have been modified - var identitySettingsModified: Bool { - (name != nil && name != originalName) || - (latitude != nil && latitude != originalLatitude) || - (longitude != nil && longitude != originalLongitude) - } - - /// Track if behavior settings have been modified - var behaviorSettingsModified: Bool { - (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) - } // MARK: - Dependencies @@ -165,231 +62,110 @@ final class RepeaterSettingsViewModel { // MARK: - Cleanup - /// Cancel any pending operations when view disappears func cleanup() async { - // Clear CLI handler to stop receiving responses await repeaterAdminService?.setCLIHandler { _, _ in } - } - - // MARK: - Synchronous Command-Response - - /// Send a CLI command and wait for its response - /// - Parameters: - /// - command: The CLI command to send (e.g., "get name", "ver") - /// - timeout: Maximum time to wait for response (default 5 seconds) - /// - rawMatching: Use FIFO matching instead of content-based matching. - /// Required for commands whose responses don't match any CLIResponse pattern - /// (e.g., bare `region` tree output, `region home` responses). - /// - Returns: The raw response text from the repeater - /// - Throws: RepeaterSettingsError.timeout if no response received - private func sendAndWait( - _ command: String, - timeout: Duration = .seconds(5), - rawMatching: Bool = false - ) async throws -> String { - guard let session, let service = repeaterAdminService else { - throw RepeaterSettingsError.noService - } - - let response: String - if rawMatching { - response = try await service.sendRawCommand(sessionID: session.id, command: command, timeout: timeout) - } else { - response = try await service.sendCommand(sessionID: session.id, command: command, timeout: timeout) - } - logger.debug("Command '\(command)' response: \(response.prefix(50))") - return response + helper.cleanup() } // MARK: - Configuration func configure(appState: AppState, session: RemoteNodeSessionDTO) async { self.repeaterAdminService = appState.services?.repeaterAdminService - self.session = session - self.name = session.name - // Register CLI handler to receive late responses - await repeaterAdminService?.setCLIHandler { [weak self] message, _ in + guard let repeaterAdminService else { return } + + helper.configure( + session: session, + sendCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.name = session.name + + helper.onPreFetchNodeInfo = { [weak self] in + await self?.fetchNodeInfo() + } + + // Register CLI handler for late responses + await repeaterAdminService.setCLIHandler { [weak self] message, _ in await MainActor.run { self?.handleLateResponse(message.text) } } - // Pre-fetch firmware version, node name, and owner info via binary protocol await fetchNodeInfo() } private var isLoadingNodeInfo = false - /// Fetch firmware version, node name, and owner info via a single binary request. private func fetchNodeInfo() async { - guard !isLoadingNodeInfo, let session, let repeaterAdminService else { return } + guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } isLoadingNodeInfo = true defer { isLoadingNodeInfo = false } do { let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) - firmwareVersion = response.firmwareVersion - name = response.nodeName - originalName = response.nodeName - ownerInfo = response.ownerInfo - originalOwnerInfo = response.ownerInfo + helper.setNodeInfo( + firmwareVersion: response.firmwareVersion, + name: response.nodeName, + ownerInfo: response.ownerInfo + ) } catch { logger.warning("Failed to fetch node info via binary: \(error)") } } - /// Handle late CLI responses that arrive after timeout - private func handleLateResponse(_ response: String) { - // Only process responses for sections that: - // 1. Have finished loading (not currently loading) - // 2. Had an error (so we're actually expecting late responses) - // This prevents responses from being incorrectly parsed as other field types. - - // Radio settings - only process if finished loading with error - if !isLoadingRadio && radioError != nil { - if frequency == nil { - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - self.radioError = nil - logger.info("Late response: received radio settings") - return - } - } - - if txPower == nil { - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - self.radioError = nil - logger.info("Late response: received TX power") - return - } - } - } - - // Device info - only process if finished loading with error - if !isLoadingDeviceInfo && deviceInfoError != nil { - if firmwareVersion == nil { - if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { - self.firmwareVersion = version - self.deviceInfoError = nil - logger.info("Late response: received firmware version") - return - } - } - - if deviceTimeUTC == nil { - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - self.deviceInfoError = nil - logger.info("Late response: received device time") - return - } - } - } - - // Identity settings - only process if finished loading with error - // Check lat/lon before name: lat/lon require valid Double parsing, - // while name accepts any string and would incorrectly capture numeric values. - if !isLoadingIdentity && identityError != nil { - if originalLatitude == nil { - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - self.identityError = nil - logger.info("Late response: received latitude") - return - } - } - - if originalLongitude == nil { - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - self.identityError = nil - logger.info("Late response: received longitude") - return - } - } + // MARK: - Late Response Handling - if originalName == nil { - if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { - self.name = n - self.originalName = n - self.identityError = nil - logger.info("Late response: received name") - return - } - } - } + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } - // Behavior settings - only process if finished loading with error - if !isLoadingBehavior && behaviorError != nil { + // Behavior settings + if !isLoadingBehavior && behaviorError { if originalRepeaterEnabled == nil { if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { self.repeaterEnabled = enabled self.originalRepeaterEnabled = enabled - self.behaviorError = nil + self.behaviorError = false logger.info("Late response: received repeat mode") return } } - if originalAdvertIntervalMinutes == nil { - if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + if let result = NodeSettingsHelper.parseBehaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case .advertInterval(let interval): self.advertIntervalMinutes = interval self.originalAdvertIntervalMinutes = interval - self.behaviorError = nil - logger.info("Late response: received advert interval") - return - } - } - - if originalFloodAdvertIntervalHours == nil { - if case .floodAdvertInterval(let interval) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + case .floodAdvertInterval(let interval): self.floodAdvertIntervalHours = interval self.originalFloodAdvertIntervalHours = interval - self.behaviorError = nil - logger.info("Late response: received flood advert interval") - return - } - } - - if originalFloodMaxHops == nil { - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + case .floodMax(let hops): self.floodMaxHops = hops self.originalFloodMaxHops = hops - self.behaviorError = nil - logger.info("Late response: received flood max hops") - return - } - } - } - - // Contact info - only process if finished loading with error - if !isLoadingContactInfo && contactInfoError != nil { - if originalOwnerInfo == nil { - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = info.replacing("|", with: "\n") - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - self.contactInfoError = nil - logger.info("Late response: received owner info") - return } + self.behaviorError = false + return } } - // Regions - only process if finished loading with error - if !isLoadingRegions && regionsError != nil { + // Regions + if !isLoadingRegions && regionsError { if originalRegions == nil { let parsed = Self.parseRegionTree(response) if !parsed.isEmpty { self.regions = parsed self.originalRegions = parsed - self.regionsError = nil + self.regionsError = false logger.info("Late response: received region tree (\(parsed.count) regions)") return } @@ -397,378 +173,84 @@ final class RepeaterSettingsViewModel { } } - // MARK: - Fetch Methods (Pull-to-Load) - - /// Fetch device info (device time; firmware version is pre-fetched via binary) - func fetchDeviceInfo() async { - isLoadingDeviceInfo = true - deviceInfoError = nil - - if firmwareVersion == nil { - await fetchNodeInfo() - } - - // Get device time - do { - let response = try await sendAndWait("clock") - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - logger.debug("Received device time: \(time)") - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = "error" - } - logger.warning("Failed to get device time: \(error)") - } - - isLoadingDeviceInfo = false - } - - /// Fetch identity settings (latitude, longitude; name is pre-fetched via binary) - func fetchIdentity() async { - isLoadingIdentity = true - identityError = nil - var hadTimeout = false - - if originalName == nil { - await fetchNodeInfo() - } - - // Get latitude - do { - let response = try await sendAndWait("get lat") - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - logger.debug("Received latitude: \(lat)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get latitude: \(error)") - } - - // Get longitude - do { - let response = try await sendAndWait("get lon") - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - logger.debug("Received longitude: \(lon)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get longitude: \(error)") - } - - if hadTimeout { - identityError = "error" - } - - isLoadingIdentity = false - } - - /// Fetch radio settings (frequency, bandwidth, SF, CR, TX power) - func fetchRadioSettings() async { - isLoadingRadio = true - radioError = nil - var hadTimeout = false - - // Get TX power first - do { - let response = try await sendAndWait("get tx") - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - logger.debug("Received TX power: \(power)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get TX power: \(error)") - } - - // Get radio parameters - do { - let response = try await sendAndWait("get radio") - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - logger.debug("Received radio: \(freq),\(bw),\(sf),\(cr)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get radio settings: \(error)") - } - - // Show error if any request timed out (even if some succeeded) - if hadTimeout { - radioError = "error" - } - - isLoadingRadio = false - } + // MARK: - Behavior Fetch/Apply - /// Fetch behavior settings (repeat mode, advert intervals, flood max) func fetchBehaviorSettings() async { isLoadingBehavior = true - behaviorError = nil + behaviorError = false var hadTimeout = false - // Get repeat mode do { - let response = try await sendAndWait("get repeat") + let response = try await helper.sendAndWait("get repeat") if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { self.repeaterEnabled = enabled self.originalRepeaterEnabled = enabled - logger.debug("Received repeat mode: \(enabled)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get repeat mode: \(error)") } - // Get advert interval do { - let response = try await sendAndWait("get advert.interval") + let response = try await helper.sendAndWait("get advert.interval") if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { self.advertIntervalMinutes = minutes self.originalAdvertIntervalMinutes = minutes - logger.debug("Received advert interval: \(minutes)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get advert interval: \(error)") } - // Get flood advert interval do { - let response = try await sendAndWait("get flood.advert.interval") + let response = try await helper.sendAndWait("get flood.advert.interval") if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { self.floodAdvertIntervalHours = hours self.originalFloodAdvertIntervalHours = hours - logger.debug("Received flood advert interval: \(hours) hours") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get flood advert interval: \(error)") } - // Get flood max do { - let response = try await sendAndWait("get flood.max") + let response = try await helper.sendAndWait("get flood.max") if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { self.floodMaxHops = hops self.originalFloodMaxHops = hops - logger.debug("Received flood max: \(hops)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get flood max: \(error)") } - // Show error if any request timed out (even if some succeeded) if hadTimeout { - behaviorError = "error" + behaviorError = true } isLoadingBehavior = false } - /// Fetch contact info (owner.info) - func fetchContactInfo() async { - if originalOwnerInfo == nil { - await fetchNodeInfo() - } - if originalOwnerInfo != nil { return } - - isLoadingContactInfo = true - contactInfoError = nil - - do { - let response = try await sendAndWait("get owner.info") - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = info.replacing("|", with: "\n") - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - logger.debug("Received owner info: \(info.prefix(50))") - } - } catch { - if case RemoteNodeError.timeout = error { - contactInfoError = "error" - } - logger.warning("Failed to get owner info: \(error)") - } - - isLoadingContactInfo = false - } - - // MARK: - Settings Actions - - /// Apply all radio settings including TX power (requires restart) - func applyRadioSettings() async { - guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded - return - } - - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" - let radioResponse = try await sendAndWait(radioCommand) - if case .ok = CLIResponse.parse(radioResponse) { - // Radio params accepted - } else { - allSucceeded = false - } - - let txCommand = "set tx \(txPower)" - let txResponse = try await sendAndWait(txCommand) - if case .ok = CLIResponse.parse(txResponse) { - // TX power accepted - } else { - allSucceeded = false - } - - if allSucceeded { - radioSettingsModified = false - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess - showSuccessAlert = true - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply only changed identity settings (name, latitude, longitude) - func applyIdentitySettings() async { - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - if let name, name != originalName { - let response = try await sendAndWait("set name \(name)") - if case .ok = CLIResponse.parse(response) { - originalName = name - } else { - allSucceeded = false - } - } - - if let latitude, latitude != originalLatitude { - let response = try await sendAndWait("set lat \(latitude)") - if case .ok = CLIResponse.parse(response) { - originalLatitude = latitude - } else { - allSucceeded = false - } - } - - if let longitude, longitude != originalLongitude { - let response = try await sendAndWait("set lon \(longitude)") - if case .ok = CLIResponse.parse(response) { - originalLongitude = longitude - } else { - allSucceeded = false - } - } - - if allSucceeded { - withAnimation { - isApplying = false - identityApplySuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { identityApplySuccess = false } - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply contact info (owner.info) - func applyContactInfoSettings() async { - isApplying = true - errorMessage = nil - - do { - let pipeText = (ownerInfo ?? "").replacing("\n", with: "|") - let response = try await sendAndWait("set owner.info \(pipeText)") - if case .ok = CLIResponse.parse(response) { - originalOwnerInfo = ownerInfo - withAnimation { - isApplying = false - contactInfoApplySuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { contactInfoApplySuccess = false } - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply only changed behavior settings (repeat mode, intervals, flood max) func applyBehaviorSettings() async { - // Clear previous validation errors - advertIntervalError = nil - floodAdvertIntervalError = nil - floodMaxHopsError = nil - - // Validate 0-hop interval: accepts 0 (disabled) or 60-240 - if let interval = advertIntervalMinutes { - if interval != 0 && (interval < 60 || interval > 240) { - advertIntervalError = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation - } - } - - // Validate flood interval: accepts 3-48 - if let interval = floodAdvertIntervalHours { - if interval < 3 || interval > 48 { - floodAdvertIntervalError = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation - } - } - - // Validate flood max hops: accepts 0-64 - if let hops = floodMaxHops { - if hops < 0 || hops > 64 { - floodMaxHopsError = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation - } - } + let validation = NodeSettingsHelper.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops - // Don't proceed if validation failed - if advertIntervalError != nil || floodAdvertIntervalError != nil || floodMaxHopsError != nil { - return - } + if validation.hasErrors { return } - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { var allSucceeded = true if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { - let response = try await sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") + let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") if case .ok = CLIResponse.parse(response) { originalRepeaterEnabled = repeaterEnabled } else { @@ -777,7 +259,7 @@ final class RepeaterSettingsViewModel { } if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await sendAndWait("set advert.interval \(advertIntervalMinutes)") + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") if case .ok = CLIResponse.parse(response) { originalAdvertIntervalMinutes = advertIntervalMinutes } else { @@ -786,7 +268,7 @@ final class RepeaterSettingsViewModel { } if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") if case .ok = CLIResponse.parse(response) { originalFloodAdvertIntervalHours = floodAdvertIntervalHours } else { @@ -795,7 +277,7 @@ final class RepeaterSettingsViewModel { } if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await sendAndWait("set flood.max \(floodMaxHops)") + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") if case .ok = CLIResponse.parse(response) { originalFloodMaxHops = floodMaxHops } else { @@ -805,141 +287,36 @@ final class RepeaterSettingsViewModel { if allSucceeded { withAnimation { - isApplying = false + helper.isApplying = false behaviorApplySuccess = true } try? await Task.sleep(for: .seconds(1.5)) withAnimation { behaviorApplySuccess = false } return } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - // MARK: - Location Picker Support - - /// Update location from map picker (triggers modified detection via computed property) - func setLocationFromPicker(latitude: Double, longitude: Double) { - self.latitude = latitude - self.longitude = longitude - } - - /// Change admin password (requires explicit action due to security) - func changePassword() async { - guard !newPassword.isEmpty else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty - return - } - guard newPassword == confirmPassword else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch - return - } - - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("password \(newPassword)") - if case .ok = CLIResponse.parse(response) { - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangedSuccess - showSuccessAlert = true - newPassword = "" - confirmPassword = "" - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - // MARK: - Device Actions - - /// Reboot the repeater - func reboot() async { - guard let session, let service = repeaterAdminService else { return } - - isRebooting = true - errorMessage = nil - - do { - _ = try await service.sendCommand(sessionID: session.id, command: "reboot") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent - showSuccessAlert = true - } catch { - errorMessage = error.localizedDescription - } - - isRebooting = false - } - - /// Force advertisement - func forceAdvert() async { - guard let session, let service = repeaterAdminService else { return } - - do { - _ = try await service.sendCommand(sessionID: session.id, command: "advert") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent - showSuccessAlert = true - } catch { - errorMessage = error.localizedDescription - } - } - - /// Sync repeater time with phone time - func syncTime() async { - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("clock sync") - switch CLIResponse.parse(response) { - case .ok: - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced - showSuccessAlert = true - case .error(let message): - // Extract message after "ERR: " prefix if present - if message.contains("clock cannot go backwards") { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError - } else { - let cleanMessage = message.replacing("ERR: ", with: "") - errorMessage = cleanMessage.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : cleanMessage - } - - default: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) - + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } // MARK: - Region Methods - /// Fetch regions from the repeater using bare `region` command (tree output) func fetchRegions() async { isLoadingRegions = true - regionsError = nil + regionsError = false do { - let treeResponse = try await sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) let parsed = Self.parseRegionTree(treeResponse) self.regions = parsed self.originalRegions = parsed - logger.debug("Fetched \(parsed.count) regions from tree output") } catch { if case RemoteNodeError.timeout = error { - regionsError = "error" + regionsError = true } logger.warning("Failed to fetch regions: \(error)") } @@ -947,25 +324,15 @@ final class RepeaterSettingsViewModel { isLoadingRegions = false } - /// Parse the tree output from bare `region` command into region entries. - /// - /// Format per line: `{spaces}{name}{^?}{ F?}` - /// - Leading spaces = hierarchy depth - /// - `^` suffix = home region - /// - ` F` suffix (with trailing newline stripped) = flood allowed - /// - `*` = wildcard root static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { var entries: [RepeaterRegionEntry] = [] let lines = response.split(separator: "\n", omittingEmptySubsequences: true) for line in lines { var text = String(line) - - // Strip leading spaces text = String(text.drop(while: { $0 == " " })) guard !text.isEmpty else { continue } - // Check for " F" suffix (flood allowed) let floodAllowed: Bool if text.hasSuffix(" F") { floodAllowed = true @@ -974,7 +341,6 @@ final class RepeaterSettingsViewModel { floodAllowed = false } - // Check for "^" suffix (home region) let isHome: Bool if text.hasSuffix("^") { isHome = true @@ -995,74 +361,69 @@ final class RepeaterSettingsViewModel { return entries } - /// Toggle flood allow/deny for a region func toggleRegionFlood(name: String) async { guard let index = regions.firstIndex(where: { $0.name == name }) else { return } let currentlyAllowed = regions[index].floodAllowed let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait(command) + let response = try await helper.sendAndWait(command) if case .ok = CLIResponse.parse(response) { regions[index].floodAllowed = !currentlyAllowed hasUnsavedRegionChanges = true } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Set the home region func setHomeRegion(name: String) async { let command = "region home \(name)" - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait(command, rawMatching: true) + let response = try await helper.sendAndWait(command, rawMatching: true) if response.contains("home is now") { - // Clear old home, set new for i in regions.indices { regions[i].isHome = (regions[i].name == name) } hasUnsavedRegionChanges = true } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Add a new region to the repeater func addRegion(name: String) async { let trimmed = name.trimmingCharacters(in: .whitespaces) if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { switch validationError { case .empty: return case .invalidCharacters, .invalidPrefix, .duplicate: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } return } - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region put \(trimmed)") + let response = try await helper.sendAndWait("region put \(trimmed)") if case .ok = CLIResponse.parse(response) { - // New regions default to flood-denied on the firmware regions.append(RepeaterRegionEntry( name: trimmed, floodAllowed: false, @@ -1071,61 +432,59 @@ final class RepeaterSettingsViewModel { hasUnsavedRegionChanges = true newRegionName = "" } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Remove a region from the repeater func removeRegion(name: String) async { - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region remove \(name)") + let response = try await helper.sendAndWait("region remove \(name)") if case .ok = CLIResponse.parse(response) { regions.removeAll { $0.name == name } hasUnsavedRegionChanges = true } else if response.contains("not empty") { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Save region configuration to device flash func saveRegions() async { - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region save") + let response = try await helper.sendAndWait("region save") if case .ok = CLIResponse.parse(response) { hasUnsavedRegionChanges = false withAnimation { - isApplying = false + helper.isApplying = false regionsSaveSuccess = true } try? await Task.sleep(for: .seconds(1.5)) withAnimation { regionsSaveSuccess = false } return } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } } @@ -1138,19 +497,3 @@ struct RepeaterRegionEntry: Identifiable, Equatable { var isHome: Bool var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } } - -// MARK: - Error Types - -enum RepeaterSettingsError: LocalizedError { - case notConnected - case timeout - case noService - - var errorDescription: String? { - switch self { - case .notConnected: return L10n.RemoteNodes.RemoteNodes.Settings.notConnected - case .timeout: return L10n.RemoteNodes.RemoteNodes.Settings.timeout - case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService - } - } -} diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index b972f6e40..4de5e6a27 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -35,9 +35,10 @@ struct RepeaterStatusView: View { } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) .radioDisabled( for: appState.connectionState, - or: viewModel.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.isLoadingTelemetry || viewModel.isLoadingOwnerInfo + or: viewModel.helper.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.helper.isLoadingTelemetry || viewModel.isLoadingOwnerInfo ) } @@ -57,13 +58,14 @@ struct RepeaterStatusView: View { viewModel.configure(appState: appState) await viewModel.registerHandlers(appState: appState) - // Request Status first (includes clock query) - await viewModel.requestStatus(for: session) - // Note: Telemetry and Neighbors are NOT auto-loaded - user must expand the section + // Only request status on first load; user can refresh via toolbar/pull-to-refresh + if viewModel.helper.status == nil { + await viewModel.requestStatus(for: session) + } // Pre-load OCV settings and contacts for neighbor matching if let deviceID = appState.connectedDevice?.id { - await viewModel.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) if let dataStore = appState.services?.dataStore { contacts = (try? await dataStore.fetchContacts(deviceID: deviceID)) ?? [] discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(deviceID: deviceID)) ?? [] @@ -77,7 +79,7 @@ struct RepeaterStatusView: View { await viewModel.requestOwnerInfo(for: session) } // Refresh telemetry only if already loaded - if viewModel.telemetryLoaded { + if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } // Refresh neighbors only if already loaded @@ -92,7 +94,7 @@ struct RepeaterStatusView: View { // MARK: - Subviews private func makeHeaderSection() -> some View { - HeaderSection(session: session) + NodeStatusHeaderSection(session: session) } private func makeOwnerInfoSection() -> some View { @@ -100,7 +102,7 @@ struct RepeaterStatusView: View { } private func makeStatusSection() -> some View { - StatusSection(viewModel: viewModel, session: session) + StatusSection(viewModel: viewModel) } private func makeNeighborsSection() -> some View { @@ -113,8 +115,8 @@ struct RepeaterStatusView: View { } private func makeBatteryCurveSection() -> some View { - BatteryCurveDisclosureSection( - viewModel: viewModel, + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, session: session, connectionState: appState.connectionState, connectedDeviceID: appState.connectedDevice?.id @@ -122,7 +124,9 @@ struct RepeaterStatusView: View { } private func makeTelemetrySection() -> some View { - TelemetrySection(viewModel: viewModel, session: session) + NodeTelemetryDisclosureSection(helper: viewModel.helper) { + await viewModel.requestTelemetry(for: session) + } } // MARK: - Actions @@ -135,7 +139,7 @@ struct RepeaterStatusView: View { await viewModel.requestOwnerInfo(for: session) } // Refresh telemetry only if already loaded - if viewModel.telemetryLoaded { + if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } // Refresh neighbors only if already loaded @@ -146,34 +150,6 @@ struct RepeaterStatusView: View { } } -// MARK: - Header Section - -private struct HeaderSection: View { - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: session.publicKey, role: .repeater, size: 60) - - Text(session.name) - .font(.headline) - - if session.permissionLevel == .guest { - Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .listRowBackground(Color.clear) - } - } -} - // MARK: - Owner Info Section private struct OwnerInfoSection: View { @@ -216,34 +192,10 @@ private struct OwnerInfoSection: View { private struct StatusSection: View { let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Status.statusSection) { - if viewModel.isLoadingStatus && viewModel.status == nil { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = viewModel.errorMessage, viewModel.status == nil { - Text(errorMessage) - .foregroundStyle(.red) - } else { - StatusRows(viewModel: viewModel) - - if let timestamp = viewModel.previousSnapshotTimestamp { - Text(timestamp) - .font(.caption) - .foregroundStyle(.secondary) - } - - NavigationLink { - NodeStatusHistoryView(fetchSnapshots: viewModel.fetchHistory, ocvArray: viewModel.ocvValues) - } label: { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } + NodeStatusSection(helper: viewModel.helper) { + StatusRows(viewModel: viewModel) } } } @@ -254,38 +206,7 @@ private struct StatusRows: View { let viewModel: RepeaterStatusViewModel var body: some View { - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.battery, - value: viewModel.batteryDisplay, - delta: viewModel.batteryDeltaMV.map { Double($0) / 1000.0 }, - higherIsBetter: true, unit: " V", fractionDigits: 2 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: viewModel.uptimeDisplay) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, - value: viewModel.lastRSSIDisplay, - delta: viewModel.rssiDelta.map(Double.init), - higherIsBetter: true, unit: " dBm", fractionDigits: 0 - ) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, - value: viewModel.lastSNRDisplay, - delta: viewModel.snrDelta, - higherIsBetter: true, unit: " dB", fractionDigits: 1 - ) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, - value: viewModel.noiseFloorDisplay, - delta: viewModel.noiseFloorDelta.map(Double.init), - higherIsBetter: false, unit: " dBm", fractionDigits: 0 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: viewModel.packetsSentDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: viewModel.packetsReceivedDisplay) + NodeCommonStatusRows(helper: viewModel.helper) if let receiveErrors = viewModel.receiveErrorsDisplay { LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrors) @@ -322,21 +243,21 @@ private struct NeighborsSection: View { NeighborSNRChartView( name: resolvedName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, neighborPrefix: neighbor.publicKeyPrefix, - fetchSnapshots: viewModel.fetchHistory + fetchSnapshots: viewModel.helper.fetchHistory ) } label: { NeighborRow( neighbor: neighbor, contact: contact, - previousNeighbor: viewModel.previousSnapshot?.neighborSnapshots?.first { + previousNeighbor: viewModel.helper.previousSnapshot?.neighborSnapshots?.first { $0.publicKeyPrefix == neighbor.publicKeyPrefix }, - hasPreviousSnapshot: viewModel.previousSnapshot?.neighborSnapshots != nil + hasPreviousSnapshot: viewModel.helper.previousSnapshot?.neighborSnapshots != nil ) } } - if let previousNeighbors = viewModel.previousSnapshot?.neighborSnapshots { + if let previousNeighbors = viewModel.helper.previousSnapshot?.neighborSnapshots { let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } ForEach(disappeared, id: \.publicKeyPrefix) { old in @@ -371,133 +292,6 @@ private struct NeighborsSection: View { } } -// MARK: - Battery Curve Disclosure Section - -private struct BatteryCurveDisclosureSection: View { - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: ConnectionState - let connectedDeviceID: UUID? - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.isBatteryCurveExpanded) { - BatteryCurveSection( - availablePresets: OCVPreset.repeaterPresets, - headerText: "", - footerText: "", - selectedPreset: $viewModel.selectedOCVPreset, - voltageValues: $viewModel.ocvValues, - onSave: viewModel.saveOCVSettings, - isDisabled: connectionState != .ready - ) - - if let error = viewModel.ocvError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) - } - .onChange(of: viewModel.isBatteryCurveExpanded) { _, isExpanded in - if isExpanded, let deviceID = connectedDeviceID { - Task { - await viewModel.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) - } - } -} - -// MARK: - Telemetry Section - -private struct TelemetrySection: View { - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.telemetryExpanded) { - if viewModel.isLoadingTelemetry { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if viewModel.telemetry != nil { - if viewModel.cachedDataPoints.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) - .foregroundStyle(.secondary) - } else if viewModel.hasMultipleChannels { - ForEach(viewModel.groupedDataPoints, id: \.channel) { group in - Section { - ForEach(group.dataPoints, id: \.self) { dataPoint in - TelemetryRow(dataPoint: dataPoint, ocvArray: viewModel.ocvValues) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) - .fontWeight(.semibold) - } - } - } else { - ForEach(viewModel.cachedDataPoints, id: \.self) { dataPoint in - TelemetryRow(dataPoint: dataPoint, ocvArray: viewModel.ocvValues) - } - } - - NavigationLink { - TelemetryHistoryView(fetchSnapshots: viewModel.fetchHistory, ocvArray: viewModel.ocvValues) - } label: { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) - .foregroundStyle(.secondary) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - } - .onChange(of: viewModel.telemetryExpanded) { _, isExpanded in - if isExpanded && !viewModel.telemetryLoaded { - Task { - await viewModel.requestTelemetry(for: session) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) - } - } -} - -// MARK: - Metric Row - -private struct MetricRow: View { - let label: String - let value: String - let delta: Double? - let higherIsBetter: Bool - let unit: String - let fractionDigits: Int - - var body: some View { - LabeledContent { - VStack(alignment: .trailing, spacing: 2) { - Text(value) - if let delta { - StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) - } - } - } label: { - Text(label) - } - } -} - // MARK: - Neighbor SNR Chart private struct NeighborSNRChartView: View { @@ -657,33 +451,6 @@ private struct DisappearedNeighborRow: View { } } -// MARK: - Telemetry Row - -private struct TelemetryRow: View { - let dataPoint: LPPDataPoint - let ocvArray: [Int] - - var body: some View { - if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { - // Calculate percentage using OCV array - let millivolts = Int(voltage * 1000) - let battery = BatteryInfo(level: millivolts) - let percentage = battery.percentage(using: ocvArray) - - LabeledContent(dataPoint.typeName) { - VStack(alignment: .trailing, spacing: 2) { - Text(dataPoint.formattedValue) - Text("\(percentage)%") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - LabeledContent(dataPoint.typeName, value: dataPoint.formattedValue) - } - } -} - #Preview { RepeaterStatusView( session: RemoteNodeSessionDTO( diff --git a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift index a3bbf411f..a87e142b2 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -9,29 +9,17 @@ private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @MainActor final class RepeaterStatusViewModel { - // MARK: - Properties + // MARK: - Shared Helper - /// Current session - var session: RemoteNodeSessionDTO? + var helper = NodeStatusHelper() - /// Last received status - var status: RemoteNodeStatus? + // MARK: - Repeater-Only Properties /// Neighbor entries var neighbors: [NeighbourInfo] = [] - /// Last received telemetry - var telemetry: TelemetryResponse? - - /// Cached decoded data points to avoid repeated LPP decoding. - /// The TelemetryResponse.dataPoints computed property decodes on every access, - /// which causes memory pressure during SwiftUI re-renders. - private(set) var cachedDataPoints: [LPPDataPoint] = [] - /// Loading states - var isLoadingStatus = false var isLoadingNeighbors = false - var isLoadingTelemetry = false /// Whether neighbors have been loaded at least once (for refresh logic) var neighborsLoaded = false @@ -39,12 +27,6 @@ final class RepeaterStatusViewModel { /// Whether the neighbors disclosure group is expanded var neighborsExpanded = false - /// Whether telemetry has been loaded at least once (for refresh logic) - var telemetryLoaded = false - - /// Whether the telemetry disclosure group is expanded - var telemetryExpanded = false - /// Owner info text var ownerInfo: String? @@ -54,65 +36,28 @@ final class RepeaterStatusViewModel { var ownerInfoExpanded = false var ownerInfoError: String? - /// Error message if any - var errorMessage: String? - - // MARK: - OCV Curve Properties - - /// Whether the battery curve disclosure group is expanded - var isBatteryCurveExpanded = false - - /// Selected OCV preset - var selectedOCVPreset: OCVPreset = .liIon - - /// Current OCV voltage values - var ocvValues: [Int] = OCVPreset.liIon.ocvArray - - /// Error from OCV save operation - var ocvError: String? - - /// Contact ID for saving OCV settings - private var contactID: UUID? - // MARK: - Dependencies private var repeaterAdminService: RepeaterAdminService? - private var contactService: ContactService? - var nodeSnapshotService: NodeSnapshotService? - - /// ID of the current session's snapshot (for enrichment). - /// Because `handleStatusResponse` suspends while saving the snapshot, - /// neighbor/telemetry handlers may fire before this is set. - /// In that case, enrichment data is buffered in `pendingNeighborEntries` - /// / `pendingTelemetryEntries` and flushed once the ID is available. - private var currentSnapshotID: UUID? - /// Buffered enrichment data received before `currentSnapshotID` was set. + /// Buffered neighbor enrichment data received before snapshot ID was set. private var pendingNeighborEntries: [NeighborSnapshotEntry]? - private var pendingTelemetryEntries: [TelemetrySnapshotEntry]? - - /// Previous snapshot for delta display - private(set) var previousSnapshot: NodeStatusSnapshotDTO? // MARK: - Initialization init() {} - /// Configure with services from AppState func configure(appState: AppState) { self.repeaterAdminService = appState.services?.repeaterAdminService - self.contactService = appState.services?.contactService - self.nodeSnapshotService = appState.services?.nodeSnapshotService - // Handler registration moved to registerHandlers() called from view's .task modifier + helper.configure( + contactService: appState.services?.contactService, + nodeSnapshotService: appState.services?.nodeSnapshotService + ) } - /// Register for push notification handlers - /// Called from view's .task modifier to ensure proper lifecycle management - /// This method is idempotent - it clears existing handlers before registering new ones func registerHandlers(appState: AppState) async { guard let repeaterAdminService = appState.services?.repeaterAdminService else { return } - // Clear any existing handlers first (idempotent setup) await repeaterAdminService.clearHandlers() await repeaterAdminService.setStatusHandler { [weak self] status in @@ -120,263 +65,121 @@ final class RepeaterStatusViewModel { } await repeaterAdminService.setNeighboursHandler { [weak self] response in - await MainActor.run { - self?.handleNeighboursResponse(response) - } + await self?.handleNeighboursResponse(response) } await repeaterAdminService.setTelemetryHandler { [weak self] response in - await MainActor.run { - self?.handleTelemetryResponse(response) - } + await self?.helper.handleTelemetryResponse(response) } - } // MARK: - Status - /// Timeout duration for status/neighbors requests - private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum - - /// Check if error is a transient "not ready" error that should be retried. - /// Error code 10 occurs when the firmware isn't fully ready after login. - private func isTransientError(_ error: Error) -> Bool { - guard let remoteError = error as? RemoteNodeError, - case .sessionError(let meshError) = remoteError, - case .deviceError(let code) = meshError else { - return false - } - return code == 10 - } - - private static let transientRetryDelays: [Duration] = [ - .milliseconds(500), - .seconds(1), - .seconds(2), - ] - - private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { - let remaining = deadline - .now - return remaining > .zero ? remaining : nil - } - - private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { - guard let remaining = remainingBudget(until: deadline) else { - throw RemoteNodeError.timeout - } - try await Task.sleep(for: min(delay, remaining)) - } - - private func performWithTransientRetries( - operationName: String, - operation: @escaping @Sendable (Duration) async throws -> T - ) async throws -> T { - let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) - var delayIterator = Self.transientRetryDelays.makeIterator() - - while true { - guard let timeout = remainingBudget(until: deadline) else { - logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") - throw RemoteNodeError.timeout - } - - do { - return try await operation(timeout) - } catch { - guard isTransientError(error), let delay = delayIterator.next() else { - throw error - } - try await waitForRetry(delay: delay, until: deadline) - } - } - } - - /// Request status from the repeater func requestStatus(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session - isLoadingStatus = true - errorMessage = nil + if helper.session == nil { helper.session = session } + helper.isLoadingStatus = true + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "status") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "status") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) } await handleStatusResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isLoadingStatus = false + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingStatus = false } catch { - errorMessage = error.localizedDescription - isLoadingStatus = false + helper.errorMessage = error.localizedDescription + helper.isLoadingStatus = false } } - /// Request neighbors from the repeater + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, + receiveErrors: response.receiveErrors + ) + + // Flush any buffered neighbor entries now that snapshot ID is set + if let pending = pendingNeighborEntries { + pendingNeighborEntries = nil + helper.flushPendingNeighborEntries(pending) + } + } + + // MARK: - Neighbors + func requestNeighbors(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session + if helper.session == nil { helper.session = session } isLoadingNeighbors = true - errorMessage = nil + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "neighbors") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "neighbors") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestNeighbors(sessionID: session.id, timeout: timeout) } handleNeighboursResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut isLoadingNeighbors = false } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription isLoadingNeighbors = false } } - /// Handle status response from push notification - /// Validates response matches current session before updating - func handleStatusResponse(_ response: RemoteNodeStatus) async { - // Session validation: only accept responses for our session - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return // Ignore responses for other sessions - } - self.status = response - self.isLoadingStatus = false - - // Capture snapshot for history - guard let nodeSnapshotService, let session else { return } - - // Fetch previous snapshot BEFORE saving so we compare against the last visit - let prev = await nodeSnapshotService.previousSnapshot( - for: session.publicKey, - before: .now - ) - self.previousSnapshot = prev - - let snapshotID = await nodeSnapshotService.saveStatusSnapshot( - nodePublicKey: session.publicKey, - batteryMillivolts: response.batteryMillivolts, - lastSNR: response.lastSNR, - lastRSSI: Int16(clamping: response.lastRSSI), - noiseFloor: Int16(clamping: response.noiseFloor), - uptimeSeconds: response.uptimeSeconds, - rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, - packetsSent: response.packetsSent, - packetsReceived: response.packetsReceived, - receiveErrors: response.receiveErrors - ) - if let snapshotID { - self.currentSnapshotID = snapshotID - } else if let prevID = prev?.id { - // Snapshot throttled — enrich the most recent existing snapshot instead - self.currentSnapshotID = prevID - } - - // Flush any enrichment data that arrived during the await - if let enrichmentTarget = self.currentSnapshotID { - if let pending = pendingNeighborEntries { - pendingNeighborEntries = nil - Task { await nodeSnapshotService.enrichWithNeighbors(pending, snapshotID: enrichmentTarget) } - } - if let pending = pendingTelemetryEntries { - pendingTelemetryEntries = nil - Task { await nodeSnapshotService.enrichWithTelemetry(pending, snapshotID: enrichmentTarget) } - } - } - } - - /// Handle neighbours response from push notification func handleNeighboursResponse(_ response: NeighboursResponse) { - // Note: NeighboursResponse may not include source prefix - validate if available self.neighbors = response.neighbours self.isLoadingNeighbors = false self.neighborsLoaded = true - // Enrich current snapshot with neighbor data let entries = response.neighbours.map { NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) } - if let snapshotID = currentSnapshotID { - Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } - } else { + if !helper.enrichWithNeighbors(entries) { pendingNeighborEntries = entries } } // MARK: - Telemetry - /// Request telemetry from the repeater func requestTelemetry(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session - isLoadingTelemetry = true - errorMessage = nil + if helper.session == nil { helper.session = session } + helper.isLoadingTelemetry = true + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) } - handleTelemetryResponse(response) + helper.handleTelemetryResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isLoadingTelemetry = false + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingTelemetry = false } catch { - errorMessage = error.localizedDescription - isLoadingTelemetry = false - } - } - - /// Handle telemetry response from push notification - func handleTelemetryResponse(_ response: TelemetryResponse) { - // Session validation: only accept responses for our session - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return // Ignore responses for other sessions - } - self.telemetry = response - // Decode and cache data points once to avoid repeated LPP decoding during view updates - self.cachedDataPoints = response.dataPoints - self.isLoadingTelemetry = false - self.telemetryLoaded = true - - // Enrich current snapshot with telemetry data - let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in - let numericValue: Double? - switch dp.value { - case .float(let value): - numericValue = value - case .integer(let value): - numericValue = Double(value) - default: - numericValue = nil - } - guard let value = numericValue else { return nil } - return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) - } - if !entries.isEmpty { - if let snapshotID = currentSnapshotID { - Task { await nodeSnapshotService?.enrichWithTelemetry(entries, snapshotID: snapshotID) } - } else { - pendingTelemetryEntries = entries - } + helper.errorMessage = error.localizedDescription + helper.isLoadingTelemetry = false } } // MARK: - Owner Info - /// Request owner info from the repeater func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } ownerInfoError = nil isLoadingOwnerInfo = true do { - let response = try await performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) } ownerInfo = response.ownerInfo @@ -388,207 +191,10 @@ final class RepeaterStatusViewModel { isLoadingOwnerInfo = false } - // MARK: - Telemetry Grouping - - /// Whether cached data points span multiple channels. - var hasMultipleChannels: Bool { - let channels = Set(cachedDataPoints.map(\.channel)) - return channels.count > 1 - } - - /// Data points grouped by channel, sorted by channel number. - /// Only useful when `hasMultipleChannels` is true. - var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { - Dictionary(grouping: cachedDataPoints, by: \.channel) - .sorted { $0.key < $1.key } - .map { (channel: $0.key, dataPoints: $0.value) } - } - - // MARK: - Computed Properties - - /// Em-dash for missing data (cleaner than "Unavailable") - private static let emDash = "—" - - private static let secondsPerMinute: UInt32 = 60 - private static let secondsPerHour: UInt32 = 3_600 - private static let secondsPerDay: UInt32 = 86_400 - - var uptimeDisplay: String { - guard let uptime = status?.uptimeSeconds else { return Self.emDash } - let days = Int(uptime / Self.secondsPerDay) - let hours = Int((uptime % Self.secondsPerDay) / Self.secondsPerHour) - let minutes = Int((uptime % Self.secondsPerHour) / Self.secondsPerMinute) - - if days > 0 { - if days == 1 { - return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) - } - } else if hours > 0 { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) - } - return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) - } - - var batteryDisplay: String { - guard let mv = status?.batteryMillivolts else { return Self.emDash } - let volts = Double(mv) / 1000.0 - let battery = BatteryInfo(level: Int(mv)) - let percent = battery.percentage(using: ocvValues) - return "\(volts.formatted(.number.precision(.fractionLength(2))))V (\(percent)%)" - } - - var lastRSSIDisplay: String { - guard let rssi = status?.lastRSSI else { return Self.emDash } - return "\(rssi) dBm" - } - - var lastSNRDisplay: String { - guard let snr = status?.lastSNR else { return Self.emDash } - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" - } - - var noiseFloorDisplay: String { - guard let nf = status?.noiseFloor else { return Self.emDash } - return "\(nf) dBm" - } - - var packetsSentDisplay: String { - guard let count = status?.packetsSent else { return Self.emDash } - return count.formatted() - } - - var packetsReceivedDisplay: String { - guard let count = status?.packetsReceived else { return Self.emDash } - return count.formatted() - } + // MARK: - Repeater-Only Display var receiveErrorsDisplay: String? { - guard let count = status?.receiveErrors, count > 0 else { return nil } + guard let count = helper.status?.receiveErrors, count > 0 else { return nil } return count.formatted() } - - // MARK: - Delta Display - - /// Format a delta timestamp relative to now. - var previousSnapshotTimestamp: String? { - guard let prev = previousSnapshot else { return nil } - let interval = prev.timestamp.distance(to: .now) - let secondsPerHour: TimeInterval = 3_600 - let secondsPerDay: TimeInterval = 86_400 - if interval < secondsPerHour { - return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) - } else if interval < secondsPerDay { - return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) - } else { - return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) - } - } - - /// Battery delta from previous snapshot (in millivolts, positive = increase) - var batteryDeltaMV: Int? { - guard let current = status?.batteryMillivolts, - let previous = previousSnapshot?.batteryMillivolts else { return nil } - return Int(current) - Int(previous) - } - - /// SNR delta from previous snapshot - var snrDelta: Double? { - guard let current = status?.lastSNR, - let previous = previousSnapshot?.lastSNR else { return nil } - return current - previous - } - - /// RSSI delta from previous snapshot - var rssiDelta: Int? { - guard let current = status?.lastRSSI, - let previous = previousSnapshot?.lastRSSI else { return nil } - return Int(current) - Int(previous) - } - - /// Noise floor delta from previous snapshot - var noiseFloorDelta: Int? { - guard let current = status?.noiseFloor, - let previous = previousSnapshot?.noiseFloor else { return nil } - return Int(current) - Int(previous) - } - - /// Fetch all snapshots for the current node - func fetchHistory() async -> [NodeStatusSnapshotDTO] { - guard let nodeSnapshotService, let session else { - logger.warning("fetchHistory: nodeSnapshotService or session is nil") - return [] - } - return await nodeSnapshotService.fetchSnapshots(for: session.publicKey) - } - - // MARK: - OCV Settings - - /// Load OCV settings for a contact by public key - func loadOCVSettings(publicKey: Data, deviceID: UUID) async { - guard let contactService else { return } - - do { - if let contact = try await contactService.getContact(deviceID: deviceID, publicKey: publicKey) { - contactID = contact.id - - if let presetName = contact.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } - } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray - } - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed - } - } - - /// Save OCV settings for the current contact - func saveOCVSettings(preset: OCVPreset, values: [Int]) async { - guard let contactService, - let contactID else { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact - return - } - - ocvError = nil - - do { - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: preset.rawValue, - customArray: nil - ) - } - - // Update local state - selectedOCVPreset = preset - ocvValues = values - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.localizedDescription) - } - } } diff --git a/MC1/Views/RemoteNodes/RoomSettingsView.swift b/MC1/Views/RemoteNodes/RoomSettingsView.swift new file mode 100644 index 000000000..1e357eb89 --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomSettingsView.swift @@ -0,0 +1,249 @@ +import SwiftUI +import MC1Services +import CoreLocation + +struct RoomSettingsView: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @FocusState private var focusedField: NodeSettingsField? + + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomSettingsViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + + var body: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField, + radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning + ) + RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + NodeSecuritySection(settings: viewModel.helper) + NodeDeviceInfoSection(settings: viewModel.helper) + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation, + rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, + rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage + ) + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil + } + } + } + .task { + await viewModel.configure(appState: appState, session: session) + } + .onDisappear { + Task { + await viewModel.cleanup() + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + } + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } + } + } +} + +// MARK: - Room Behavior Section + +private struct RoomBehaviorSection: View { + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isRoomSettingsExpanded, + isLoaded: { viewModel.roomSettingsLoaded }, + isLoading: $viewModel.isLoadingRoomSettings, + hasError: $viewModel.roomSettingsError, + onLoad: { await viewModel.fetchRoomSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter + ) { + SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( + get: { viewModel.guestPassword ?? "" }, + set: { viewModel.guestPassword = $0 } + )) + .focused(focusedField, equals: .guestPassword) + .overlay(alignment: .trailing) { + if viewModel.guestPassword == nil && viewModel.isLoadingRoomSettings { + Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + + Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( + get: { viewModel.allowReadOnly ?? false }, + set: { viewModel.allowReadOnly = $0 } + )) + .overlay(alignment: .trailing) { + if viewModel.allowReadOnly == nil && viewModel.isLoadingRoomSettings { + Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 60) + } + } + + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await viewModel.applyRoomSettings() } + } label: { + HStack { + Spacer() + if viewModel.helper.isApplying { + ProgressView() + } else if viewModel.roomSettingsApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) + .foregroundStyle(viewModel.roomSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) + } + Spacer() + } + .animation(.default, value: viewModel.roomSettingsApplySuccess) + } + .disabled(viewModel.helper.isApplying || viewModel.roomSettingsApplySuccess || !viewModel.roomSettingsModified) + } + } +} + +#Preview { + NavigationStack { + RoomSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + deviceID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Community Room", + role: .roomServer, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } +} diff --git a/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift new file mode 100644 index 000000000..d36752f6a --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift @@ -0,0 +1,284 @@ +import SwiftUI +import MC1Services +import OSLog + +@Observable +@MainActor +final class RoomSettingsViewModel { + + // MARK: - Shared Helper + + var helper = NodeSettingsHelper() + + // MARK: - Room-Only: Room Settings + + var guestPassword: String? + var allowReadOnly: Bool? + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + private var originalGuestPassword: String? + private var originalAllowReadOnly: Bool? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + var isLoadingRoomSettings = false + var roomSettingsError = false + var roomSettingsLoaded: Bool { allowReadOnly != nil || advertIntervalMinutes != nil } + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var roomSettingsApplySuccess = false + + var roomSettingsModified: Bool { + (guestPassword != nil && guestPassword != originalGuestPassword) || + (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Expansion State (room-only sections) + + var isRoomSettingsExpanded = false + + // MARK: - Dependencies + + private var roomAdminService: RoomAdminService? + private let logger = Logger(subsystem: "MC1", category: "RoomSettings") + + // MARK: - Cleanup + + func cleanup() async { + await roomAdminService?.setCLIHandler { _, _ in } + helper.cleanup() + } + + // MARK: - Configuration + + func configure(appState: AppState, session: RemoteNodeSessionDTO) async { + self.roomAdminService = appState.services?.roomAdminService + + guard let roomAdminService else { return } + + helper.configure( + session: session, + sendCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + + // Room doesn't have binary protocol for node info — firmware fetched via CLI + helper.onPreFetchNodeInfo = nil + + // Register CLI handler for late responses + await roomAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } + } + + Task { await helper.fetchDeviceInfo() } + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Room settings + if !isLoadingRoomSettings && roomSettingsError { + if let result = NodeSettingsHelper.parseBehaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case .advertInterval(let interval): + self.advertIntervalMinutes = interval + self.originalAdvertIntervalMinutes = interval + case .floodAdvertInterval(let interval): + self.floodAdvertIntervalHours = interval + self.originalFloodAdvertIntervalHours = interval + case .floodMax(let hops): + self.floodMaxHops = hops + self.originalFloodMaxHops = hops + } + self.roomSettingsError = false + return + } + } + } + + // MARK: - Room Settings Fetch/Apply + + func fetchRoomSettings() async { + isLoadingRoomSettings = true + roomSettingsError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get guest.password", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get guest.password") + switch parsed { + case .ok, .error, .unknownCommand: + self.guestPassword = "" + self.originalGuestPassword = "" + default: + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed + self.guestPassword = value + self.originalGuestPassword = value + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get guest password: \(error)") + } + + do { + let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) + let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") + switch parsed { + case .raw(let value): + let isOn = value.lowercased() == "on" + self.allowReadOnly = isOn + self.originalAllowReadOnly = isOn + default: + break + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get allow read only: \(error)") + } + + do { + let response = try await helper.sendAndWait("get advert.interval") + if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + self.advertIntervalMinutes = minutes + self.originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") + } + + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + self.floodAdvertIntervalHours = hours + self.originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") + } + + do { + let response = try await helper.sendAndWait("get flood.max") + if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + self.floodMaxHops = hops + self.originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") + } + + if hadTimeout { + roomSettingsError = true + } + + isLoadingRoomSettings = false + } + + func applyRoomSettings() async { + let validation = NodeSettingsHelper.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + helper.isApplying = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let guestPassword, guestPassword != originalGuestPassword { + let response = try await helper.sendAndWait("set guest.password \(guestPassword)") + if case .ok = CLIResponse.parse(response) { + originalGuestPassword = guestPassword + } else { + allSucceeded = false + } + } + + if let allowReadOnly, allowReadOnly != originalAllowReadOnly { + let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalAllowReadOnly = allowReadOnly + } else { + allSucceeded = false + } + } + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false + } + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false + } + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false + } + } + + if allSucceeded { + withAnimation { + helper.isApplying = false + roomSettingsApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { roomSettingsApplySuccess = false } + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.localizedDescription + } + + helper.isApplying = false + } + +} diff --git a/MC1/Views/RemoteNodes/RoomStatusView.swift b/MC1/Views/RemoteNodes/RoomStatusView.swift new file mode 100644 index 000000000..dee7dccd1 --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomStatusView.swift @@ -0,0 +1,152 @@ +import MC1Services +import SwiftUI + +/// Display view for room server stats, telemetry, and battery curve +struct RoomStatusView: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomStatusViewModel() + + var body: some View { + NavigationStack { + List { + makeHeaderSection() + makeStatusSection() + makeTelemetrySection() + makeBatteryCurveSection() + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } + + ToolbarItem(placement: .primaryAction) { + Button { + refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) + .radioDisabled( + for: appState.connectionState, + or: viewModel.helper.isLoadingStatus || viewModel.helper.isLoadingTelemetry + ) + } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } + } + .task { + viewModel.configure(appState: appState) + await viewModel.registerHandlers(appState: appState) + + // Only request status on first load; user can refresh via toolbar/pull-to-refresh + if viewModel.helper.status == nil { + await viewModel.requestStatus(for: session) + } + + // Pre-load OCV settings + if let deviceID = appState.connectedDevice?.id { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + } + } + .refreshable { + await viewModel.requestStatus(for: session) + // Refresh telemetry only if already loaded + if viewModel.helper.telemetryLoaded { + await viewModel.requestTelemetry(for: session) + } + } + } + .presentationDetents([.large]) + } + + // MARK: - Subviews + + private func makeHeaderSection() -> some View { + NodeStatusHeaderSection(session: session) + } + + private func makeStatusSection() -> some View { + RoomStatusSection(viewModel: viewModel) + } + + private func makeTelemetrySection() -> some View { + NodeTelemetryDisclosureSection(helper: viewModel.helper) { + await viewModel.requestTelemetry(for: session) + } + } + + private func makeBatteryCurveSection() -> some View { + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.id + ) + } + + // MARK: - Actions + + private func refresh() { + Task { + await viewModel.requestStatus(for: session) + // Refresh telemetry only if already loaded + if viewModel.helper.telemetryLoaded { + await viewModel.requestTelemetry(for: session) + } + } + } +} + +// MARK: - Status Section + +private struct RoomStatusSection: View { + let viewModel: RoomStatusViewModel + + var body: some View { + NodeStatusSection(helper: viewModel.helper) { + RoomStatusRows(viewModel: viewModel) + } + } +} + +// MARK: - Status Rows + +private struct RoomStatusRows: View { + let viewModel: RoomStatusViewModel + + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) + } +} + +#Preview { + RoomStatusView( + session: RemoteNodeSessionDTO( + deviceID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) +} diff --git a/MC1/Views/RemoteNodes/RoomStatusViewModel.swift b/MC1/Views/RemoteNodes/RoomStatusViewModel.swift new file mode 100644 index 000000000..57c34b5ab --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomStatusViewModel.swift @@ -0,0 +1,108 @@ +import MC1Services +import SwiftUI + +/// ViewModel for room server status display +@Observable +@MainActor +final class RoomStatusViewModel { + + // MARK: - Shared Helper + + var helper = NodeStatusHelper() + + // MARK: - Dependencies + + private var roomAdminService: RoomAdminService? + + // MARK: - Initialization + + init() {} + + func configure(appState: AppState) { + self.roomAdminService = appState.services?.roomAdminService + helper.configure( + contactService: appState.services?.contactService, + nodeSnapshotService: appState.services?.nodeSnapshotService + ) + } + + func registerHandlers(appState: AppState) async { + guard let roomAdminService = appState.services?.roomAdminService else { return } + + await roomAdminService.clearHandlers() + + await roomAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) + } + + await roomAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) + } + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + + if helper.session == nil { helper.session = session } + helper.isLoadingStatus = true + helper.errorMessage = nil + + do { + let response = try await helper.performWithTransientRetries(operationName: "status") { [roomAdminService] timeout in + return try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) + } + await handleStatusResponse(response) + } catch RemoteNodeError.timeout { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingStatus = false + } catch { + helper.errorMessage = error.localizedDescription + helper.isLoadingStatus = false + } + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + postedCount: response.roomServerPostedCount, + postPushCount: response.roomServerPostPushCount + ) + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + + if helper.session == nil { helper.session = session } + helper.isLoadingTelemetry = true + helper.errorMessage = nil + + do { + let response = try await helper.performWithTransientRetries(operationName: "telemetry") { [roomAdminService] timeout in + return try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + } + helper.handleTelemetryResponse(response) + } catch RemoteNodeError.timeout { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingTelemetry = false + } catch { + helper.errorMessage = error.localizedDescription + helper.isLoadingTelemetry = false + } + } + + // MARK: - Room-Only Display + + var postsReceivedDisplay: String { + guard let count = helper.status?.roomServerPostedCount else { return NodeStatusHelper.emDash } + return count.formatted() + } + + var postsPushedDisplay: String { + guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusHelper.emDash } + return count.formatted() + } +} diff --git a/MC1/Views/RemoteNodes/SharedNodeViews.swift b/MC1/Views/RemoteNodes/SharedNodeViews.swift new file mode 100644 index 000000000..5733e0619 --- /dev/null +++ b/MC1/Views/RemoteNodes/SharedNodeViews.swift @@ -0,0 +1,662 @@ +import MC1Services +import SwiftUI + +// MARK: - Unified Focus Field + +enum NodeSettingsField: Hashable { + case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops + case identityName, contactInfo, guestPassword +} + +// MARK: - Status Header + +struct NodeStatusHeaderSection: View { + let session: RemoteNodeSessionDTO + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) + + Text(session.name) + .font(.headline) + + if session.permissionLevel == .guest { + Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Settings Header + +struct NodeSettingsHeaderSection: View { + let publicKey: Data + let name: String + let role: RemoteNodeRole + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: publicKey, role: role, size: 60) + Text(name) + .font(.headline) + } + Spacer() + } + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Common Status Rows + +struct NodeCommonStatusRows: View { + let helper: NodeStatusHelper + + var body: some View { + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.battery, + value: helper.batteryDisplay, + delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, + higherIsBetter: true, unit: " V", fractionDigits: 2 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, + value: helper.lastRSSIDisplay, + delta: helper.rssiDelta.map(Double.init), + higherIsBetter: true, unit: " dBm", fractionDigits: 0 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, + value: helper.lastSNRDisplay, + delta: helper.snrDelta, + higherIsBetter: true, unit: " dB", fractionDigits: 1 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, + value: helper.noiseFloorDisplay, + delta: helper.noiseFloorDelta.map(Double.init), + higherIsBetter: false, unit: " dBm", fractionDigits: 0 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) + } +} + +// MARK: - Status Section + +struct NodeStatusSection: View { + let helper: NodeStatusHelper + @ViewBuilder let rows: () -> Rows + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Status.statusSection) { + if helper.isLoadingStatus && helper.status == nil { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.errorMessage, helper.status == nil { + Text(errorMessage) + .foregroundStyle(.red) + } else { + rows() + + if let timestamp = helper.previousSnapshotTimestamp { + Text(timestamp) + .font(.caption) + .foregroundStyle(.secondary) + } + + NavigationLink { + NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + } label: { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } + } + } +} + +// MARK: - Metric Row + +struct NodeMetricRow: View { + let label: String + let value: String + let delta: Double? + let higherIsBetter: Bool + let unit: String + let fractionDigits: Int + + var body: some View { + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(value) + if let delta { + StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) + } + } + } label: { + Text(label) + } + } +} + +// MARK: - Telemetry Row + +struct NodeTelemetryRow: View { + let dataPoint: LPPDataPoint + let ocvArray: [Int] + + var body: some View { + if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { + let millivolts = Int(voltage * 1000) + let battery = BatteryInfo(level: millivolts) + let percentage = battery.percentage(using: ocvArray) + + LabeledContent(dataPoint.typeName) { + VStack(alignment: .trailing, spacing: 2) { + Text(dataPoint.formattedValue) + Text("\(percentage)%") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + LabeledContent(dataPoint.typeName, value: dataPoint.formattedValue) + } + } +} + +// MARK: - Battery Curve Disclosure Section + +struct NodeBatteryCurveDisclosureSection: View { + @Bindable var helper: NodeStatusHelper + let session: RemoteNodeSessionDTO + let connectionState: ConnectionState + let connectedDeviceID: UUID? + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { + BatteryCurveSection( + availablePresets: OCVPreset.nodePresets, + headerText: "", + footerText: "", + selectedPreset: $helper.selectedOCVPreset, + voltageValues: $helper.ocvValues, + onSave: helper.saveOCVSettings, + isDisabled: connectionState != .ready + ) + + if let error = helper.ocvError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) + } + .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in + if isExpanded, let deviceID = connectedDeviceID { + Task { + await helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + } + } + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) + } + } +} + +// MARK: - Telemetry Disclosure Section + +struct NodeTelemetryDisclosureSection: View { + @Bindable var helper: NodeStatusHelper + let onRequestTelemetry: () async -> Void + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.telemetryExpanded) { + if helper.isLoadingTelemetry { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if helper.telemetry != nil { + if helper.cachedDataPoints.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) + .foregroundStyle(.secondary) + } else if helper.hasMultipleChannels { + ForEach(helper.groupedDataPoints, id: \.channel) { group in + Section { + ForEach(group.dataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) + } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) + .fontWeight(.semibold) + } + } + } else { + ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) + } + } + + NavigationLink { + TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + } label: { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) + .foregroundStyle(.secondary) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + } + .onChange(of: helper.telemetryExpanded) { _, isExpanded in + if isExpanded && !helper.telemetryLoaded { + Task { + await onRequestTelemetry() + } + } + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) + } + } +} + +// MARK: - Device Info Section + +struct NodeDeviceInfoSection: View { + @Bindable var settings: NodeSettingsHelper + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, + icon: "info.circle", + isExpanded: $settings.isDeviceInfoExpanded, + isLoaded: { settings.deviceInfoLoaded }, + isLoading: $settings.isLoadingDeviceInfo, + hasError: $settings.deviceInfoError, + onLoad: { await settings.fetchDeviceInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter + ) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? "\u{2014}") + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? "\u{2014}") + } + } +} + +// MARK: - Radio Settings Section + +struct NodeRadioSettingsSection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, + icon: "antenna.radiowaves.left.and.right", + isExpanded: $settings.isRadioExpanded, + isLoaded: { settings.radioLoaded }, + isLoading: $settings.isLoadingRadio, + hasError: $settings.radioError, + onLoad: { await settings.fetchRadioSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter + ) { + if settings.radioSettingsModified { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(radioRestartWarning) + .font(.subheadline) + } + .padding() + .frame(maxWidth: .infinity) + .background(.yellow.opacity(0.1)) + .clipShape(.rect(cornerRadius: 8)) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) + Spacer() + if let frequency = settings.frequency { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( + get: { frequency }, + set: { settings.frequency = $0 } + ), format: .number.precision(.fractionLength(3)).locale(.posix)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused(focusedField, equals: .frequency) + .onChange(of: settings.frequency) { _, _ in + settings.radioSettingsModified = true + } + } else { + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + } + } + + if let bandwidth = settings.bandwidth { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( + get: { bandwidth }, + set: { settings.bandwidth = $0 } + )) { + ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in + Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) + .tag(bwKHz) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) + .onChange(of: settings.bandwidth) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let spreadingFactor = settings.spreadingFactor { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( + get: { spreadingFactor }, + set: { settings.spreadingFactor = $0 } + )) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in + Text(sf, format: .number) + .tag(sf) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) + .onChange(of: settings.spreadingFactor) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let codingRate = settings.codingRate { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( + get: { codingRate }, + set: { settings.codingRate = $0 } + )) { + ForEach(RadioOptions.codingRates, id: \.self) { cr in + Text("\(cr)") + .tag(cr) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) + .onChange(of: settings.codingRate) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) + Spacer() + if let txPower = settings.txPower { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( + get: { txPower }, + set: { settings.txPower = $0 } + ), format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused(focusedField, equals: .txPower) + .onChange(of: settings.txPower) { _, _ in + settings.radioSettingsModified = true + } + } else { + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .trailing) + } + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) { + Task { await settings.applyRadioSettings() } + } + .disabled(!settings.radioSettingsModified || settings.isApplying) + } + } +} + +// MARK: - Identity Section + +struct RemoteNodeIdentitySection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + var onPickLocation: () -> Void + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, + icon: "person.text.rectangle", + isExpanded: $settings.isIdentityExpanded, + isLoaded: { settings.identityLoaded }, + isLoading: $settings.isLoadingIdentity, + hasError: $settings.identityError, + onLoad: { await settings.fetchIdentity() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.name) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( + get: { settings.name ?? "" }, + set: { settings.name = $0 } + )) + .multilineTextAlignment(.trailing) + .focused(focusedField, equals: .identityName) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( + get: { settings.latitude ?? 0 }, + set: { settings.latitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( + get: { settings.longitude ?? 0 }, + set: { settings.longitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { + onPickLocation() + } + + Button { + Task { await settings.applyIdentitySettings() } + } label: { + if settings.identityApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) + } + } + .disabled(!settings.identitySettingsModified || settings.isApplying) + } + } +} + +// MARK: - Contact Info Section + +struct NodeContactInfoSection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, + icon: "person.crop.rectangle", + isExpanded: $settings.isContactInfoExpanded, + isLoaded: { settings.contactInfoLoaded }, + isLoading: $settings.isLoadingContactInfo, + hasError: $settings.contactInfoError, + onLoad: { await settings.fetchContactInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter + ) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: Binding( + get: { settings.ownerInfo ?? "" }, + set: { settings.ownerInfo = $0 } + ), axis: .vertical) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + .overlay(alignment: .bottomTrailing) { + Text("\(settings.ownerInfoCharCount)/119") + .font(.caption2) + .foregroundStyle(settings.ownerInfoCharCount > 119 ? .red : .secondary) + .padding(4) + } + + Button { + Task { await settings.applyContactInfoSettings() } + } label: { + if settings.contactInfoApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + } + } + .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.ownerInfoCharCount > 119) + } + } +} + +// MARK: - Security Section + +struct NodeSecuritySection: View { + @Bindable var settings: NodeSettingsHelper + + var body: some View { + Section { + DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) { + Task { await settings.changePassword() } + } + .disabled(settings.isApplying || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) + } label: { + Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) + } + } +} + +// MARK: - Actions Section + +struct NodeActionsSection: View { + let settings: NodeSettingsHelper + @Binding var showRebootConfirmation: Bool + var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle + var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) { + Task { await settings.forceAdvert() } + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) { + Task { await settings.syncTime() } + } + .disabled(settings.isApplying) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { + showRebootConfirmation = true + } + .disabled(settings.isRebooting) + .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { + Task { await settings.reboot() } + } + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } + } message: { + Text(rebootMessage) + } + + if let error = settings.errorMessage { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + } +} diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift index fafcf1b10..83f6c4c66 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -54,7 +54,8 @@ struct TelemetryHistoryOverviewView: View { $0.batteryMillivolts != nil || $0.lastSNR != nil || $0.lastRSSI != nil || $0.noiseFloor != nil || $0.packetsSent != nil || $0.packetsReceived != nil || - $0.receiveErrors != nil + $0.receiveErrors != nil || + $0.postedCount != nil || $0.postPushCount != nil } if hasRadioData { @@ -121,6 +122,22 @@ struct TelemetryHistoryOverviewView: View { s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, + unit: "", color: .purple, + dataPoints: filtered.compactMap { s in + s.postedCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, + unit: "", color: .cyan, + dataPoints: filtered.compactMap { s in + s.postPushCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) } } } diff --git a/MC1Services/Sources/MC1Services/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index 1c0ed0cb0..25e7b127f 100644 --- a/MC1Services/Sources/MC1Services/MC1Services.swift +++ b/MC1Services/Sources/MC1Services/MC1Services.swift @@ -80,6 +80,9 @@ public enum RadioOptions { 7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000 ] + /// Bandwidth options in kHz for CLI protocol display + public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } + /// Valid spreading factor range (SF5-SF12) public static let spreadingFactors: ClosedRange = 5...12 diff --git a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift index 2390d4779..69b981977 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift @@ -55,6 +55,11 @@ public final class NodeStatusSnapshot { public var packetsReceived: UInt32? public var receiveErrors: UInt32? + // MARK: - Room server metrics + + public var postedCount: UInt16? + public var postPushCount: UInt16? + // MARK: - Optional neighbor/telemetry data /// Neighbor data, only populated if the user expanded the neighbors section. @@ -76,6 +81,8 @@ public final class NodeStatusSnapshot { packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -91,6 +98,8 @@ public final class NodeStatusSnapshot { self.packetsSent = packetsSent self.packetsReceived = packetsReceived self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } @@ -111,6 +120,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { public let packetsSent: UInt32? public let packetsReceived: UInt32? public let receiveErrors: UInt32? + public let postedCount: UInt16? + public let postPushCount: UInt16? public let neighborSnapshots: [NeighborSnapshotEntry]? public let telemetryEntries: [TelemetrySnapshotEntry]? @@ -127,6 +138,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.packetsSent = model.packetsSent self.packetsReceived = model.packetsReceived self.receiveErrors = model.receiveErrors + self.postedCount = model.postedCount + self.postPushCount = model.postPushCount self.neighborSnapshots = model.neighborSnapshots self.telemetryEntries = model.telemetryEntries } @@ -144,6 +157,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -159,6 +174,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.packetsSent = packetsSent self.packetsReceived = packetsReceived self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } diff --git a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift index 66f97a3cb..9a2f445f9 100644 --- a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift +++ b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift @@ -103,9 +103,9 @@ public enum OCVPreset: String, CaseIterable, Codable, Sendable { allCases.filter { $0.category == .batteryChemistry } } - /// Presets available for repeater/remote node configuration. + /// Presets available for remote node configuration. /// Includes battery chemistry types plus select device-specific presets. - public static var repeaterPresets: [OCVPreset] { + public static var nodePresets: [OCVPreset] { var presets = batteryChemistryPresets presets.append(.seeedSolarNode) return presets diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index 0b5746eda..3ae67b019 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -411,7 +411,9 @@ public protocol PersistenceStoreProtocol: Actor { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? ) async throws -> UUID /// Fetch the most recent snapshot for a node diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index fb0853157..90709d593 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -103,6 +103,9 @@ public final class ServiceContainer { /// Service for repeater administration public let repeaterAdminService: RepeaterAdminService + /// Service for room server administration (telemetry, settings) + public let roomAdminService: RoomAdminService + /// Service for room server operations public let roomServerService: RoomServerService @@ -179,6 +182,10 @@ public final class ServiceContainer { remoteNodeService: remoteNodeService, dataStore: dataStore ) + self.roomAdminService = RoomAdminService( + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) self.roomServerService = RoomServerService( session: session, remoteNodeService: remoteNodeService, diff --git a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index 50977c309..d30506371 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -25,7 +25,9 @@ public actor NodeSnapshotService { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) async -> UUID? { do { if let latest = try await dataStore.fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), @@ -44,7 +46,9 @@ public actor NodeSnapshotService { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) logger.info("Saved status snapshot for node") return id diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index bde70b7aa..b2831a05c 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -377,7 +377,9 @@ extension PersistenceStore { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { try saveNodeStatusSnapshot( timestamp: .now, @@ -390,7 +392,9 @@ extension PersistenceStore { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) } @@ -407,7 +411,9 @@ extension PersistenceStore { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { let snapshot = NodeStatusSnapshot( timestamp: timestamp, @@ -420,7 +426,9 @@ extension PersistenceStore { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) modelContext.insert(snapshot) try modelContext.save() diff --git a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift new file mode 100644 index 000000000..ae7a9eeef --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift @@ -0,0 +1,156 @@ +import Foundation +import MeshCore +import os + +/// Service for room server admin interactions. +/// Handles viewing status/telemetry and sending CLI commands to room servers. +/// Room authentication is handled by `RoomServerService.joinRoom()` via `NodeAuthenticationSheet`. +public actor RoomAdminService { + + // MARK: - Properties + + private let remoteNodeService: RemoteNodeService + private let dataStore: PersistenceStore + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") + private let auditLogger = CommandAuditLogger() + + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + // MARK: - Initialization + + public init( + remoteNodeService: RemoteNodeService, + dataStore: PersistenceStore + ) { + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Status + + /// Request status from a room server. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a room server. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a room server and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all room admin sessions for a device. + public func fetchRoomAdminSessions(deviceID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(deviceID: deviceID) + return sessions.filter { $0.isRoom } + } + + /// Check if a contact is a known room with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom && remoteSession.isConnected else { + return nil + } + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + await auditLogger.logStatusResponse( + target: .room, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for room response from \(prefixHex), ignoring") + return + } + await handler(status) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + await auditLogger.logTelemetryResponse( + target: .room, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered for room, ignoring response") + return + } + await handler(response) + } + + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) + + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") + return + } + await handler(message, contact) + } + + // MARK: - Handler Setters + + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + self.statusResponseHandler = handler + } + + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + self.telemetryResponseHandler = handler + } + + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + self.cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + self.statusResponseHandler = nil + self.telemetryResponseHandler = nil + self.cliResponseHandler = nil + } +} diff --git a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift index 3db0e2a31..778f38455 100644 --- a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift @@ -410,7 +410,11 @@ extension SyncCoordinator { guard let self else { return } if let contact { - await services.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + if contact.type == .room { + await services.roomAdminService.invokeCLIHandler(message, fromContact: contact) + } else { + await services.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + } } else { self.logger.warning("Dropping CLI response: no contact found for sender") } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 6aad0bcbc..da4a1de6f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1426,7 +1426,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? ) async throws -> UUID { let dto = NodeStatusSnapshotDTO( nodePublicKey: nodePublicKey, @@ -1438,7 +1440,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) nodeStatusSnapshots.append(dto) return dto.id @@ -1479,6 +1483,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: existing.rxAirtimeSeconds, packetsSent: existing.packetsSent, packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, neighborSnapshots: neighbors, telemetryEntries: existing.telemetryEntries ) @@ -1500,6 +1507,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: existing.rxAirtimeSeconds, packetsSent: existing.packetsSent, packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, neighborSnapshots: existing.neighborSnapshots, telemetryEntries: telemetry ) diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index a0711a3bc..4de825291 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -376,7 +376,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { // Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/Services/MessageEventBroadcasterTests.swift b/MC1Tests/Services/MessageEventBroadcasterTests.swift index 6a60fb9de..436d96425 100644 --- a/MC1Tests/Services/MessageEventBroadcasterTests.swift +++ b/MC1Tests/Services/MessageEventBroadcasterTests.swift @@ -37,7 +37,6 @@ struct MessageEventBroadcasterTests { #expect(broadcaster.dataStore == nil) #expect(broadcaster.roomServerService == nil) #expect(broadcaster.binaryProtocolService == nil) - #expect(broadcaster.repeaterAdminService == nil) } // MARK: - Handler Methods @@ -379,7 +378,6 @@ struct MessageEventBroadcasterTests { #expect(broadcaster.dataStore != nil) #expect(broadcaster.roomServerService != nil) #expect(broadcaster.binaryProtocolService != nil) - #expect(broadcaster.repeaterAdminService != nil) } } diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 7ddee7ae3..84d54a941 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -386,7 +386,7 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 67f94d21b..58611a1bd 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -211,7 +211,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots (stubs) // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift index 980295576..8e51078bf 100644 --- a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift +++ b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift @@ -72,31 +72,42 @@ struct RepeaterStatusViewModelTests { let session = createTestSession() let viewModel = RepeaterStatusViewModel() - viewModel.nodeSnapshotService = service - viewModel.session = session + viewModel.helper.configure(contactService: nil, nodeSnapshotService: service) + viewModel.helper.session = session // Visit 1: First status response — snapshot saved (not throttled) - await viewModel.handleStatusResponse(createStatusResponse()) - let snapshots1 = await viewModel.fetchHistory() + let status = createStatusResponse() + await viewModel.helper.handleStatusResponse( + status, + rxAirtimeSeconds: status.repeaterRxAirtimeSeconds, + receiveErrors: status.receiveErrors + ) + let snapshots1 = await viewModel.helper.fetchHistory() #expect(snapshots1.count == 1, "First visit should save a snapshot") // Simulate refresh within 15 min — snapshot will be throttled - await viewModel.handleStatusResponse(createStatusResponse()) - let snapshots2 = await viewModel.fetchHistory() + await viewModel.helper.handleStatusResponse( + status, + rxAirtimeSeconds: status.repeaterRxAirtimeSeconds, + receiveErrors: status.receiveErrors + ) + let snapshots2 = await viewModel.helper.fetchHistory() #expect(snapshots2.count == 1, "Throttled save should not create a new snapshot") // User expands neighbors section — enrichment data arrives viewModel.handleNeighboursResponse(createNeighboursResponse()) - // Give fire-and-forget enrichment Task time to complete - try await Task.sleep(for: .milliseconds(50)) - - // Verify: the existing snapshot should be enriched - let snapshots3 = await viewModel.fetchHistory() - #expect(snapshots3.count == 1) - #expect( - snapshots3[0].neighborSnapshots?.isEmpty == false, - "Neighbor enrichment should persist even after throttled refresh" - ) + // Poll until enrichment completes (fire-and-forget Task) or timeout + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + var enriched = false + while ContinuousClock.now < deadline { + let snapshots = await viewModel.helper.fetchHistory() + if snapshots.first?.neighborSnapshots?.isEmpty == false { + enriched = true + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(enriched, "Neighbor enrichment should persist even after throttled refresh") } } From 296c61461ded9e82b02c5972a5dca670206dac8d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:10:35 -0700 Subject: [PATCH 36/48] feat(map): migrate from MapKit to MapLibre Native (#253) Replaces MapKit with MapLibre Native across all map views (main map, Line of Sight, Trace Path, LocationPicker). - New `MC1MapView` (UIViewRepresentable) using data-driven GeoJSON layers, sprite-based pins, and raster tile overlays for satellite/topo styles - New `OfflineMapService` with settings UI for downloading and managing offline map packs - New `PinSpriteRenderer` for rendering map pin sprites without a network connection - Removed all MapKit representables and annotation views - Added MapLibre Native SPM dependency (maplibre-gl-native-distribution 6.23+) --- .gitignore | 1 + ...LLocationCoordinate2D+BoundingRegion.swift | 46 + .../CLLocationCoordinate2D+Formatting.swift | 7 + MC1/Extensions/ContactDTO+Coordinate.swift | 8 + MC1/Extensions/ContactType+Display.swift | 28 + MC1/Extensions/View+LiquidGlass.swift | 10 + MC1/Resources/Generated/L10n.swift | 110 +- .../Localization/de.lproj/Contacts.strings | 10 + .../Localization/de.lproj/Map.strings | 39 +- .../Localization/de.lproj/Settings.strings | 94 ++ .../Localization/en.lproj/Contacts.strings | 10 + .../Localization/en.lproj/Map.strings | 39 +- .../Localization/en.lproj/Settings.strings | 94 ++ .../Localization/es.lproj/Contacts.strings | 10 + .../Localization/es.lproj/Map.strings | 39 +- .../Localization/es.lproj/Settings.strings | 94 ++ .../Localization/fr.lproj/Contacts.strings | 10 + .../Localization/fr.lproj/Map.strings | 39 +- .../Localization/fr.lproj/Settings.strings | 94 ++ .../Localization/nl.lproj/Contacts.strings | 10 + .../Localization/nl.lproj/Map.strings | 39 +- .../Localization/nl.lproj/Settings.strings | 94 ++ .../Localization/pl.lproj/Contacts.strings | 10 + .../Localization/pl.lproj/Map.strings | 39 +- .../Localization/pl.lproj/Settings.strings | 94 ++ .../Localization/ru.lproj/Contacts.strings | 10 + .../Localization/ru.lproj/Map.strings | 39 +- .../Localization/ru.lproj/Settings.strings | 94 ++ .../Localization/uk.lproj/Contacts.strings | 10 + .../Localization/uk.lproj/Map.strings | 39 +- .../Localization/uk.lproj/Settings.strings | 94 ++ .../zh-Hans.lproj/Contacts.strings | 10 + .../Localization/zh-Hans.lproj/Map.strings | 39 +- .../zh-Hans.lproj/Settings.strings | 94 ++ MC1/Resources/Styles/topo-offline.json | 22 + MC1/Services/OfflineMapService.swift | 425 ++++++++ MC1/State/AppState.swift | 7 + .../ChatConversationMessagesContent.swift | 204 +--- .../Components/ChatMessagesTableView.swift | 117 +++ .../Chats/Components/MessageBubbleView.swift | 93 ++ MC1/Views/Components/LabelsToggleButton.swift | 19 + MC1/Views/Components/MapControlsToolbar.swift | 77 +- MC1/Views/Components/NoDoubleTapMapView.swift | 31 - MC1/Views/Components/NorthLockButton.swift | 22 + MC1/Views/Components/ShareSheet.swift | 12 - MC1/Views/Contacts/BatchRTTRow.swift | 28 + MC1/Views/Contacts/ComparisonRowView.swift | 61 ++ MC1/Views/Contacts/ContactDetailView.swift | 47 +- MC1/Views/Contacts/DiscoveryView.swift | 4 +- .../Contacts/DistanceInfoSheetView.swift | 54 + MC1/Views/Contacts/GlassButtonModifier.swift | 11 - MC1/Views/Contacts/SavePathRowView.swift | 58 + MC1/Views/Contacts/TotalDistanceRow.swift | 57 + MC1/Views/Contacts/TracePathListView.swift | 250 +---- .../AvailableRepeatersSectionView.swift | 113 ++ .../TracePathMap/PathActionsSectionView.swift | 67 ++ .../TracePathMap/PathLineOverlay.swift | 72 -- .../TracePathMap/PathLineRenderer.swift | 42 - .../TracePathMap/RunTraceSectionView.swift | 75 ++ .../TracePathMap/StatsBadgeAnnotation.swift | 37 - .../TracePathMap/StatsBadgeView.swift | 89 -- .../TracePathMap/TracePathClusterView.swift | 86 -- .../TracePathFloatingButtonsView.swift | 76 ++ .../TracePathMap/TracePathMKMapView.swift | 316 ------ .../TracePathMapToolbarView.swift | 65 ++ .../TracePathMap/TracePathMapView.swift | 228 ++-- .../TracePathMap/TracePathMapViewModel.swift | 353 +++---- .../TracePathRepeaterPinView.swift | 264 ----- MC1/Views/Contacts/TraceResultHopRow.swift | 95 ++ .../Contacts/TraceResultsSectionView.swift | 78 ++ MC1/Views/Contacts/TraceResultsSheet.swift | 394 +------ .../LineOfSight/AddRepeaterRowView.swift | 34 + MC1/Views/LineOfSight/AnalysisErrorView.swift | 30 + MC1/Views/LineOfSight/HeightEditorGrid.swift | 73 ++ .../LineOfSight/LineOfSightLayoutMode.swift | 5 + MC1/Views/LineOfSight/LineOfSightView.swift | 988 +++--------------- .../LineOfSight/LineOfSightViewModel.swift | 228 ++-- .../LineOfSight/Map/LOSAnnotations.swift | 50 - MC1/Views/LineOfSight/Map/LOSMKMapView.swift | 497 --------- .../LineOfSight/Map/LOSPathOverlay.swift | 18 - .../LineOfSight/Map/LOSPointPinView.swift | 88 -- .../LineOfSight/Map/LOSRepeaterPinView.swift | 255 ----- .../Map/LOSRepeaterTargetPinView.swift | 116 -- .../LineOfSight/PointHeightEditorView.swift | 18 + .../LineOfSight/PointRowButtonsView.swift | 103 ++ MC1/Views/LineOfSight/PointRowView.swift | 86 ++ .../PointsSummarySectionView.swift | 116 ++ .../LineOfSight/RFSettingsSectionView.swift | 38 + .../RepeaterHeightEditorView.swift | 18 + MC1/Views/LineOfSight/RepeaterRowView.swift | 64 ++ .../TerrainProfileSectionView.swift | 58 + MC1/Views/Map/ContactAnnotation.swift | 41 - MC1/Views/Map/ContactCalloutContent.swift | 39 +- MC1/Views/Map/ContactDetailSheet.swift | 209 ++++ MC1/Views/Map/ContactNameLabel.swift | 27 - MC1/Views/Map/ContactPinView.swift | 298 ------ MC1/Views/Map/LayersMenu.swift | 32 +- MC1/Views/Map/MC1MapView+Layers.swift | 389 +++++++ MC1/Views/Map/MC1MapView.swift | 504 +++++++++ MC1/Views/Map/MKMapViewRepresentable.swift | 399 ------- MC1/Views/Map/MapCanvasView.swift | 136 +++ MC1/Views/Map/MapContentView.swift | 86 ++ MC1/Views/Map/MapLine.swift | 27 + MC1/Views/Map/MapPoint.swift | 37 + MC1/Views/Map/MapStyleSelection.swift | 40 +- MC1/Views/Map/MapTileURLs.swift | 8 + MC1/Views/Map/MapView.swift | 543 +--------- MC1/Views/Map/MapViewModel.swift | 90 +- MC1/Views/Map/OfflineBadge.swift | 22 + MC1/Views/Map/PinSpriteRenderer.swift | 347 ++++++ MC1/Views/Settings/LocationPickerView.swift | 112 +- .../Settings/OfflineMapSettingsView.swift | 448 ++++++++ .../Sections/DiagnosticsSection.swift | 37 +- MC1/Views/Settings/SettingsView.swift | 6 + MC1/Views/Tools/RxLogView.swift | 4 +- project.yml | 4 + 116 files changed, 6634 insertions(+), 5680 deletions(-) create mode 100644 MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift create mode 100644 MC1/Extensions/CLLocationCoordinate2D+Formatting.swift create mode 100644 MC1/Extensions/ContactDTO+Coordinate.swift create mode 100644 MC1/Extensions/ContactType+Display.swift create mode 100644 MC1/Resources/Styles/topo-offline.json create mode 100644 MC1/Services/OfflineMapService.swift create mode 100644 MC1/Views/Chats/Components/ChatMessagesTableView.swift create mode 100644 MC1/Views/Chats/Components/MessageBubbleView.swift create mode 100644 MC1/Views/Components/LabelsToggleButton.swift delete mode 100644 MC1/Views/Components/NoDoubleTapMapView.swift create mode 100644 MC1/Views/Components/NorthLockButton.swift delete mode 100644 MC1/Views/Components/ShareSheet.swift create mode 100644 MC1/Views/Contacts/BatchRTTRow.swift create mode 100644 MC1/Views/Contacts/ComparisonRowView.swift create mode 100644 MC1/Views/Contacts/DistanceInfoSheetView.swift delete mode 100644 MC1/Views/Contacts/GlassButtonModifier.swift create mode 100644 MC1/Views/Contacts/SavePathRowView.swift create mode 100644 MC1/Views/Contacts/TotalDistanceRow.swift create mode 100644 MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift create mode 100644 MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathRepeaterPinView.swift create mode 100644 MC1/Views/Contacts/TraceResultHopRow.swift create mode 100644 MC1/Views/Contacts/TraceResultsSectionView.swift create mode 100644 MC1/Views/LineOfSight/AddRepeaterRowView.swift create mode 100644 MC1/Views/LineOfSight/AnalysisErrorView.swift create mode 100644 MC1/Views/LineOfSight/HeightEditorGrid.swift create mode 100644 MC1/Views/LineOfSight/LineOfSightLayoutMode.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSAnnotations.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSMKMapView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSPathOverlay.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSPointPinView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift create mode 100644 MC1/Views/LineOfSight/PointHeightEditorView.swift create mode 100644 MC1/Views/LineOfSight/PointRowButtonsView.swift create mode 100644 MC1/Views/LineOfSight/PointRowView.swift create mode 100644 MC1/Views/LineOfSight/PointsSummarySectionView.swift create mode 100644 MC1/Views/LineOfSight/RFSettingsSectionView.swift create mode 100644 MC1/Views/LineOfSight/RepeaterHeightEditorView.swift create mode 100644 MC1/Views/LineOfSight/RepeaterRowView.swift create mode 100644 MC1/Views/LineOfSight/TerrainProfileSectionView.swift delete mode 100644 MC1/Views/Map/ContactAnnotation.swift create mode 100644 MC1/Views/Map/ContactDetailSheet.swift delete mode 100644 MC1/Views/Map/ContactNameLabel.swift delete mode 100644 MC1/Views/Map/ContactPinView.swift create mode 100644 MC1/Views/Map/MC1MapView+Layers.swift create mode 100644 MC1/Views/Map/MC1MapView.swift delete mode 100644 MC1/Views/Map/MKMapViewRepresentable.swift create mode 100644 MC1/Views/Map/MapCanvasView.swift create mode 100644 MC1/Views/Map/MapContentView.swift create mode 100644 MC1/Views/Map/MapLine.swift create mode 100644 MC1/Views/Map/MapPoint.swift create mode 100644 MC1/Views/Map/MapTileURLs.swift create mode 100644 MC1/Views/Map/OfflineBadge.swift create mode 100644 MC1/Views/Map/PinSpriteRenderer.swift create mode 100644 MC1/Views/Settings/OfflineMapSettingsView.swift diff --git a/.gitignore b/.gitignore index dd2ad8bae..78e46200e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ playground.xcworkspace build/ .build/ +.spm-cache/ # CocoaPods # diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift new file mode 100644 index 000000000..41d8f9955 --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -0,0 +1,46 @@ +import CoreLocation +import MapKit +import MapLibre + +extension Array where Element == CLLocationCoordinate2D { + /// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding. + func boundingRegion(paddingMultiplier: Double = 1.5) -> MKCoordinateRegion? { + guard let first else { return nil } + + var minLat = first.latitude, maxLat = first.latitude + var minLon = first.longitude, maxLon = first.longitude + + for coord in dropFirst() { + minLat = Swift.min(minLat, coord.latitude) + maxLat = Swift.max(maxLat, coord.latitude) + minLon = Swift.min(minLon, coord.longitude) + maxLon = Swift.max(maxLon, coord.longitude) + } + + return MKCoordinateRegion( + center: CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ), + span: MKCoordinateSpan( + latitudeDelta: Swift.min(180, Swift.max(0.01, (maxLat - minLat) * paddingMultiplier)), + longitudeDelta: Swift.min(360, Swift.max(0.01, (maxLon - minLon) * paddingMultiplier)) + ) + ) + } +} + +extension MKCoordinateRegion { + func toMLNCoordinateBounds() -> MLNCoordinateBounds { + MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: center.latitude - span.latitudeDelta / 2, + longitude: center.longitude - span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: center.latitude + span.latitudeDelta / 2, + longitude: center.longitude + span.longitudeDelta / 2 + ) + ) + } +} diff --git a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift new file mode 100644 index 000000000..08bd97305 --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift @@ -0,0 +1,7 @@ +import CoreLocation + +extension CLLocationCoordinate2D { + var formattedString: String { + "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" + } +} diff --git a/MC1/Extensions/ContactDTO+Coordinate.swift b/MC1/Extensions/ContactDTO+Coordinate.swift new file mode 100644 index 000000000..8f268014a --- /dev/null +++ b/MC1/Extensions/ContactDTO+Coordinate.swift @@ -0,0 +1,8 @@ +import CoreLocation +import MC1Services + +extension ContactDTO { + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} diff --git a/MC1/Extensions/ContactType+Display.swift b/MC1/Extensions/ContactType+Display.swift new file mode 100644 index 000000000..99966bd60 --- /dev/null +++ b/MC1/Extensions/ContactType+Display.swift @@ -0,0 +1,28 @@ +import MeshCore +import SwiftUI + +extension ContactType { + var iconSystemName: String { + switch self { + case .chat: "person.fill" + case .repeater: "antenna.radiowaves.left.and.right" + case .room: "person.3.fill" + } + } + + var displayColor: Color { + switch self { + case .chat: .blue + case .repeater: .green + case .room: .purple + } + } + + var pinStyle: MapPoint.PinStyle { + switch self { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } +} diff --git a/MC1/Extensions/View+LiquidGlass.swift b/MC1/Extensions/View+LiquidGlass.swift index cd5d4b829..a7ad6ba59 100644 --- a/MC1/Extensions/View+LiquidGlass.swift +++ b/MC1/Extensions/View+LiquidGlass.swift @@ -21,6 +21,16 @@ extension View { } } + /// Applies glass button style on iOS 26+, falls back to bordered (secondary weight) on earlier versions + @ViewBuilder + func liquidGlassSecondaryButtonStyle() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions @ViewBuilder func liquidGlassProminentButtonStyle() -> some View { diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index a028aec5e..96cfe53ca 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1511,6 +1511,12 @@ public enum L10n { public static func viewRuns(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.results.viewRuns", p1, fallback: "View %d runs") } + public enum Comparison { + /// Decreased + public static let decreased = L10n.tr("Contacts", "contacts.results.comparison.decreased", fallback: "Decreased") + /// Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction + public static let increased = L10n.tr("Contacts", "contacts.results.comparison.increased", fallback: "Increased") + } public enum Hop { /// Location: TraceResultsSheet.swift - Purpose: Average SNR display public static func avgSNR(_ p1: Any, _ p2: Any, _ p3: Any) -> String { @@ -1772,8 +1778,12 @@ public enum L10n { } } public enum Map { + /// Location: TracePathMapView.swift - Purpose: Center on path accessibility + public static let centerOnPath = L10n.tr("Contacts", "contacts.trace.map.centerOnPath", fallback: "Center on path") /// Location: TracePathMapView.swift - Purpose: Clear button public static let clear = L10n.tr("Contacts", "contacts.trace.map.clear", fallback: "Clear") + /// Location: TracePathMapViewModel.swift - Purpose: Default path name fallback + public static let defaultPathName = L10n.tr("Contacts", "contacts.trace.map.defaultPathName", fallback: "Path") /// Location: TracePathMapView.swift - Purpose: Hide labels accessibility public static let hideLabels = L10n.tr("Contacts", "contacts.trace.map.hideLabels", fallback: "Hide labels") /// Location: TracePathMapView.swift - Purpose: Hops count in results banner @@ -2021,10 +2031,10 @@ public enum L10n { } } public enum Common { + /// Dismiss + public static let dismissOverlay = L10n.tr("Map", "map.common.dismissOverlay", fallback: "Dismiss") /// Location: MapView.swift - Purpose: Done button for sheets public static let done = L10n.tr("Map", "map.common.done", fallback: "Done") - /// Location: MapView.swift - Purpose: Refresh button label - public static let refresh = L10n.tr("Map", "map.common.refresh", fallback: "Refresh") } public enum Controls { /// Location: MapView.swift - Purpose: Accessibility label for center on all contacts button @@ -2035,8 +2045,14 @@ public enum L10n { public static let hideLabels = L10n.tr("Map", "map.controls.hideLabels", fallback: "Hide labels") /// Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button public static let layers = L10n.tr("Map", "map.controls.layers", fallback: "Map layers") + /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) + public static let lockNorth = L10n.tr("Map", "map.controls.lockNorth", fallback: "Lock to north") + /// Location: MapView.swift - Purpose: Accessibility label for refresh button + public static let refresh = L10n.tr("Map", "map.controls.refresh", fallback: "Refresh contacts") /// Location: MapView.swift - Purpose: Accessibility label when labels are hidden public static let showLabels = L10n.tr("Map", "map.controls.showLabels", fallback: "Show labels") + /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) + public static let unlockNorth = L10n.tr("Map", "map.controls.unlockNorth", fallback: "Unlock rotation") } public enum Detail { /// Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited @@ -2088,12 +2104,6 @@ public enum L10n { public static let networkPath = L10n.tr("Map", "map.detail.section.networkPath", fallback: "Network Path") } } - public enum EmptyState { - /// Location: MapView.swift - Purpose: Empty state description - public static let description = L10n.tr("Map", "map.emptyState.description", fallback: "Contacts with location data will appear here once discovered on the mesh network.") - /// Location: MapView.swift - Purpose: Empty state title when no contacts have location - public static let title = L10n.tr("Map", "map.emptyState.title", fallback: "No Contacts on Map") - } public enum NodeKind { /// Location: MapView.swift ContactDetailSheet - Purpose: Display name for chat contact type public static let chatContact = L10n.tr("Map", "map.nodeKind.chatContact", fallback: "Chat Contact") @@ -2102,13 +2112,23 @@ public enum L10n { /// Location: MapView.swift ContactDetailSheet - Purpose: Display name for room type public static let room = L10n.tr("Map", "map.nodeKind.room", fallback: "Room") } + public enum OfflineBadge { + /// Label shown on map when device has no internet connection + public static let label = L10n.tr("Map", "map.offlineBadge.label", fallback: "Offline") + } public enum Style { - /// Location: MapStyleSelection.swift - Purpose: Hybrid map style option - public static let hybrid = L10n.tr("Map", "map.style.hybrid", fallback: "Hybrid") + /// Location: LayersMenu.swift - Purpose: Accessibility label for map style menu + public static let accessibilityLabel = L10n.tr("Map", "map.style.accessibilityLabel", fallback: "Map style") + /// Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport + public static let noOfflineCoverage = L10n.tr("Map", "map.style.noOfflineCoverage", fallback: "No offline map covers this area") + /// Location: LayersMenu.swift - Purpose: Hint when style requires network + public static let requiresNetwork = L10n.tr("Map", "map.style.requiresNetwork", fallback: "Requires network connection") /// Location: MapStyleSelection.swift - Purpose: Satellite map style option public static let satellite = L10n.tr("Map", "map.style.satellite", fallback: "Satellite") /// Location: MapStyleSelection.swift - Purpose: Standard map style option public static let standard = L10n.tr("Map", "map.style.standard", fallback: "Standard") + /// Location: MapStyleSelection.swift - Purpose: Topo map style option + public static let topo = L10n.tr("Map", "map.style.topo", fallback: "Topography") } } } @@ -3701,6 +3721,76 @@ public enum L10n { /// Toggle label for room messages notifications public static let roomMessages = L10n.tr("Settings", "notifications.roomMessages", fallback: "Room Messages") } + public enum OfflineMaps { + /// Cancel button + public static let cancel = L10n.tr("Settings", "offlineMaps.cancel", fallback: "Cancel") + /// Status when pack download is complete + public static let complete = L10n.tr("Settings", "offlineMaps.complete", fallback: "Downloaded") + /// Delete button + public static let delete = L10n.tr("Settings", "offlineMaps.delete", fallback: "Delete") + /// Delete confirmation message + public static let deleteMessage = L10n.tr("Settings", "offlineMaps.deleteMessage", fallback: "The downloaded map data will be removed.") + /// Delete confirmation title + public static let deleteTitle = L10n.tr("Settings", "offlineMaps.deleteTitle", fallback: "Delete Offline Map?") + /// Button to start download + public static let download = L10n.tr("Settings", "offlineMaps.download", fallback: "Download") + /// Hint shown before estimate is available + public static let downloadHint = L10n.tr("Settings", "offlineMaps.downloadHint", fallback: "Enter a name and select an area to download.") + /// Status when pack is downloading + public static let downloading = L10n.tr("Settings", "offlineMaps.downloading", fallback: "Downloading…") + /// Button to download a new offline region + public static let downloadRegion = L10n.tr("Settings", "offlineMaps.downloadRegion", fallback: "Download Region") + /// Description for empty state + public static let emptyDescription = L10n.tr("Settings", "offlineMaps.emptyDescription", fallback: "Download map regions for use without internet.") + /// Title for empty state when no offline packs exist + public static let emptyTitle = L10n.tr("Settings", "offlineMaps.emptyTitle", fallback: "No Offline Maps") + /// Estimated download size + public static func estimatedSize(_ p1: Any) -> String { + return L10n.tr("Settings", "offlineMaps.estimatedSize", String(describing: p1), fallback: "Estimated size: ~%@") + } + /// Download exceeds available storage + public static let exceedsStorage = L10n.tr("Settings", "offlineMaps.exceedsStorage", fallback: "Not enough storage on this device. Zoom in to select a smaller area.") + /// Include layers prompt + public static let includeLayers = L10n.tr("Settings", "offlineMaps.includeLayers", fallback: "Include additional layers for offline use.") + /// Large tile download warning + public static let largeTileWarning = L10n.tr("Settings", "offlineMaps.largeTileWarning", fallback: "Large download area. This may take a while and use significant storage.") + /// Layers section header + public static let layers = L10n.tr("Settings", "offlineMaps.layers", fallback: "Layers") + /// No network available + public static let noNetwork = L10n.tr("Settings", "offlineMaps.noNetwork", fallback: "An internet connection is required to download maps.") + /// Pause download button + public static let pause = L10n.tr("Settings", "offlineMaps.pause", fallback: "Pause") + /// Paused status label + public static let paused = L10n.tr("Settings", "offlineMaps.paused", fallback: "Paused") + /// Navigation title for region picker sheet + public static let pickRegion = L10n.tr("Settings", "offlineMaps.pickRegion", fallback: "Select Region") + /// Placeholder for region name text field + public static let regionName = L10n.tr("Settings", "offlineMaps.regionName", fallback: "Region Name") + /// Resume download button + public static let resume = L10n.tr("Settings", "offlineMaps.resume", fallback: "Resume") + /// Section header for storage info + public static let storage = L10n.tr("Settings", "offlineMaps.storage", fallback: "Storage") + /// Storage section footer + public static let storageFooter = L10n.tr("Settings", "offlineMaps.storageFooter", fallback: "Includes map data and internal indexes. Total may be larger than the sum of individual downloads.") + /// Label for total storage used + public static let storageUsed = L10n.tr("Settings", "offlineMaps.storageUsed", fallback: "Storage Used") + /// Navigation title for offline maps settings + public static let title = L10n.tr("Settings", "offlineMaps.title", fallback: "Offline Maps") + /// Fallback name for unknown region + public static let unknownRegion = L10n.tr("Settings", "offlineMaps.unknownRegion", fallback: "Unknown Region") + public enum Error { + /// Error: insufficient disk space + public static let insufficientDiskSpace = L10n.tr("Settings", "offlineMaps.error.insufficientDiskSpace", fallback: "Not enough storage space. At least 100 MB is required.") + /// Error: tile limit reached + public static let tileLimitReached = L10n.tr("Settings", "offlineMaps.error.tileLimitReached", fallback: "The download tile limit has been reached.") + } + public enum Layer { + /// Layer type labels + public static let base = L10n.tr("Settings", "offlineMaps.layer.base", fallback: "Base Map") + /// Topography + public static let topo = L10n.tr("Settings", "offlineMaps.layer.topo", fallback: "Topography") + } + } public enum PathHashMode { /// Footer explaining path hash mode tradeoff public static let footer = L10n.tr("Settings", "pathHashMode.footer", fallback: "Larger hashes reduce routing collisions but limit the maximum number of hops per path.") diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 98d0214fd..c01b76aa1 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Beschriftungen einblenden"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Auf Pfad zentrieren"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Pfad speichern"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Ergebnisse"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Pfad"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms am %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Gestiegen"; +"contacts.results.comparison.decreased" = "Gesunken"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "%d Durchläufe anzeigen"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index b50ba436e..999085b82 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Fertig"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Aktualisieren"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Keine Kontakte auf der Karte"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Kontakte mit Standortdaten erscheinen hier, sobald sie im Mesh-Netzwerk entdeckt werden."; +"map.common.dismissOverlay" = "Schließen"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Kartenebenen"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Nach Norden ausrichten"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Drehung freigeben"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Kontakte aktualisieren"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellit"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrid"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Raum"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 02ed3d364..1f7792716 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline-Karten"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Keine Offline-Karten"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Kartenregionen für die Nutzung ohne Internet herunterladen."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Region herunterladen"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Speicher"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Speicher belegt"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Unbekannte Region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Heruntergeladen"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Wird heruntergeladen…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Region auswählen"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Regionsname"; + +/* Button to start download */ +"offlineMaps.download" = "Herunterladen"; + +/* Cancel button */ +"offlineMaps.cancel" = "Abbrechen"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Offline-Karte löschen?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Die heruntergeladenen Kartendaten werden entfernt."; + +/* Delete button */ +"offlineMaps.delete" = "Löschen"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Nicht genügend Speicherplatz. Mindestens 100 MB erforderlich."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Das Kachel-Limit für diesen Download wurde erreicht."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Fortsetzen"; + +/* Paused status label */ +"offlineMaps.paused" = "Pausiert"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Geschätzte Größe: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Großes Downloadgebiet. Dies kann eine Weile dauern und erheblichen Speicherplatz beanspruchen."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Geben Sie einen Namen ein und wählen Sie einen Bereich zum Herunterladen."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Nicht genügend Speicherplatz auf diesem Gerät. Vergrößern Sie die Ansicht, um einen kleineren Bereich auszuwählen."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Basiskarte"; +"offlineMaps.layer.topo" = "Topografie"; + +/* Layers section header */ +"offlineMaps.layers" = "Kartenebenen"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Zusätzliche Kartenebenen für die Offline-Nutzung einschließen."; + +/* No network available */ +"offlineMaps.noNetwork" = "Zum Herunterladen von Karten ist eine Internetverbindung erforderlich."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Umfasst Kartendaten und interne Indizes. Der Gesamtspeicher kann größer sein als die Summe der einzelnen Downloads."; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 5e7dc7704..a2b9ca893 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Show labels"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Center on path"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Save Path"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Results"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Path"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms on %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Increased"; +"contacts.results.comparison.decreased" = "Decreased"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "View %d runs"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index b4575e2a6..443e3454f 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Done"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Refresh"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "No Contacts on Map"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Contacts with location data will appear here once discovered on the mesh network."; +"map.common.dismissOverlay" = "Dismiss"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Map layers"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Lock to north"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Unlock rotation"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Refresh contacts"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrid"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topography"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Room"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index f24e15eee..9c4545327 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1269,3 +1269,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline Maps"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "No Offline Maps"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Download map regions for use without internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Download Region"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Storage"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Storage Used"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Unknown Region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Downloaded"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Downloading…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Select Region"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Region Name"; + +/* Button to start download */ +"offlineMaps.download" = "Download"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancel"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Delete Offline Map?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "The downloaded map data will be removed."; + +/* Delete button */ +"offlineMaps.delete" = "Delete"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Not enough storage space. At least 100 MB is required."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "The download tile limit has been reached."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Resume"; + +/* Paused status label */ +"offlineMaps.paused" = "Paused"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Estimated size: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Large download area. This may take a while and use significant storage."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Enter a name and select an area to download."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Not enough storage on this device. Zoom in to select a smaller area."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.topo" = "Topography"; + +/* Layers section header */ +"offlineMaps.layers" = "Layers"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Include additional layers for offline use."; + +/* No network available */ +"offlineMaps.noNetwork" = "An internet connection is required to download maps."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 26764c831..4defd58b7 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Mostrar etiquetas"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrar en la ruta"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Guardar ruta"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Resultados"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Ruta"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms en %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Aumentado"; +"contacts.results.comparison.decreased" = "Disminuido"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Ver %d ejecuciones"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 608148012..cc3e6934c 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Listo"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Actualizar"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Sin contactos en el mapa"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Los contactos con datos de ubicación aparecerán aquí una vez descubiertos en la red mesh."; +"map.common.dismissOverlay" = "Cerrar"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Capas del mapa"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Fijar al norte"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Desbloquear rotación"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Actualizar contactos"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satélite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Híbrido"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografía"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Sala"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Sin conexión"; diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index f7ade27bc..e070cac3d 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapas sin conexión"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Sin mapas sin conexión"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Descarga regiones del mapa para usar sin internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Descargar región"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Almacenamiento"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Almacenamiento usado"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Región desconocida"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Descargado"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Descargando…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Seleccionar región"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nombre de la región"; + +/* Button to start download */ +"offlineMaps.download" = "Descargar"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancelar"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "¿Eliminar mapa sin conexión?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Se eliminarán los datos del mapa descargado."; + +/* Delete button */ +"offlineMaps.delete" = "Eliminar"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "No hay suficiente espacio de almacenamiento. Se requieren al menos 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Se ha alcanzado el límite de mosaicos de descarga."; + +/* Pause download button */ +"offlineMaps.pause" = "Pausar"; + +/* Resume download button */ +"offlineMaps.resume" = "Reanudar"; + +/* Paused status label */ +"offlineMaps.paused" = "En pausa"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Tamaño estimado: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Área de descarga grande. Esto puede tardar un tiempo y usar bastante almacenamiento."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Ingrese un nombre y seleccione un área para descargar."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "No hay suficiente espacio en este dispositivo. Acerque el zoom para seleccionar un área más pequeña."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa base"; +"offlineMaps.layer.topo" = "Topografía"; + +/* Layers section header */ +"offlineMaps.layers" = "Capas"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Incluir capas adicionales para uso sin conexión."; + +/* No network available */ +"offlineMaps.noNetwork" = "Se requiere conexión a internet para descargar mapas."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Incluye datos del mapa e índices internos. El total puede ser mayor que la suma de las descargas individuales."; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index bbbbfc454..987ff347b 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Afficher les étiquettes"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrer sur le chemin"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Enregistrer le chemin"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Résultats"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Chemin"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs %d ms le %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Augmenté"; +"contacts.results.comparison.decreased" = "Diminué"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Voir %d exécutions"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index e7ac361ef..07a145d6a 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Terminé"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Actualiser"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Aucun contact sur la carte"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Les contacts avec des données de position apparaîtront ici une fois découverts sur le réseau mesh."; +"map.common.dismissOverlay" = "Fermer"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Couches de la carte"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Verrouiller au nord"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Déverrouiller la rotation"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Actualiser les contacts"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybride"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topographie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Salon"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Hors ligne"; diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 01c31541f..74a4f5400 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Cartes hors ligne"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Aucune carte hors ligne"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Téléchargez des régions pour une utilisation sans internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Télécharger une région"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Stockage"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Stockage utilisé"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Région inconnue"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Téléchargé"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Téléchargement…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Sélectionner une région"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nom de la région"; + +/* Button to start download */ +"offlineMaps.download" = "Télécharger"; + +/* Cancel button */ +"offlineMaps.cancel" = "Annuler"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Supprimer la carte hors ligne ?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Les données cartographiques téléchargées seront supprimées."; + +/* Delete button */ +"offlineMaps.delete" = "Supprimer"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Espace de stockage insuffisant. Au moins 100 Mo sont requis."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "La limite de tuiles pour ce téléchargement a été atteinte."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Reprendre"; + +/* Paused status label */ +"offlineMaps.paused" = "En pause"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Taille estimée : ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Zone de téléchargement étendue. Cela peut prendre du temps et utiliser un espace de stockage important."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Entrez un nom et sélectionnez une zone à télécharger."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Pas assez d'espace sur cet appareil. Zoomez pour sélectionner une zone plus petite."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Carte de base"; +"offlineMaps.layer.topo" = "Topographie"; + +/* Layers section header */ +"offlineMaps.layers" = "Couches"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Inclure des couches supplémentaires pour une utilisation hors ligne."; + +/* No network available */ +"offlineMaps.noNetwork" = "Une connexion internet est nécessaire pour télécharger les cartes."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Comprend les données cartographiques et les index internes. Le total peut être supérieur à la somme des téléchargements individuels."; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 827b2281f..de4875284 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Labels tonen"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centreren op pad"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Pad opslaan"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Resultaten"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Pad"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms op %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Gestegen"; +"contacts.results.comparison.decreased" = "Gedaald"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Bekijk %d uitvoeringen"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index c14cbe175..f7857235b 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Klaar"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Vernieuwen"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Geen contacten op de kaart"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Contacten met locatiegegevens verschijnen hier zodra ze worden ontdekt op het mesh-netwerk."; +"map.common.dismissOverlay" = "Sluiten"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Kaartlagen"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Vergrendel op noorden"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Rotatie ontgrendelen"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Contacten vernieuwen"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satelliet"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybride"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Kamer"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index b98af98e5..372e43f17 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline kaarten"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Geen offline kaarten"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Download kaartregio's voor gebruik zonder internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Regio downloaden"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Opslag"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Opslag gebruikt"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Onbekende regio"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Gedownload"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Downloaden…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Selecteer regio"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Regionaam"; + +/* Button to start download */ +"offlineMaps.download" = "Downloaden"; + +/* Cancel button */ +"offlineMaps.cancel" = "Annuleren"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Offline kaart verwijderen?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "De gedownloade kaartgegevens worden verwijderd."; + +/* Delete button */ +"offlineMaps.delete" = "Verwijderen"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Niet genoeg opslagruimte. Er is minimaal 100 MB vereist."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "De tegellimiet voor deze download is bereikt."; + +/* Pause download button */ +"offlineMaps.pause" = "Pauzeren"; + +/* Resume download button */ +"offlineMaps.resume" = "Hervatten"; + +/* Paused status label */ +"offlineMaps.paused" = "Gepauzeerd"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Geschatte grootte: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Groot downloadgebied. Dit kan even duren en aanzienlijke opslagruimte gebruiken."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Voer een naam in en selecteer een gebied om te downloaden."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Niet genoeg opslagruimte op dit apparaat. Zoom in om een kleiner gebied te selecteren."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Basiskaart"; +"offlineMaps.layer.topo" = "Topografie"; + +/* Layers section header */ +"offlineMaps.layers" = "Kaartlagen"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Extra kaartlagen opnemen voor offline gebruik."; + +/* No network available */ +"offlineMaps.noNetwork" = "Een internetverbinding is vereist om kaarten te downloaden."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Omvat kaartgegevens en interne indexen. Het totaal kan groter zijn dan de som van de afzonderlijke downloads."; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index e88734cdb..03bd9459e 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Pokaż etykiety"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Wyśrodkuj na ścieżce"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Zapisz ścieżkę"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Wyniki"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Ścieżka"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms w %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Wzrosło"; +"contacts.results.comparison.decreased" = "Spadło"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Zobacz %d uruchomień"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 8424914f3..a5e5b553d 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Gotowe"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Odśwież"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Brak kontaktów na mapie"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Kontakty z danymi lokalizacji pojawią się tutaj, gdy zostaną odkryte w sieci mesh."; +"map.common.dismissOverlay" = "Zamknij"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Warstwy mapy"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Zablokuj na północ"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Odblokuj obrót"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Odśwież kontakty"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satelitarna"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrydowa"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografia"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Pokój"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 0f97b96fd..2139f0129 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapy offline"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Brak map offline"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Pobierz regiony mapy do użytku bez internetu."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Pobierz region"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Pamięć"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Zajęta pamięć"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Nieznany region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Pobrano"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Pobieranie…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Wybierz region"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nazwa regionu"; + +/* Button to start download */ +"offlineMaps.download" = "Pobierz"; + +/* Cancel button */ +"offlineMaps.cancel" = "Anuluj"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Usunąć mapę offline?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Pobrane dane mapy zostaną usunięte."; + +/* Delete button */ +"offlineMaps.delete" = "Usuń"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Za mało miejsca na dysku. Wymagane jest co najmniej 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Osiągnięto limit kafelków dla tego pobierania."; + +/* Pause download button */ +"offlineMaps.pause" = "Wstrzymaj"; + +/* Resume download button */ +"offlineMaps.resume" = "Wznów"; + +/* Paused status label */ +"offlineMaps.paused" = "Wstrzymano"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Szacowany rozmiar: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Duży obszar pobierania. Może to trochę potrwać i zużyć dużo miejsca."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Wpisz nazwę i wybierz obszar do pobrania."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Za mało miejsca na tym urządzeniu. Przybliż, aby wybrać mniejszy obszar."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa bazowa"; +"offlineMaps.layer.topo" = "Topografia"; + +/* Layers section header */ +"offlineMaps.layers" = "Warstwy"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Dołącz dodatkowe warstwy do użytku offline."; + +/* No network available */ +"offlineMaps.noNetwork" = "Do pobrania map wymagane jest połączenie z internetem."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Obejmuje dane mapy i wewnętrzne indeksy. Łączny rozmiar może być większy niż suma poszczególnych pobrań."; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index eed626315..7fcac86b1 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Показать метки"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Центрировать на маршруте"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Сохранить путь"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Результаты"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Путь"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "по сравнению с %d мс %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Увеличено"; +"contacts.results.comparison.decreased" = "Уменьшено"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Просмотреть %d запусков"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 725c4dc60..be54431dd 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Обновить"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Нет контактов на карте"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Контакты с данными о местоположении появятся здесь при обнаружении в сети mesh."; +"map.common.dismissOverlay" = "Закрыть"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Слои карты"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Зафиксировать на север"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Разблокировать вращение"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Обновить контакты"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Спутниковая"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Гибридная"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Топография"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Комната"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Офлайн"; diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 314ae6f6f..00dc1f5a1 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Офлайн-карты"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Нет офлайн-карт"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Скачайте регионы карты для использования без интернета."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Скачать регион"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Хранилище"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Занято"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Неизвестный регион"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Загружено"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Загрузка…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Выберите регион"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Название региона"; + +/* Button to start download */ +"offlineMaps.download" = "Скачать"; + +/* Cancel button */ +"offlineMaps.cancel" = "Отмена"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Удалить офлайн-карту?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Скачанные данные карты будут удалены."; + +/* Delete button */ +"offlineMaps.delete" = "Удалить"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Недостаточно места. Требуется не менее 100 МБ."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Достигнут лимит тайлов для этой загрузки."; + +/* Pause download button */ +"offlineMaps.pause" = "Пауза"; + +/* Resume download button */ +"offlineMaps.resume" = "Продолжить"; + +/* Paused status label */ +"offlineMaps.paused" = "Приостановлено"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Примерный размер: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Большая область загрузки. Это может занять время и потребовать значительного объёма памяти."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Введите название и выберите область для загрузки."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Недостаточно места на устройстве. Увеличьте масштаб, чтобы выбрать меньшую область."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Базовая карта"; +"offlineMaps.layer.topo" = "Топография"; + +/* Layers section header */ +"offlineMaps.layers" = "Слои"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Включить дополнительные слои для использования офлайн."; + +/* No network available */ +"offlineMaps.noNetwork" = "Для загрузки карт требуется подключение к интернету."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Включает данные карты и внутренние индексы. Общий объём может превышать сумму отдельных загрузок."; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 1ac2da435..17559d38c 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Показати підписи"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Центрувати на маршруті"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Зберегти шлях"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Результати"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Шлях"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "проти %d мс на %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Збільшено"; +"contacts.results.comparison.decreased" = "Зменшено"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Переглянути %d запусків"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 4741c2db9..cdf012004 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Оновити"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Немає контактів на карті"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Контакти з даними про місцезнаходження з'являться тут при виявленні в mesh-мережі."; +"map.common.dismissOverlay" = "Закрити"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Шари карти"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Зафіксувати на північ"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Розблокувати обертання"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Оновити контакти"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Супутникова"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Гібридна"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Топографія"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Кімната"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Офлайн"; diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 8c7a2c78d..d3cf44935 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Офлайн-карти"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Немає офлайн-карт"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Завантажте регіони карти для використання без інтернету."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Завантажити регіон"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Сховище"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Використано"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Невідомий регіон"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Завантажено"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Завантаження…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Оберіть регіон"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Назва регіону"; + +/* Button to start download */ +"offlineMaps.download" = "Завантажити"; + +/* Cancel button */ +"offlineMaps.cancel" = "Скасувати"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Видалити офлайн-карту?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Завантажені дані карти буде видалено."; + +/* Delete button */ +"offlineMaps.delete" = "Видалити"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Недостатньо місця. Потрібно щонайменше 100 МБ."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Досягнуто ліміт тайлів для цього завантаження."; + +/* Pause download button */ +"offlineMaps.pause" = "Пауза"; + +/* Resume download button */ +"offlineMaps.resume" = "Продовжити"; + +/* Paused status label */ +"offlineMaps.paused" = "Призупинено"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Орієнтовний розмір: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Велика область завантаження. Це може зайняти час і потребувати значного обсягу пам'яті."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Введіть назву та виберіть область для завантаження."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Недостатньо місця на пристрої. Збільште масштаб, щоб вибрати меншу область."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Базова карта"; +"offlineMaps.layer.topo" = "Топографія"; + +/* Layers section header */ +"offlineMaps.layers" = "Шари"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Додати додаткові шари для використання офлайн."; + +/* No network available */ +"offlineMaps.noNetwork" = "Для завантаження карт потрібне з'єднання з інтернетом."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Включає дані карти та внутрішні індекси. Загальний обсяг може перевищувати суму окремих завантажень."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index b7d327e70..3e50f4c62 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "显示标签"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "以路径为中心"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "保存路径"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "结果"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "路径"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "对比 %@ 毫秒在 %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "增加"; +"contacts.results.comparison.decreased" = "减少"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "查看 %d 次运行"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index a2129c897..3bcfa42e0 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "完成"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "刷新"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "地图上无联系人"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "带有位置数据的联系人将在Mesh网络上发现后显示在此处"; +"map.common.dismissOverlay" = "关闭"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "地图图层"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "锁定朝北"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "解锁旋转"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "刷新联系人"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "卫星"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "混合"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "地形"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "房间"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "离线"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index 3803e72ac..c19945ed7 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1237,3 +1237,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "离线地图"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "无离线地图"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "下载地图区域以便在无网络时使用。"; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "下载区域"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "存储空间"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "已使用"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "未知区域"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "已下载"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "下载中…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "选择区域"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "区域名称"; + +/* Button to start download */ +"offlineMaps.download" = "下载"; + +/* Cancel button */ +"offlineMaps.cancel" = "取消"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "删除离线地图?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "已下载的地图数据将被删除。"; + +/* Delete button */ +"offlineMaps.delete" = "删除"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "存储空间不足,至少需要 100 MB。"; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "已达到此下载的瓦片数量限制。"; + +/* Pause download button */ +"offlineMaps.pause" = "暂停"; + +/* Resume download button */ +"offlineMaps.resume" = "继续"; + +/* Paused status label */ +"offlineMaps.paused" = "已暂停"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "预计大小:~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "下载区域较大,可能需要较长时间并占用大量存储空间。"; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "输入名称并选择要下载的区域。"; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "设备存储空间不足。请放大地图以选择更小的区域。"; + +/* Layer type labels */ +"offlineMaps.layer.base" = "基础地图"; +"offlineMaps.layer.topo" = "地形"; + +/* Layers section header */ +"offlineMaps.layers" = "图层"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "包含额外图层以供离线使用。"; + +/* No network available */ +"offlineMaps.noNetwork" = "下载地图需要互联网连接。"; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "包括地图数据和内部索引。总大小可能超过各项下载的总和。"; diff --git a/MC1/Resources/Styles/topo-offline.json b/MC1/Resources/Styles/topo-offline.json new file mode 100644 index 000000000..de137f080 --- /dev/null +++ b/MC1/Resources/Styles/topo-offline.json @@ -0,0 +1,22 @@ +{ + "version": 8, + "name": "Topo Offline", + "sources": { + "topo": { + "type": "raster", + "tiles": [ + "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + ], + "tileSize": 256, + "maxzoom": 17, + "attribution": "OpenTopoMap" + } + }, + "layers": [ + { + "id": "topo", + "type": "raster", + "source": "topo" + } + ] +} diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift new file mode 100644 index 000000000..11db500d7 --- /dev/null +++ b/MC1/Services/OfflineMapService.swift @@ -0,0 +1,425 @@ +import Foundation +import MapLibre +import Network +import os + +enum OfflineMapLayer: String, Codable { + case base + case topo + + var label: String { + switch self { + case .base: L10n.Settings.OfflineMaps.Layer.base + case .topo: L10n.Settings.OfflineMaps.Layer.topo + } + } + + var maxDownloadZoom: Double { + switch self { + case .base: 14 + case .topo: 17 + } + } + + var styleURL: URL? { + switch self { + case .base: + URL(string: MapTileURLs.openFreeMapLiberty) + case .topo: + Bundle.main.url(forResource: "topo-offline", withExtension: "json") + } + } +} + +struct OfflinePackMetadata: Codable { + let name: String + let createdAt: Date + var layer: OfflineMapLayer + + init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { + self.name = name + self.createdAt = createdAt + self.layer = layer + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + createdAt = try container.decode(Date.self, forKey: .createdAt) + layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base + } +} + +enum OfflineMapError: LocalizedError { + case insufficientDiskSpace + case missingStyleResource(OfflineMapLayer) + + var errorDescription: String? { + switch self { + case .insufficientDiskSpace: + L10n.Settings.OfflineMaps.Error.insufficientDiskSpace + case .missingStyleResource(let layer): + "Missing style resource for layer: \(layer.rawValue)" + } + } +} + +@MainActor @Observable +final class OfflineMapService { + private static let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") + + private static let minimumDiskSpaceBytes: Int64 = 100_000_000 + + private(set) var packs: [OfflinePack] = [] + private(set) var databaseSize: Int64 = 0 + private(set) var isNetworkAvailable = true + private(set) var lastPackError: String? + + private let monitor = NWPathMonitor() + private var observationTasks: [Task] = [] + private var pendingLoadTask: Task? + private var highWaterMarks: [ObjectIdentifier: Double] = [:] + private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] + private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] + private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] + private var deletingPackIDs: Set = [] + private var userPausedPackIDs: Set = [] + + init() { + let monitor = self.monitor + let networkStream = AsyncStream { continuation in + continuation.onTermination = { _ in monitor.cancel() } + monitor.pathUpdateHandler = { continuation.yield($0) } + // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. + monitor.start(queue: .global(qos: .utility)) + } + observationTasks.append(Task { [weak self] in + for await path in networkStream { + self?.isNetworkAvailable = path.status == .satisfied + } + }) + + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { + self?.scheduleLoadPacks() + } + }) + observationTasks.append(Task { [weak self] in + for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { + if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { + Self.logger.warning("Offline pack error: \(error.localizedDescription)") + self?.lastPackError = error.localizedDescription + } + } + }) + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications( + named: .MLNOfflinePackMaximumMapboxTilesReached + ) { + Self.logger.warning("Offline pack tile limit reached") + self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached + } + }) + + excludeDatabaseFromBackup() + loadPacks() + updateDatabaseSize() + + // MLNOfflineStorage.shared.packs may be nil until async DB load completes. + // Retry once after a delay to catch late initialization. + if MLNOfflineStorage.shared.packs == nil { + observationTasks.append(Task { [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + self?.loadPacks() + }) + } + } + + isolated deinit { + monitor.cancel() + pendingLoadTask?.cancel() + for task in observationTasks { + task.cancel() + } + } + + func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { + packs.contains { $0.layer == layer && $0.isComplete } + } + + func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { + packs.contains { pack in + pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false + } + } + + func loadPacks() { + let mlnPacks = MLNOfflineStorage.shared.packs ?? [] + let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) + let now = ContinuousClock.now + + // Remove tracking data for deleted packs + highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } + byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } + downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } + metadataCache = metadataCache.filter { currentIDs.contains($0.key) } + + packs = mlnPacks.map { mlnPack in + let packID = ObjectIdentifier(mlnPack) + let previousFraction = highWaterMarks[packID] ?? 0 + let currentBytes = mlnPack.progress.countOfBytesCompleted + + if let previous = byteSnapshots[packID] { + let elapsed = now - previous.time + let seconds = elapsed / .seconds(1) + if seconds > 0.5, currentBytes > previous.bytes { + downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) + } else if currentBytes == previous.bytes { + downloadSpeeds[packID] = 0 + } + } + byteSnapshots[packID] = (bytes: currentBytes, time: now) + + let speed = downloadSpeeds[packID] + if metadataCache[packID] == nil { + metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) + } + let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) + highWaterMarks[packID] = pack.completedFraction + return pack + } + + for mlnPack in mlnPacks where mlnPack.state == .unknown { + mlnPack.requestProgress() + } + } + + private func updateDatabaseSize() { + let url = MLNOfflineStorage.shared.databaseURL + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + databaseSize = Int64(size) + } + + /// Coalesces rapid progress notifications into a single `loadPacks()` call. + private func scheduleLoadPacks() { + pendingLoadTask?.cancel() + pendingLoadTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + loadPacks() + } + } + + // Note: active offline downloads share MapLibre's internal FIFO request queue + // with the interactive map renderer. Large downloads may degrade live map tile + // loading. Consider suspending packs during active map interaction if needed. + func downloadRegion( + name: String, + bounds: MLNCoordinateBounds, + layers: Set, + minZoom: Double = 10 + ) async throws { + let values = try URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + if let available = values.volumeAvailableCapacityForImportantUsage, + available < Self.minimumDiskSpaceBytes { + throw OfflineMapError.insufficientDiskSpace + } + + let encoder = JSONEncoder() + let now = Date.now + + var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] + for layer in layers { + guard let styleURL = layer.styleURL else { + throw OfflineMapError.missingStyleResource(layer) + } + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: layer.maxDownloadZoom + ) + let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) + let context = try encoder.encode(metadata) + pendingPacks.append((region, context)) + } + + for (region, context) in pendingPacks { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in + if let error { + continuation.resume(throwing: error) + } else { + pack?.resume() + continuation.resume() + } + } + } + } + loadPacks() + updateDatabaseSize() + } + + func deletePack(_ pack: OfflinePack) async { + guard deletingPackIDs.insert(pack.id).inserted else { return } + defer { deletingPackIDs.remove(pack.id) } + + await withCheckedContinuation { continuation in + MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in + if let error { + Self.logger.error("Failed to delete offline pack: \(error.localizedDescription)") + } + continuation.resume() + } + } + highWaterMarks.removeValue(forKey: pack.id) + byteSnapshots.removeValue(forKey: pack.id) + downloadSpeeds.removeValue(forKey: pack.id) + loadPacks() + updateDatabaseSize() + } + + func pausePack(_ pack: OfflinePack) { + userPausedPackIDs.insert(pack.id) + pack.mlnPack.suspend() + loadPacks() + } + + func resumePack(_ pack: OfflinePack) { + userPausedPackIDs.remove(pack.id) + pack.mlnPack.resume() + loadPacks() + } + + func resumeAllPacks() { + for pack in MLNOfflineStorage.shared.packs ?? [] { + let packID = ObjectIdentifier(pack) + if pack.state == .inactive, !userPausedPackIDs.contains(packID) { + pack.resume() + } + } + loadPacks() + } + + func clearLastPackError() { + lastPackError = nil + } + + /// Estimated download size using per-zoom average byte sizes. + nonisolated static func estimatedDownloadSize( + bounds: MLNCoordinateBounds, + minZoom: Int, + maxZoom: Int, + layer: OfflineMapLayer = .base + ) -> Int64 { + let bytesPerTile: [Int: Int64] + switch layer { + case .base: + // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). + // Populated land regions average 30-150 KB per tile at these zooms. + bytesPerTile = [ + 10: 15_000, 11: 25_000, 12: 45_000, + 13: 70_000, 14: 100_000, + ] + case .topo: + // OpenTopoMap PNG raster tiles (256px, max z17). + bytesPerTile = [ + 10: 15_000, 11: 18_000, 12: 22_000, + 13: 25_000, 14: 30_000, 15: 35_000, + 16: 40_000, 17: 45_000, + ] + } + + // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs + let overhead: Int64 = 500_000 + + var total: Int64 = 0 + for z in minZoom...maxZoom { + let n = Double(1 << z) + let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) + let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) + + let latRadNE = bounds.ne.latitude * .pi / 180 + let latRadSW = bounds.sw.latitude * .pi / 180 + let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) + let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) + + let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) + total += Int64(tileCount) * (bytesPerTile[z] ?? 10_000) + } + return total + overhead + } + + private func excludeDatabaseFromBackup() { + var url = MLNOfflineStorage.shared.databaseURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + Self.logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") + } + } +} + +struct OfflinePack: Identifiable { + let id: ObjectIdentifier + fileprivate let mlnPack: MLNOfflinePack + let name: String + let createdAt: Date? + let layer: OfflineMapLayer + let completedFraction: Double + let downloadSpeed: Int64? + let bounds: MLNCoordinateBounds? + + private let progress: MLNOfflinePackProgress + private let state: MLNOfflinePackState + + var completedBytes: UInt64 { progress.countOfBytesCompleted } + var isComplete: Bool { state == .complete } + var isPaused: Bool { state == .inactive } + + init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { + self.id = ObjectIdentifier(pack) + self.mlnPack = pack + self.progress = pack.progress + self.state = pack.state + self.bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds + + let rawFraction: Double + if state == .complete { + rawFraction = 1 + } else if progress.countOfResourcesExpected > 0 { + rawFraction = Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) + } else { + rawFraction = 0 + } + self.completedFraction = max(rawFraction, previousFraction) + self.downloadSpeed = state == .active ? downloadSpeed : nil + + if let metadata { + self.name = metadata.name + self.createdAt = metadata.createdAt + self.layer = metadata.layer + } else { + self.name = L10n.Settings.OfflineMaps.unknownRegion + self.createdAt = nil + self.layer = .base + } + } +} + +extension MLNCoordinateBounds { + func overlaps(_ other: MLNCoordinateBounds) -> Bool { + sw.latitude <= other.ne.latitude + && ne.latitude >= other.sw.latitude + && sw.longitude <= other.ne.longitude + && ne.longitude >= other.sw.longitude + } +} diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index 726e09212..5baac3427 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -24,6 +24,11 @@ public final class AppState { /// App-wide location service for permission management public let locationService = LocationService() + // MARK: - Offline Maps + + /// Offline map pack management and network monitoring + let offlineMapService = OfflineMapService() + /// Best available location for proximity-based disambiguation. public var bestAvailableLocation: CLLocation? { if let phoneLocation = locationService.currentLocation { @@ -640,6 +645,8 @@ public final class AppState { await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) } + + offlineMapService.resumeAllPacks() } // MARK: - Onboarding diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 31d1e92cd..abb6b7a5c 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -47,29 +47,42 @@ struct ChatConversationMessagesContent: View { let onScrollToMention: () -> Void let onRetryMessage: (MessageDTO) -> Void - // MARK: - Private State - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @State private var hasDismissedDividerFAB = false - - private var showDividerFAB: Bool { - newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB - } - // MARK: - Body var body: some View { - if !viewModel.hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - messagesTable - .overlay { - if viewModel.messages.isEmpty { - emptyState - .allowsHitTesting(false) - } - } + Group { + if !viewModel.hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.messages.isEmpty { + emptyState + } else { + ChatMessagesTableView( + viewModel: viewModel, + contactName: conversationType.navigationTitle, + deviceName: deviceName, + configuration: bubbleConfiguration, + recentEmojisStore: recentEmojisStore, + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + unseenMentionIDs: unseenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: newMessagesDividerMessageID, + onMentionSeen: onMentionSeen, + onScrollToMention: onScrollToMention, + onRetryMessage: onRetryMessage + ) + } } } @@ -89,75 +102,7 @@ struct ChatConversationMessagesContent: View { } } - // MARK: - Messages Table + Overlays - - private var messagesTable: some View { - let mentionIDSet = Set(unseenMentionIDs) - let contactName = conversationType.navigationTitle - let configuration = bubbleConfiguration - return ChatTableView( - items: viewModel.displayItems, - cellContent: { item in messageBubble(for: item, contactName: contactName, configuration: configuration) }, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { item in - item.containsSelfMention && !item.mentionSeen && mentionIDSet.contains(item.id) - }, - onMentionBecameVisible: { id in - Task { - await onMentionSeen(id) - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { - Task { - await viewModel.loadOlderMessages() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerFAB { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerFAB = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !unseenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: unseenMentionIDs.count, - onTap: { onScrollToMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerFAB) - .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - .onChange(of: newMessagesDividerMessageID) { _, _ in - hasDismissedDividerFAB = false - } - } - - // MARK: - Message Bubble Construction + // MARK: - Bubble Configuration private var bubbleConfiguration: MessageBubbleConfiguration { switch conversationType { @@ -170,87 +115,6 @@ struct ChatConversationMessagesContent: View { ) } } - - private func onReaction(for message: MessageDTO) -> ((String) -> Void) { - { emoji in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - } - } - - @ViewBuilder - private func messageBubble( - for item: MessageDisplayItem, - contactName: String, - configuration: MessageBubbleConfiguration - ) -> some View { - if let message = viewModel.message(for: item) { - UnifiedMessageBubble( - message: message, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - displayState: MessageDisplayState( - showTimestamp: item.showTimestamp, - showDirectionGap: item.showDirectionGap, - showSenderName: item.showSenderName, - showNewMessagesDivider: item.showNewMessagesDivider, - detectedURL: item.detectedURL, - previewState: item.previewState, - loadedPreview: item.loadedPreview, - isImageURL: item.isImageURL, - decodedImage: viewModel.decodedImage(for: message.id), - decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), - decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), - isGIF: viewModel.isGIFImage(for: message.id), - showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs, - showIncomingPath: showIncomingPath, - showIncomingHopCount: showIncomingHopCount, - formattedText: viewModel.formattedText( - for: message.id, - text: message.text, - isOutgoing: message.isOutgoing, - currentUserName: deviceName, - isHighContrast: colorSchemeContrast == .increased - ) - ), - callbacks: MessageBubbleCallbacks( - onRetry: { onRetryMessage(message) }, - onReaction: onReaction(for: message), - onLongPress: { selectedMessageForActions = message }, - onImageTap: { - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: viewModel.isGIFImage(for: message.id) - ) - } - }, - onRetryImageFetch: { - Task { await viewModel.retryImageFetch(for: message.id) } - }, - onRequestPreviewFetch: { - if item.isImageURL && showInlineImages { - viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) - } else { - viewModel.requestPreviewFetch(for: message.id) - } - }, - onManualPreviewFetch: { - Task { - await viewModel.manualFetchPreview(for: message.id) - } - } - ) - ) - } else { - Text(L10n.Chats.Chats.Message.unavailable) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) - } - } } // MARK: - DM Empty Messages View diff --git a/MC1/Views/Chats/Components/ChatMessagesTableView.swift b/MC1/Views/Chats/Components/ChatMessagesTableView.swift new file mode 100644 index 000000000..5321f777d --- /dev/null +++ b/MC1/Views/Chats/Components/ChatMessagesTableView.swift @@ -0,0 +1,117 @@ +import SwiftUI +import MC1Services + +/// Messages table with ChatTableView, overlay FABs, and divider state management +struct ChatMessagesTableView: View { + @Bindable var viewModel: ChatViewModel + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let recentEmojisStore: RecentEmojisStore + let showInlineImages: Bool + let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool + + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + + let unseenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + let onMentionSeen: (UUID) async -> Void + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void + + @State private var hasDismissedDividerFAB = false + + private var showDividerFAB: Bool { + newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB + } + + var body: some View { + let mentionIDSet = Set(unseenMentionIDs) + ChatTableView( + items: viewModel.displayItems, + cellContent: { item in + MessageBubbleView( + item: item, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + viewModel: viewModel, + recentEmojisStore: recentEmojisStore, + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + onRetryMessage: onRetryMessage + ) + }, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + isUnseenMention: { item in + item.containsSelfMention && !item.mentionSeen && mentionIDSet.contains(item.id) + }, + onMentionBecameVisible: { id in + Task { + await onMentionSeen(id) + } + }, + mentionTargetID: scrollToTargetID, + scrollToDividerRequest: $scrollToDividerRequest, + dividerItemID: newMessagesDividerMessageID, + isDividerVisible: $isDividerVisible, + onNearTop: { + Task { + await viewModel.loadOlderMessages() + } + }, + isLoadingOlderMessages: viewModel.isLoadingOlder + ) + .overlay(alignment: .bottomTrailing) { + VStack(spacing: 12) { + if showDividerFAB { + ScrollToDividerButton( + onTap: { + scrollToDividerRequest += 1 + hasDismissedDividerFAB = true + } + ) + .transition(.scale.combined(with: .opacity)) + } + + if !unseenMentionIDs.isEmpty { + ScrollToMentionButton( + unreadMentionCount: unseenMentionIDs.count, + onTap: { onScrollToMention() } + ) + .transition(.scale.combined(with: .opacity)) + } + + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + } + .animation(.snappy(duration: 0.2), value: showDividerFAB) + .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: newMessagesDividerMessageID) { _, _ in + hasDismissedDividerFAB = false + } + } +} diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift new file mode 100644 index 000000000..8e0101cda --- /dev/null +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -0,0 +1,93 @@ +import SwiftUI +import MC1Services + +/// Constructs a UnifiedMessageBubble for a given display item, resolving message data from the view model +struct MessageBubbleView: View { + let item: MessageDisplayItem + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + @Bindable var viewModel: ChatViewModel + let recentEmojisStore: RecentEmojisStore + let showInlineImages: Bool + let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + let onRetryMessage: (MessageDTO) -> Void + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + var body: some View { + if let message = viewModel.message(for: item) { + UnifiedMessageBubble( + message: message, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + displayState: MessageDisplayState( + showTimestamp: item.showTimestamp, + showDirectionGap: item.showDirectionGap, + showSenderName: item.showSenderName, + showNewMessagesDivider: item.showNewMessagesDivider, + detectedURL: item.detectedURL, + previewState: item.previewState, + loadedPreview: item.loadedPreview, + isImageURL: item.isImageURL, + decodedImage: viewModel.decodedImage(for: message.id), + decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), + decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), + isGIF: viewModel.isGIFImage(for: message.id), + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + formattedText: viewModel.formattedText( + for: message.id, + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: deviceName, + isHighContrast: colorSchemeContrast == .increased + ) + ), + callbacks: MessageBubbleCallbacks( + onRetry: { onRetryMessage(message) }, + onReaction: { emoji in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { selectedMessageForActions = message }, + onImageTap: { + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: viewModel.isGIFImage(for: message.id) + ) + } + }, + onRetryImageFetch: { + Task { await viewModel.retryImageFetch(for: message.id) } + }, + onRequestPreviewFetch: { + if item.isImageURL && showInlineImages { + viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) + } else { + viewModel.requestPreviewFetch(for: message.id) + } + }, + onManualPreviewFetch: { + Task { + await viewModel.manualFetchPreview(for: message.id) + } + } + ) + ) + } else { + Text(L10n.Chats.Chats.Message.unavailable) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) + } + } +} diff --git a/MC1/Views/Components/LabelsToggleButton.swift b/MC1/Views/Components/LabelsToggleButton.swift new file mode 100644 index 000000000..c940740ac --- /dev/null +++ b/MC1/Views/Components/LabelsToggleButton.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct LabelsToggleButton: View { + @Binding var showLabels: Bool + + var body: some View { + Button(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { + withAnimation { + showLabels.toggle() + } + } + .font(.body.weight(.medium)) + .foregroundStyle(showLabels ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index ae416dce1..00865f4e7 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -2,22 +2,29 @@ import MapKit import SwiftUI /// Shared toolbar for map control buttons with liquid glass styling. -/// Provides location and layers buttons with a slot for custom content. -struct MapControlsToolbar: View { +/// Provides location and layers buttons with slots for top and bottom custom content. +struct MapControlsToolbar: View { /// MapScope for SwiftUI Map's MapUserLocationButton. Mutually exclusive with onLocationTap. var mapScope: Namespace.ID? - /// Custom action for location button. Used when MapScope isn't available (e.g., MKMapViewRepresentable). + /// Custom action for location button. Used when MapScope isn't available (e.g., MapLibre views). var onLocationTap: (() -> Void)? /// Binding to control layers menu visibility. Parent view handles menu presentation. @Binding var showingLayersMenu: Bool + /// Custom buttons to display above the standard buttons. + @ViewBuilder var topContent: () -> TopContent + /// Custom buttons to display below the standard buttons. @ViewBuilder var customContent: () -> CustomContent var body: some View { VStack(spacing: 0) { + CustomContentStack { + topContent() + } + locationButton Divider() @@ -43,34 +50,45 @@ struct MapControlsToolbar: View { .frame(width: 44, height: 44) .contentShape(.rect) } else if let onLocationTap { - Button(action: onLocationTap) { - Image(systemName: "location.fill") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Controls.centerOnMyLocation) + Button(L10n.Map.Map.Controls.centerOnMyLocation, systemImage: "location.fill", action: onLocationTap) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) } } // MARK: - Layers Button private var layersButton: some View { - Button { + Button(L10n.Map.Map.Controls.layers, systemImage: "square.3.layers.3d.down.right") { withAnimation(.spring(response: 0.3)) { showingLayersMenu.toggle() } - } label: { - Image(systemName: "square.3.layers.3d.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) } + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Controls.layers) + .labelStyle(.iconOnly) + } +} + +extension MapControlsToolbar where TopContent == EmptyView { + init( + mapScope: Namespace.ID? = nil, + onLocationTap: (() -> Void)? = nil, + showingLayersMenu: Binding, + @ViewBuilder customContent: @escaping () -> CustomContent + ) { + self.mapScope = mapScope + self.onLocationTap = onLocationTap + self._showingLayersMenu = showingLayersMenu + self.topContent = { EmptyView() } + self.customContent = customContent } } @@ -81,19 +99,12 @@ private struct CustomContentStack: View { @ViewBuilder var content: Content var body: some View { - _VariadicView.Tree(DividerLayout()) { - content - } - } -} - -/// Layout that prepends a divider before each child view. -private struct DividerLayout: _VariadicView_MultiViewRoot { - func body(children: _VariadicView.Children) -> some View { - ForEach(children) { child in - Divider() - .frame(width: 36) - child + Group(subviews: content) { subviews in + ForEach(subviews) { subview in + Divider() + .frame(width: 36) + subview + } } } } diff --git a/MC1/Views/Components/NoDoubleTapMapView.swift b/MC1/Views/Components/NoDoubleTapMapView.swift deleted file mode 100644 index 732b7a566..000000000 --- a/MC1/Views/Components/NoDoubleTapMapView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import MapKit - -/// MKMapView subclass that disables double-tap-to-zoom and one-handed zoom gestures. -/// Directly disables VariableDelayTap and OneHandedZoom gesture recognizers on MapKit's -/// content view rather than using `require(toFail:)` blockers, which avoids a ~1s cascading -/// gesture timeout after tapping annotation pins. -final class NoDoubleTapMapView: MKMapView { - override func layoutSubviews() { - super.layoutSubviews() - disableDoubleTapGestures() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - disableDoubleTapGestures() - return super.hitTest(point, with: event) - } - - private func disableDoubleTapGestures() { - guard let contentView = subviews.first(where: { - NSStringFromClass(type(of: $0)).contains("MapContentView") - }) else { return } - - for gesture in contentView.gestureRecognizers ?? [] { - guard gesture.isEnabled else { continue } - let className = NSStringFromClass(type(of: gesture)) - if className.contains("VariableDelayTap") || className.contains("OneHandedZoom") { - gesture.isEnabled = false - } - } - } -} diff --git a/MC1/Views/Components/NorthLockButton.swift b/MC1/Views/Components/NorthLockButton.swift new file mode 100644 index 000000000..d8a91113f --- /dev/null +++ b/MC1/Views/Components/NorthLockButton.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct NorthLockButton: View { + @Binding var isNorthLocked: Bool + + var body: some View { + Button( + isNorthLocked ? L10n.Map.Map.Controls.unlockNorth : L10n.Map.Map.Controls.lockNorth, + systemImage: isNorthLocked ? "location.north.line.fill" : "location.north.line" + ) { + withAnimation { + isNorthLocked.toggle() + } + } + .font(.body.weight(.medium)) + .foregroundStyle(isNorthLocked ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Components/ShareSheet.swift b/MC1/Views/Components/ShareSheet.swift deleted file mode 100644 index eb8295b4e..000000000 --- a/MC1/Views/Components/ShareSheet.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI -import UIKit - -struct ShareSheet: UIViewControllerRepresentable { - let items: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/MC1/Views/Contacts/BatchRTTRow.swift b/MC1/Views/Contacts/BatchRTTRow.swift new file mode 100644 index 000000000..b20c5d857 --- /dev/null +++ b/MC1/Views/Contacts/BatchRTTRow.swift @@ -0,0 +1,28 @@ +import SwiftUI +import MC1Services + +/// Row displaying batch RTT statistics (average, min, max) +struct BatchRTTRow: View { + @Bindable var viewModel: TracePathViewModel + + var body: some View { + if let avg = viewModel.averageRTT, + let min = viewModel.minRTT, + let max = viewModel.maxRTT { + HStack { + Text(L10n.Contacts.Contacts.Results.avgRoundTrip) + .foregroundStyle(.secondary) + Spacer() + VStack(alignment: .trailing) { + Text("\(avg) ms") + .font(.body.monospacedDigit()) + Text("(\(min) – \(max))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) + } + } +} diff --git a/MC1/Views/Contacts/ComparisonRowView.swift b/MC1/Views/Contacts/ComparisonRowView.swift new file mode 100644 index 000000000..566243d01 --- /dev/null +++ b/MC1/Views/Contacts/ComparisonRowView.swift @@ -0,0 +1,61 @@ +import SwiftUI +import MC1Services + +/// Row comparing current trace RTT with a previous saved path run +struct ComparisonRowView: View { + let currentMs: Int + let previousRun: TracePathRunDTO + @Bindable var viewModel: TracePathViewModel + + var body: some View { + let diff = currentMs - previousRun.roundTripMs + let percentChange = previousRun.roundTripMs > 0 + ? Double(diff) / Double(previousRun.roundTripMs) * 100 + : 0 + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(currentMs) ms") + .font(.body.monospacedDigit()) + + // Change indicator + if diff != 0 { + Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") + .foregroundStyle(diff > 0 ? .red : .green) + .accessibilityLabel(diff > 0 + ? L10n.Contacts.Contacts.Results.Comparison.increased + : L10n.Contacts.Contacts.Results.Comparison.decreased) + Text(abs(percentChange), format: .number.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + + Text("%") + .font(.caption) + } + } + + Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) + .font(.caption) + .foregroundStyle(.secondary) + } + + // Sparkline with history link + if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { + HStack { + MiniSparkline(values: savedPath.recentRTTs) + .frame(height: 20) + + Spacer() + + NavigationLink { + SavedPathDetailView(savedPath: savedPath) + } label: { + Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) + .font(.caption) + } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } + } +} diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index b19b6dc45..9ebb394eb 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -736,24 +736,41 @@ private struct ContactInfoSection: View { } private struct ContactLocationSection: View { - let currentContact: ContactDTO + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme - private var contactCoordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: currentContact.latitude, - longitude: currentContact.longitude - ) - } + let currentContact: ContactDTO var body: some View { Section { // Mini map - Map(position: .constant(.region(MKCoordinateRegion( - center: contactCoordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )))) { - Marker(currentContact.displayName, coordinate: contactCoordinate) - } + MC1MapView( + points: [MapPoint( + id: currentContact.id, + coordinate: currentContact.coordinate, + pinStyle: currentContact.type.pinStyle, + label: currentContact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: false, + showsUserLocation: false, + isInteractive: false, + showsScale: false, + cameraRegion: .constant(MKCoordinateRegion( + center: currentContact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )), + cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: nil + ) .frame(height: 200) .clipShape(.rect(cornerRadius: 12)) .listRowInsets(EdgeInsets()) @@ -770,7 +787,7 @@ private struct ContactLocationSection: View { } .listRowBackground( UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemGroupedBackground)) ) // Open in Maps @@ -785,7 +802,7 @@ private struct ContactLocationSection: View { } private func openInMaps() { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: contactCoordinate)) + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) mapItem.name = currentContact.displayName mapItem.openInMaps() } diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index a9bdd9bcf..3469947e1 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -390,7 +390,7 @@ private struct DiscoverySortMenu: View { } label: { Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) } @@ -413,7 +413,7 @@ private struct DiscoveryMoreMenu: View { } label: { Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() } } diff --git a/MC1/Views/Contacts/DistanceInfoSheetView.swift b/MC1/Views/Contacts/DistanceInfoSheetView.swift new file mode 100644 index 000000000..9c0371649 --- /dev/null +++ b/MC1/Views/Contacts/DistanceInfoSheetView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import MC1Services + +/// Sheet explaining distance calculation details and limitations +struct DistanceInfoSheetView: View { + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var showingDistanceInfo: Bool + + var body: some View { + NavigationStack { + List { + if viewModel.isDistanceUsingFallback { + Section { + Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) + } header: { + Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") + } + Section { + Text(L10n.Contacts.Contacts.Results.fullPathTip) + } header: { + Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") + } + } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { + Section { + Text(L10n.Contacts.Contacts.Results.needsRepeaters) + } + } else if viewModel.repeatersWithoutLocation.isEmpty { + Section { + Text(L10n.Contacts.Contacts.Results.distanceError) + } + } else { + Section { + Text(L10n.Contacts.Contacts.Results.missingLocations) + } + Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { + ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in + Text(name) + } + } + } + } + .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Common.done) { + showingDistanceInfo = false + } + } + } + } + } +} diff --git a/MC1/Views/Contacts/GlassButtonModifier.swift b/MC1/Views/Contacts/GlassButtonModifier.swift deleted file mode 100644 index b1b8b291f..000000000 --- a/MC1/Views/Contacts/GlassButtonModifier.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct GlassButtonModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.buttonStyle(.glass) - } else { - content - } - } -} diff --git a/MC1/Views/Contacts/SavePathRowView.swift b/MC1/Views/Contacts/SavePathRowView.swift new file mode 100644 index 000000000..52590a3a7 --- /dev/null +++ b/MC1/Views/Contacts/SavePathRowView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import MC1Services + +/// Row allowing the user to save the current trace path +struct SavePathRowView: View { + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + + @State private var showingSaveDialog = false + @State private var savePathName = "" + + var body: some View { + if showingSaveDialog { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) + .textFieldStyle(.roundedBorder) + + HStack { + Button(L10n.Contacts.Contacts.Common.cancel) { + showingSaveDialog = false + savePathName = "" + } + .buttonStyle(.bordered) + + Spacer() + + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await viewModel.savePath(name: savePathName) + if success { + saveHapticTrigger += 1 + } + showingSaveDialog = false + savePathName = "" + } + } + .buttonStyle(.borderedProminent) + .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) + } + } + .padding(.vertical, 4) + } else { + Button { + savePathName = viewModel.generatePathName() + showingSaveDialog = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + .disabled(!viewModel.canSavePath) + } + } +} diff --git a/MC1/Views/Contacts/TotalDistanceRow.swift b/MC1/Views/Contacts/TotalDistanceRow.swift new file mode 100644 index 000000000..ddaf77194 --- /dev/null +++ b/MC1/Views/Contacts/TotalDistanceRow.swift @@ -0,0 +1,57 @@ +import SwiftUI +import MC1Services + +/// Row displaying total path distance with optional info sheet +struct TotalDistanceRow: View { + @Bindable var viewModel: TracePathViewModel + let result: TraceResult + @Binding var showingDistanceInfo: Bool + + var body: some View { + HStack { + Text(L10n.Contacts.Contacts.Results.totalDistance) + .foregroundStyle(.secondary) + Spacer() + + if let distance = viewModel.totalPathDistance { + HStack { + Text(formatDistance(distance)) + .font(.body.monospacedDigit()) + if viewModel.isDistanceUsingFallback { + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) + } + } + } else { + HStack { + Text(L10n.Contacts.Contacts.Results.unavailable) + .foregroundStyle(.secondary) + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) + } + } + } + .sheet(isPresented: $showingDistanceInfo) { + DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + + private func formatDistance(_ meters: Double) -> String { + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + } +} diff --git a/MC1/Views/Contacts/TracePathListView.swift b/MC1/Views/Contacts/TracePathListView.swift index 7c2a4c0ab..41fc2390c 100644 --- a/MC1/Views/Contacts/TracePathListView.swift +++ b/MC1/Views/Contacts/TracePathListView.swift @@ -15,45 +15,28 @@ struct TracePathListView: View { @Binding var presentedResult: TraceResult? @Binding var showJumpToPath: Bool - @State private var isRepeatersExpanded = false @State private var codeInput = "" @State private var codeInputError: String? @State private var pastedSuccessfully = false - @AppStorage("tracePathShowOnlyFavorites") private var showOnlyFavorites = false - @AppStorage("tracePathIncludeRooms") private var includeRooms = false - @AppStorage("tracePathIncludeDiscovered") private var includeDiscovered = false - - private var filteredNodes: [PickerNode] { - var nodes: [PickerNode] = viewModel.availableRepeaters.map { .contact($0) } - if includeRooms { - nodes += viewModel.availableRooms.map { .contact($0) } - } - if includeDiscovered { - let contactKeys = Set(nodes.compactMap { - if case .contact(let c) = $0 { c.publicKey } else { nil } - }) - nodes += viewModel.discoveredRepeaters - .filter { !contactKeys.contains($0.publicKey) } - .map { .discovered($0) } - } - if showOnlyFavorites { - nodes = nodes.filter { - switch $0 { - case .contact(let c): c.isFavorite - case .discovered: false - } - } - } - return nodes - } var body: some View { List { codeInputSection - availableRepeatersSection + AvailableRepeatersSectionView( + viewModel: viewModel, + recentlyAddedRepeaterID: $recentlyAddedRepeaterID, + addHapticTrigger: $addHapticTrigger + ) outboundPathSection - pathActionsSection - runTraceSection + PathActionsSectionView( + viewModel: viewModel, + showingClearConfirmation: $showingClearConfirmation, + copyHapticTrigger: $copyHapticTrigger + ) + RunTraceSectionView( + viewModel: viewModel, + showJumpToPath: $showJumpToPath + ) Color.clear .frame(height: 1) @@ -119,82 +102,6 @@ struct TracePathListView: View { } } - // MARK: - Repeaters Section - - private var availableRepeatersSection: some View { - Section { - DisclosureGroup(isExpanded: $isRepeatersExpanded) { - Toggle(L10n.Contacts.Contacts.Trace.List.favoritesOnly, isOn: $showOnlyFavorites) - Toggle(L10n.Contacts.Contacts.Trace.List.includeRooms, isOn: $includeRooms) - if !showOnlyFavorites { - Toggle(L10n.Contacts.Contacts.Trace.List.includeDiscovered, isOn: $includeDiscovered) - } - - if filteredNodes.isEmpty { - if showOnlyFavorites { - ContentUnavailableView( - L10n.Contacts.Contacts.Trace.List.NoFavorites.title, - systemImage: "star.slash", - description: Text(L10n.Contacts.Contacts.Trace.List.NoFavorites.description) - ) - } else { - ContentUnavailableView( - L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(L10n.Contacts.Contacts.PathEdit.NoRepeaters.description) - ) - } - } else { - ForEach(filteredNodes) { node in - Button { - recentlyAddedRepeaterID = node.id - addHapticTrigger += 1 - viewModel.addNode(node.underlying) - } label: { - HStack { - VStack(alignment: .leading) { - HStack { - Text(node.displayName) - if node.isRoom { - NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.room, color: .orange) - } - if node.isDiscovered { - NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.discovered, color: .blue) - } - } - Text(node.publicKeyHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Image(systemName: recentlyAddedRepeaterID == node.id ? "checkmark.circle.fill" : "plus.circle") - .foregroundStyle(recentlyAddedRepeaterID == node.id ? Color.green : Color.accentColor) - .contentTransition(.symbolEffect(.replace)) - } - } - .id(node.id) - .foregroundStyle(.primary) - .accessibilityLabel(L10n.Contacts.Contacts.PathEdit.addToPath(node.displayName)) - } - } - } label: { - HStack { - Text(L10n.Contacts.Contacts.Trace.List.repeaters) - Spacer() - Text("\(filteredNodes.count)") - .foregroundStyle(.secondary) - } - } - .onChange(of: showOnlyFavorites) { _, newValue in - if newValue { - includeDiscovered = false - } - } - } - } - // MARK: - Outbound Path Section private var outboundPathSection: some View { @@ -221,133 +128,4 @@ struct TracePathListView: View { Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } } - - // MARK: - Path Actions Section - - private var pathActionsSection: some View { - Section { - if !viewModel.outboundPath.isEmpty { - Toggle(isOn: $viewModel.autoReturnPath) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.autoReturn) - Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Toggle(isOn: $viewModel.batchEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.batchTrace) - Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if viewModel.batchEnabled { - HStack(spacing: 12) { - Text(L10n.Contacts.Contacts.Trace.List.traces) - .foregroundStyle(.secondary) - Spacer() - BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) - } - } - - HStack { - Text(viewModel.fullPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - - Spacer() - - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - viewModel.copyPathToClipboard() - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } - - Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { - showingClearConfirmation = true - } - .foregroundStyle(.red) - } - } footer: { - if !viewModel.outboundPath.isEmpty { - Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) - } - } - } - - // MARK: - Run Trace Section - - private var runTraceSection: some View { - Section { - HStack { - Spacer() - if viewModel.isRunning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - if viewModel.batchEnabled { - Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) - } else { - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } - .frame(minWidth: 160) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(.regularMaterial, in: .capsule) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) - } - .accessibilityLabel(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.runningLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) - } else { - Button { - Task { - if viewModel.batchEnabled { - await viewModel.runBatchTrace() - } else { - await viewModel.runTrace() - } - } - } label: { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - .frame(minWidth: 160) - .padding(.vertical, 4) - } - .liquidGlassProminentButtonStyle() - .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) - .accessibilityHint(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.singleHint) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .id("runTrace") - .onAppear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = false - } - } - .onDisappear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = true - } - } - } - .listSectionSeparator(.hidden) - } } diff --git a/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift new file mode 100644 index 000000000..d8292f339 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import MC1Services + +/// Disclosure group section listing available repeaters to add to a trace path +struct AvailableRepeatersSectionView: View { + var viewModel: TracePathViewModel + @Binding var recentlyAddedRepeaterID: UUID? + @Binding var addHapticTrigger: Int + + @State private var isRepeatersExpanded = false + @AppStorage("tracePathShowOnlyFavorites") private var showOnlyFavorites = false + @AppStorage("tracePathIncludeRooms") private var includeRooms = false + @AppStorage("tracePathIncludeDiscovered") private var includeDiscovered = false + + private var filteredNodes: [PickerNode] { + var nodes: [PickerNode] = viewModel.availableRepeaters.map { .contact($0) } + if includeRooms { + nodes += viewModel.availableRooms.map { .contact($0) } + } + if includeDiscovered { + let contactKeys = Set(nodes.compactMap { + if case .contact(let c) = $0 { c.publicKey } else { nil } + }) + nodes += viewModel.discoveredRepeaters + .filter { !contactKeys.contains($0.publicKey) } + .map { .discovered($0) } + } + if showOnlyFavorites { + nodes = nodes.filter { + switch $0 { + case .contact(let c): c.isFavorite + case .discovered: false + } + } + } + return nodes + } + + var body: some View { + let nodes = filteredNodes + Section { + DisclosureGroup(isExpanded: $isRepeatersExpanded) { + Toggle(L10n.Contacts.Contacts.Trace.List.favoritesOnly, isOn: $showOnlyFavorites) + Toggle(L10n.Contacts.Contacts.Trace.List.includeRooms, isOn: $includeRooms) + if !showOnlyFavorites { + Toggle(L10n.Contacts.Contacts.Trace.List.includeDiscovered, isOn: $includeDiscovered) + } + + if nodes.isEmpty { + if showOnlyFavorites { + ContentUnavailableView( + L10n.Contacts.Contacts.Trace.List.NoFavorites.title, + systemImage: "star.slash", + description: Text(L10n.Contacts.Contacts.Trace.List.NoFavorites.description) + ) + } else { + ContentUnavailableView( + L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(L10n.Contacts.Contacts.PathEdit.NoRepeaters.description) + ) + } + } else { + ForEach(nodes) { node in + Button { + recentlyAddedRepeaterID = node.id + addHapticTrigger += 1 + viewModel.addNode(node.underlying) + } label: { + HStack { + VStack(alignment: .leading) { + HStack { + Text(node.displayName) + if node.isRoom { + NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.room, color: .orange) + } + if node.isDiscovered { + NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.discovered, color: .blue) + } + } + Text(node.publicKeyHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Image(systemName: recentlyAddedRepeaterID == node.id ? "checkmark.circle.fill" : "plus.circle") + .foregroundStyle(recentlyAddedRepeaterID == node.id ? Color.green : Color.accentColor) + .contentTransition(.symbolEffect(.replace)) + } + } + .id(node.id) + .foregroundStyle(.primary) + .accessibilityLabel(L10n.Contacts.Contacts.PathEdit.addToPath(node.displayName)) + } + } + } label: { + HStack { + Text(L10n.Contacts.Contacts.Trace.List.repeaters) + Spacer() + Text("\(nodes.count)") + .foregroundStyle(.secondary) + } + } + .onChange(of: showOnlyFavorites) { _, newValue in + if newValue { + includeDiscovered = false + } + } + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift b/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift new file mode 100644 index 000000000..cf0f29fa4 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import MC1Services + +/// Section with path configuration toggles, copy, and clear actions +struct PathActionsSectionView: View { + @Bindable var viewModel: TracePathViewModel + @Binding var showingClearConfirmation: Bool + @Binding var copyHapticTrigger: Int + + var body: some View { + Section { + if !viewModel.outboundPath.isEmpty { + Toggle(isOn: $viewModel.autoReturnPath) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.autoReturn) + Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Toggle(isOn: $viewModel.batchEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.batchTrace) + Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if viewModel.batchEnabled { + HStack(spacing: 12) { + Text(L10n.Contacts.Contacts.Trace.List.traces) + .foregroundStyle(.secondary) + Spacer() + BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) + } + } + + HStack { + Text(viewModel.fullPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Spacer() + + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + viewModel.copyPathToClipboard() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + + Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { + showingClearConfirmation = true + } + } + } footer: { + if !viewModel.outboundPath.isEmpty { + Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) + } + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift b/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift deleted file mode 100644 index 3880da2f5..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift +++ /dev/null @@ -1,72 +0,0 @@ -import MapKit -import MC1Services - -/// Custom polyline overlay that carries signal quality data for styling -/// Note: All properties are immutable - create new overlay instances when signal quality changes -final class PathLineOverlay: MKPolyline { - - /// Signal quality - nil means untraced (pre-trace dashed gray line) - private(set) var signalQuality: SNRQuality? - - /// Distance in meters between the two endpoints - immutable after creation - private(set) var distanceMeters: Double = 0 - - /// SNR value in dB - immutable after creation - private(set) var snr: Double = 0 - - /// Index of this segment in the path (0 = user to first hop) - immutable after creation - private(set) var segmentIndex: Int = 0 - - /// Start coordinate for this segment - private(set) var startCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// End coordinate for this segment - private(set) var endCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// Create overlay between two coordinates - static func line( - from start: CLLocationCoordinate2D, - to end: CLLocationCoordinate2D, - segmentIndex: Int, - signalQuality: SNRQuality? = nil, - snr: Double = 0 - ) -> PathLineOverlay { - var coords = [start, end] - let overlay = PathLineOverlay(coordinates: &coords, count: 2) - overlay.segmentIndex = segmentIndex - overlay.signalQuality = signalQuality - overlay.snr = snr - overlay.startCoordinate = start - overlay.endCoordinate = end - - // Calculate distance - let startLocation = CLLocation(latitude: start.latitude, longitude: start.longitude) - let endLocation = CLLocation(latitude: end.latitude, longitude: end.longitude) - overlay.distanceMeters = startLocation.distance(from: endLocation) - - return overlay - } - - /// Create a new overlay with updated signal quality (immutable pattern) - func withSignalQuality(_ quality: SNRQuality, snr: Double) -> PathLineOverlay { - PathLineOverlay.line( - from: startCoordinate, - to: endCoordinate, - segmentIndex: segmentIndex, - signalQuality: quality, - snr: snr - ) - } - - /// Midpoint coordinate for placing stats badge - var midpoint: CLLocationCoordinate2D { - guard pointCount >= 2 else { return coordinate } - let points = self.points() - let start = points[0] - let end = points[1] - return CLLocationCoordinate2D( - latitude: (start.coordinate.latitude + end.coordinate.latitude) / 2, - longitude: (start.coordinate.longitude + end.coordinate.longitude) / 2 - ) - } -} diff --git a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift b/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift deleted file mode 100644 index ff6d4a8f8..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift +++ /dev/null @@ -1,42 +0,0 @@ -import MapKit -import UIKit - -/// Renderer for PathLineOverlay that draws dashed or solid colored lines -/// Note: Since PathLineOverlay is immutable, create new overlays when signal quality changes -/// rather than calling updateAppearance on existing renderers -final class PathLineRenderer: MKPolylineRenderer { - - override init(overlay: any MKOverlay) { - super.init(overlay: overlay) - configureAppearance() - } - - private func configureAppearance() { - guard let pathOverlay = overlay as? PathLineOverlay else { return } - - guard let quality = pathOverlay.signalQuality else { - // Untraced — dashed gray - strokeColor = UIColor.systemGray - lineWidth = 2 - lineDashPattern = [8, 6] - return - } - - strokeColor = quality.uiColor - - switch quality { - case .excellent, .good: - lineWidth = 4 - lineDashPattern = nil - case .fair: - lineWidth = 3 - lineDashPattern = [12, 4] - case .poor: - lineWidth = 3 - lineDashPattern = [4, 4] - case .unknown: - lineWidth = 2 - lineDashPattern = [8, 6] - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift b/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift new file mode 100644 index 000000000..448c93e62 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import MC1Services + +/// Section with the run trace button and running state indicator +struct RunTraceSectionView: View { + @Environment(\.appState) private var appState + var viewModel: TracePathViewModel + @Binding var showJumpToPath: Bool + + var body: some View { + Section { + HStack { + Spacer() + if viewModel.isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + if viewModel.batchEnabled { + Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) + } else { + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) + } + } + .frame(minWidth: 160) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.regularMaterial, in: .capsule) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + } + .accessibilityLabel(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.runningLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) + } else { + Button { + Task { + if viewModel.batchEnabled { + await viewModel.runBatchTrace() + } else { + await viewModel.runTrace() + } + } + } label: { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + .frame(minWidth: 160) + .padding(.vertical, 4) + } + .liquidGlassProminentButtonStyle() + .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) + .accessibilityHint(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.singleHint) + } + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .id("runTrace") + .onAppear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = false + } + } + .onDisappear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = true + } + } + } + .listSectionSeparator(.hidden) + } +} diff --git a/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift b/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift deleted file mode 100644 index a53ef4d7f..000000000 --- a/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift +++ /dev/null @@ -1,37 +0,0 @@ -import MapKit - -/// Annotation for displaying stats badge at path segment midpoint -final class StatsBadgeAnnotation: NSObject, MKAnnotation { - let coordinate: CLLocationCoordinate2D - let distanceMeters: Double - let snr: Double - let segmentIndex: Int - - init(coordinate: CLLocationCoordinate2D, distanceMeters: Double, snr: Double, segmentIndex: Int) { - self.coordinate = coordinate - self.distanceMeters = distanceMeters - self.snr = snr - self.segmentIndex = segmentIndex - super.init() - } - - /// Formatted distance string (e.g., "1.2 mi" or "500 m") - var distanceString: String { - let miles = distanceMeters / 1609.34 - if miles >= 0.1 { - return "\(miles.formatted(.number.precision(.fractionLength(1)))) mi" - } else { - return "\(distanceMeters.formatted(.number.precision(.fractionLength(0)))) m" - } - } - - /// Formatted SNR string (e.g., "8 dB") - var snrString: String { - "\(snr.formatted(.number.precision(.fractionLength(0)))) dB" - } - - /// Combined display string - var displayString: String { - "\(distanceString) • \(snrString)" - } -} diff --git a/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift b/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift deleted file mode 100644 index c37630450..000000000 --- a/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift +++ /dev/null @@ -1,89 +0,0 @@ -import MapKit -import UIKit - -/// Annotation view displaying stats badge with liquid glass styling -final class StatsBadgeView: MKAnnotationView { - static let reuseIdentifier = "StatsBadgeView" - - private let containerView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - private let label = UILabel() - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupViews() { - // Container with blur - containerView.translatesAutoresizingMaskIntoConstraints = false - containerView.layer.cornerRadius = 12 - containerView.layer.masksToBounds = true - addSubview(containerView) - - // Shadow - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.2 - layer.shadowRadius = 4 - layer.shadowOffset = CGSize(width: 0, height: 2) - - // Label with Dynamic Type - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - containerView.contentView.addSubview(label) - - NSLayoutConstraint.activate([ - containerView.topAnchor.constraint(equalTo: topAnchor), - containerView.bottomAnchor.constraint(equalTo: bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: trailingAnchor), - - label.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), - label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -6), - label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10), - label.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10) - ]) - - canShowCallout = false - isEnabled = false - } - - func configure(with annotation: StatsBadgeAnnotation) { - label.text = annotation.displayString - - // Size to fit content - label.sizeToFit() - let size = CGSize( - width: label.frame.width + 20, - height: label.frame.height + 12 - ) - frame = CGRect(origin: .zero, size: size) - centerOffset = CGPoint(x: 0, y: 0) - - // Accessibility - isAccessibilityElement = true - accessibilityLabel = "Distance: \(annotation.distanceString), Signal: \(Int(annotation.snr)) decibels" - accessibilityTraits = .staticText - } - - override func prepareForReuse() { - super.prepareForReuse() - label.text = nil - accessibilityLabel = nil - } - - override func prepareForDisplay() { - super.prepareForDisplay() - if let statsAnnotation = annotation as? StatsBadgeAnnotation { - configure(with: statsAnnotation) - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift b/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift deleted file mode 100644 index 3aaf0f5a8..000000000 --- a/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift +++ /dev/null @@ -1,86 +0,0 @@ -import MapKit -import UIKit - -/// Cluster annotation view for grouped repeater pins -final class TracePathClusterView: MKAnnotationView { - - private let countLabel = UILabel() - private let circleView = UIView() - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupViews() { - let size: CGFloat = 32 - - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = size / 2 - circleView.layer.borderColor = UIColor.white.cgColor - circleView.layer.borderWidth = 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - countLabel.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, - weight: .bold - ) - countLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: baseFont) - countLabel.adjustsFontForContentSizeCategory = true - countLabel.textColor = .white - countLabel.textAlignment = .center - circleView.addSubview(countLabel) - - NSLayoutConstraint.activate([ - circleView.widthAnchor.constraint(equalToConstant: size), - circleView.heightAnchor.constraint(equalToConstant: size), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.centerYAnchor.constraint(equalTo: centerYAnchor), - - countLabel.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - countLabel.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - frame = CGRect(x: 0, y: 0, width: size, height: size) - centerOffset = .zero - canShowCallout = false - - displayPriority = .defaultHigh - collisionMode = .circle - } - - func configure(with clusterAnnotation: MKClusterAnnotation) { - let count = clusterAnnotation.memberAnnotations.count - countLabel.text = "\(count)" - - isAccessibilityElement = true - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Cluster.label(count) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Cluster.hint - accessibilityTraits = .button - } - - override func prepareForReuse() { - super.prepareForReuse() - countLabel.text = nil - accessibilityLabel = nil - accessibilityHint = nil - } - - override func prepareForDisplay() { - super.prepareForDisplay() - if let cluster = annotation as? MKClusterAnnotation { - configure(with: cluster) - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift b/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift new file mode 100644 index 000000000..fcfcfb5ff --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import MC1Services + +/// Floating action buttons for trace path map (clear, run trace, view results) +struct TracePathFloatingButtonsView: View { + var mapViewModel: TracePathMapViewModel + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + var buttonNamespace: Namespace.ID + + var body: some View { + VStack { + Spacer() + + LiquidGlassContainer(spacing: 12) { + HStack(spacing: 12) { + if mapViewModel.hasPath { + // Clear button + Button { + showingClearConfirmation = true + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.clear) + } + .liquidGlassButtonStyle() + .liquidGlassID("clear", in: buttonNamespace) + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + mapViewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } + + // Run Trace button + Button { + Task { + await mapViewModel.runTrace() + } + } label: { + if mapViewModel.isRunning { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) + } + } else { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + } + } + .liquidGlassProminentButtonStyle() + .liquidGlassID("trace", in: buttonNamespace) + .disabled(!mapViewModel.canRunTrace) + + // View Results button + if let result = mapViewModel.result, result.success { + Button { + presentedResult = result + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.viewResults) + } + .liquidGlassButtonStyle() + .liquidGlassID("viewResults", in: buttonNamespace) + } + } + } + } + .animation(.spring(response: 0.3), value: mapViewModel.hasPath) + .animation(.spring(response: 0.3), value: mapViewModel.result?.id) + .padding(.bottom, 24) + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift deleted file mode 100644 index c37c93b6f..000000000 --- a/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift +++ /dev/null @@ -1,316 +0,0 @@ -import MapKit -import SwiftUI -import MC1Services - -/// UIViewRepresentable for trace path map with custom overlays and interactions -struct TracePathMKMapView: UIViewRepresentable { - let repeaters: [ContactDTO] - let lineOverlays: [PathLineOverlay] - let badgeAnnotations: [StatsBadgeAnnotation] - let mapType: MKMapType - let showLabels: Bool - - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - - // Pre-computed path membership for all repeaters (closure to defer computation to updateUIView) - let pathState: () -> [UUID: TracePathMapViewModel.RepeaterPathInfo] - let onRepeaterTap: (ContactDTO) -> Void - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - mapView.showsUserLocation = true - - // Register annotation views - mapView.register( - TracePathRepeaterPinView.self, - forAnnotationViewWithReuseIdentifier: TracePathRepeaterPinView.reuseIdentifier - ) - mapView.register( - TracePathClusterView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - mapView.register( - StatsBadgeView.self, - forAnnotationViewWithReuseIdentifier: StatsBadgeView.reuseIdentifier - ) - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Update callbacks and state - let pathState = pathState() - coordinator.pathState = pathState - coordinator.onRepeaterTap = onRepeaterTap - coordinator.showLabels = showLabels - - // Update map type - mapView.mapType = mapType - - // Update repeater annotations - updateRepeaterAnnotations(in: mapView, coordinator: coordinator, pathState: pathState) - - // Update overlays (with change detection) - updateOverlays(in: mapView, coordinator: coordinator) - - // Update badge annotations (with change detection) - updateBadgeAnnotations(in: mapView, coordinator: coordinator) - - // Update region only when explicitly requested (version changed) - if cameraRegionVersion != coordinator.lastAppliedRegionVersion, - let region = cameraRegion { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - coordinator.hasPendingProgrammaticRegion = true - mapView.setRegion(region, animated: coordinator.lastAppliedRegion != nil) - coordinator.lastAppliedRegion = region - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(setCameraRegion: { cameraRegion = $0 }) - } - - // MARK: - Annotation Updates - - private func updateRepeaterAnnotations( - in mapView: MKMapView, - coordinator: Coordinator, - pathState: [UUID: TracePathMapViewModel.RepeaterPathInfo] - ) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? RepeaterAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.repeater.id }) - let newIDs = Set(repeaters.map { $0.id }) - - // Remove old - let toRemove = currentAnnotations.filter { !newIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toRemove) - - // Add new - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.repeater.id })) - let toAdd = repeaters.filter { !existingIDs.contains($0.id) } - .map { RepeaterAnnotation(repeater: $0) } - mapView.addAnnotations(toAdd) - - // Determine which annotations changed path membership and need re-adding - // (MapKit doesn't pick up clusteringIdentifier changes on existing views) - let currentInPathIDs = Set(pathState.filter { $0.value.inPath }.map { $0.key }) - let previousInPathIDs = coordinator.previousInPathIDs - let changedIDs = currentInPathIDs.symmetricDifference(previousInPathIDs) - coordinator.previousInPathIDs = currentInPathIDs - - if !changedIDs.isEmpty { - let toReAdd = mapView.annotations - .compactMap { $0 as? RepeaterAnnotation } - .filter { changedIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toReAdd) - mapView.addAnnotations(toReAdd) - } - - // Update visible pin views using pre-computed state - for annotation in mapView.annotations.compactMap({ $0 as? RepeaterAnnotation }) { - guard let view = mapView.view(for: annotation) as? TracePathRepeaterPinView else { - continue - } - let info = pathState[annotation.repeater.id] ?? TracePathMapViewModel.RepeaterPathInfo( - inPath: false, hopIndex: nil, isLastHop: false - ) - view.configure( - for: annotation.repeater, - inPath: info.inPath, - hopIndex: info.hopIndex, - isLastHop: info.isLastHop, - showLabel: showLabels - ) - } - } - - private func updateOverlays(in mapView: MKMapView, coordinator: Coordinator) { - let newIdentities = Set(lineOverlays.map { ObjectIdentifier($0) }) - - guard newIdentities != coordinator.lastOverlayIdentities else { return } - coordinator.lastOverlayIdentities = newIdentities - - let existingPathOverlays = mapView.overlays.compactMap { $0 as? PathLineOverlay } - mapView.removeOverlays(existingPathOverlays) - mapView.addOverlays(lineOverlays) - } - - private func updateBadgeAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let newIdentities = Set(badgeAnnotations.map { ObjectIdentifier($0) }) - - guard newIdentities != coordinator.lastBadgeIdentities else { return } - coordinator.lastBadgeIdentities = newIdentities - - let existingBadges = mapView.annotations.compactMap { $0 as? StatsBadgeAnnotation } - mapView.removeAnnotations(existingBadges) - mapView.addAnnotations(badgeAnnotations) - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate { - var setCameraRegion: (MKCoordinateRegion?) -> Void - - var pathState: [UUID: TracePathMapViewModel.RepeaterPathInfo] = [:] - var onRepeaterTap: ((ContactDTO) -> Void)? - var showLabels: Bool = true - - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var lastAppliedRegionVersion = -1 - var hasPendingProgrammaticRegion = false - - // Change detection state - var previousInPathIDs: Set = [] - var lastOverlayIdentities: Set = [] - var lastBadgeIdentities: Set = [] - - /// Tracks whether the initial MKMapView region change has been received. - /// The first region change is from MKMapView initialization, not a user gesture. - private var hasReceivedInitialRegion = false - - /// Pending region update task for cancellation - private var pendingRegionTask: Task? - - lazy var mapView: MKMapView = NoDoubleTapMapView() - - init(setCameraRegion: @escaping (MKCoordinateRegion?) -> Void) { - self.setCameraRegion = setCameraRegion - } - - deinit { - pendingRegionTask?.cancel() - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - if annotation is MKUserLocation { - return nil - } - - if let clusterAnnotation = annotation as? MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? TracePathClusterView ?? TracePathClusterView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.configure(with: clusterAnnotation) - return view - } - - if let repeaterAnnotation = annotation as? RepeaterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: TracePathRepeaterPinView.reuseIdentifier, - for: annotation - ) as? TracePathRepeaterPinView ?? TracePathRepeaterPinView( - annotation: annotation, - reuseIdentifier: TracePathRepeaterPinView.reuseIdentifier - ) - - let info = pathState[repeaterAnnotation.repeater.id] - ?? TracePathMapViewModel.RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - - view.configure( - for: repeaterAnnotation.repeater, - inPath: info.inPath, - hopIndex: info.hopIndex, - isLastHop: info.isLastHop, - showLabel: showLabels - ) - - view.onTap = { [weak self] in - self?.onRepeaterTap?(repeaterAnnotation.repeater) - } - - return view - } - - if let badgeAnnotation = annotation as? StatsBadgeAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: StatsBadgeView.reuseIdentifier, - for: annotation - ) as? StatsBadgeView ?? StatsBadgeView( - annotation: annotation, - reuseIdentifier: StatsBadgeView.reuseIdentifier - ) - view.configure(with: badgeAnnotation) - return view - } - - return nil - } - - func mapView(_ mapView: MKMapView, rendererFor overlay: any MKOverlay) -> MKOverlayRenderer { - if let pathOverlay = overlay as? PathLineOverlay { - return PathLineRenderer(overlay: pathOverlay) - } - return MKOverlayRenderer(overlay: overlay) - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - mapView.deselectAnnotation(annotation, animated: false) - - if let cluster = annotation as? MKClusterAnnotation { - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - // Repeater taps are handled by UITapGestureRecognizer on the pin view - // to bypass MapKit's ~300ms selection delay - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { return } - - if hasPendingProgrammaticRegion { - hasPendingProgrammaticRegion = false - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - // The first region change is from MKMapView initialization, not a user gesture. - // Don't block programmatic updates during this initial phase. - if !hasReceivedInitialRegion { - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - lastAppliedRegion = mapView.region - - // Debounce region sync back to SwiftUI to avoid update cascade during panning - pendingRegionTask?.cancel() - pendingRegionTask = Task { @MainActor in - guard !Task.isCancelled else { return } - self.setCameraRegion(mapView.region) - } - } - } -} - -// MARK: - Repeater Annotation - -final class RepeaterAnnotation: NSObject, MKAnnotation { - let repeater: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: repeater.latitude, longitude: repeater.longitude) - } - - var title: String? { repeater.displayName } - - init(repeater: ContactDTO) { - self.repeater = repeater - super.init() - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift new file mode 100644 index 000000000..165974fff --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -0,0 +1,65 @@ +import MapKit +import MapLibre +import SwiftUI +import MC1Services + +/// Map controls toolbar for trace path map view (location, labels, layers) +struct TracePathMapToolbarView: View { + @Environment(\.appState) private var appState + @Bindable var mapViewModel: TracePathMapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + if let location = appState.bestAvailableLocation { + mapViewModel.setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )) + } else { + appState.locationService.requestLocation() + } + }, + showingLayersMenu: $mapViewModel.showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $mapViewModel.isNorthLocked) + } + ) { + LabelsToggleButton(showLabels: $showLabels) + + // Center on path + if mapViewModel.hasPath { + Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + mapViewModel.centerOnPath() + } + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } + } + } + } + .overlay(alignment: .bottomTrailing) { + if mapViewModel.showingLayersMenu { + LayersMenu( + selection: $mapStyleSelection, + isPresented: $mapViewModel.showingLayersMenu, + viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 16) + .padding(.bottom, 160) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } +} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 56963689b..d81da5783 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -8,8 +8,11 @@ private let logger = Logger(subsystem: "com.mc1", category: "TracePathMapView") /// Map-based view for building and visualizing trace paths struct TracePathMapView: View { @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme @Bindable var traceViewModel: TracePathViewModel @Binding var presentedResult: TraceResult? + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage("mapShowLabels") private var showLabels = true @State private var mapViewModel = TracePathMapViewModel() @State private var showingSavePrompt = false @@ -28,34 +31,51 @@ struct TracePathMapView: View { // Results banner at top if let result = mapViewModel.result, result.success { - resultsBanner(result: result) + TracePathResultsBanner( + result: result, + totalPathDistance: traceViewModel.totalPathDistance + ) } // Empty state if mapViewModel.repeatersWithLocation.isEmpty { - emptyState + TracePathEmptyState() } // Floating buttons - floatingButtons + TracePathFloatingButtonsView( + mapViewModel: mapViewModel, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + buttonNamespace: buttonNamespace + ) // Map controls toolbar - mapToolbar + TracePathMapToolbarView( + mapViewModel: mapViewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels + ) } .onAppear { mapViewModel.configure( traceViewModel: traceViewModel, userLocation: appState.bestAvailableLocation ) + mapViewModel.showLabels = showLabels mapViewModel.rebuildOverlays() mapViewModel.performInitialCentering() } + .onChange(of: showLabels) { _, newValue in + mapViewModel.showLabels = newValue + } .onChange(of: appState.bestAvailableLocation) { old, new in guard old?.coordinate.latitude != new?.coordinate.latitude || old?.coordinate.longitude != new?.coordinate.longitude else { return } mapViewModel.updateUserLocation(new) } .onChange(of: traceViewModel.availableNodes) { _, _ in + mapViewModel.rebuildPathState() if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { mapViewModel.performInitialCentering() } @@ -99,39 +119,56 @@ struct TracePathMapView: View { // MARK: - Map Content private var mapContent: some View { - TracePathMKMapView( - repeaters: mapViewModel.repeatersWithLocation, - lineOverlays: mapViewModel.lineOverlays, - badgeAnnotations: mapViewModel.badgeAnnotations, - mapType: mapViewModel.mapType, - showLabels: mapViewModel.showLabels, + MC1MapView( + points: mapViewModel.mapPoints, + lines: mapViewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: mapViewModel.isNorthLocked, cameraRegion: $mapViewModel.cameraRegion, cameraRegionVersion: mapViewModel.cameraRegionVersion, - pathState: { mapViewModel.pathState }, - onRepeaterTap: { repeater in - let result = mapViewModel.handleRepeaterTap(repeater) - if result == .rejectedMiddleHop { - rejectedTapHaptic += 1 - } else { - pinTapHaptic += 1 + cameraBottomSheetFraction: 0, + onPointTap: { point, _ in + if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + let result = mapViewModel.handleRepeaterTap(repeater) + if result == .rejectedMiddleHop { + rejectedTapHaptic += 1 + } else { + pinTapHaptic += 1 + } } - } + }, + onMapTap: nil, + onCameraRegionChange: { region in + mapViewModel.cameraRegion = region + }, ) .ignoresSafeArea() } - // MARK: - Results Banner +} + +// MARK: - Results Banner - private func resultsBanner(result: TraceResult) -> some View { +private struct TracePathResultsBanner: View { + let result: TraceResult + let totalPathDistance: Double? + + var body: some View { VStack { HStack { let hopCount = result.hops.count - 2 Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) - if let distance = traceViewModel.totalPathDistance { + if let distance = totalPathDistance { Text("•") - let miles = distance / 1609.34 - Text("\(miles, format: .number.precision(.fractionLength(1))) mi") + Text(Measurement(value: distance, unit: UnitLength.meters), + format: .measurement(width: .abbreviated, usage: .road)) } } .font(.subheadline.weight(.medium)) @@ -141,14 +178,16 @@ struct TracePathMapView: View { Spacer() } - .padding(.top, 8) + .safeAreaPadding(.top) .transition(.move(edge: .top).combined(with: .opacity)) .animation(.spring(response: 0.3), value: result.id) } +} - // MARK: - Empty State +// MARK: - Empty State - private var emptyState: some View { +private struct TracePathEmptyState: View { + var body: some View { VStack { Spacer() ContentUnavailableView( @@ -156,140 +195,9 @@ struct TracePathMapView: View { systemImage: "map", description: Text(L10n.Contacts.Contacts.Trace.Map.Empty.description) ) - Spacer() - } - .background(.regularMaterial) - } - - // MARK: - Floating Buttons - - private var floatingButtons: some View { - VStack { - Spacer() - - LiquidGlassContainer(spacing: 12) { - HStack(spacing: 12) { - if mapViewModel.hasPath { - // Clear button - Button { - showingClearConfirmation = true - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.clear) - } - .liquidGlassButtonStyle() - .liquidGlassID("clear", in: buttonNamespace) - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - mapViewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - - // Run Trace button - Button { - Task { - await mapViewModel.runTrace() - } - } label: { - if mapViewModel.isRunning { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } else { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - } - } - .liquidGlassProminentButtonStyle() - .liquidGlassID("trace", in: buttonNamespace) - .disabled(!mapViewModel.canRunTrace) - - // View Results button - if let result = mapViewModel.result, result.success { - Button { - presentedResult = result - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.viewResults) - } - .liquidGlassButtonStyle() - .liquidGlassID("viewResults", in: buttonNamespace) - } - } - } - } - .animation(.spring(response: 0.3), value: mapViewModel.hasPath) - .animation(.spring(response: 0.3), value: mapViewModel.result?.id) - .padding(.bottom, 24) - } - } - - // MARK: - Map Toolbar - - private var mapToolbar: some View { - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - if let location = appState.bestAvailableLocation { - mapViewModel.cameraRegion = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - mapViewModel.cameraRegionVersion += 1 - } else { - appState.locationService.requestLocation() - } - }, - showingLayersMenu: $mapViewModel.showingLayersMenu - ) { - // Labels toggle - Button { - mapViewModel.showLabels.toggle() - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(mapViewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(mapViewModel.showLabels ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels) - - // Center on path - if mapViewModel.hasPath { - Button { - mapViewModel.centerOnPath() - } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - } - } - } - } - .overlay(alignment: .bottomTrailing) { - if mapViewModel.showingLayersMenu { - LayersMenu( - selection: $mapViewModel.mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) - } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 16)) + .padding() } - .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) } } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 31d1d5ce4..43bb1b6f4 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -14,32 +14,27 @@ final class TracePathMapViewModel { var cameraRegion: MKCoordinateRegion? /// Incremented when code intentionally moves the camera (not from user gesture sync) - var cameraRegionVersion = 0 - var mapStyleSelection: MapStyleSelection = .standard - var showLabels: Bool = true + private(set) var cameraRegionVersion = 0 + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + var isNorthLocked = false var showingLayersMenu: Bool = false /// Tracks whether initial centering on repeaters has been performed private(set) var hasInitiallyCenteredOnRepeaters = false - /// MKMapType for UIKit map view - var mapType: MKMapType { - switch mapStyleSelection { - case .standard: .standard - case .satellite: .satellite - case .hybrid: .hybrid - } - } - // MARK: - Path Overlays - private(set) var lineOverlays: [PathLineOverlay] = [] - private(set) var badgeAnnotations: [StatsBadgeAnnotation] = [] + private(set) var mapLines: [MapLine] = [] + private(set) var badgePoints: [MapPoint] = [] + private(set) var mapPoints: [MapPoint] = [] // MARK: - Dependencies private weak var traceViewModel: TracePathViewModel? private var userLocation: CLLocation? + private var lastRebuildLocation: CLLocation? // MARK: - Path State @@ -50,33 +45,8 @@ final class TracePathMapViewModel { } /// Pre-computed path membership for all repeaters, keyed by repeater ID. - /// Iterates the path once (O(M) resolutions) then does O(N) dictionary lookups, - /// instead of O(N × M × N) per-repeater closure calls. - var pathState: [UUID: RepeaterPathInfo] { - let repeaters = repeatersWithLocation - - // Build path lookup: resolve each hop to a repeater UUID - var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] - if let path = traceViewModel?.outboundPath { - for (index, hop) in path.enumerated() { - if let repeater = findRepeater(for: hop) { - pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) - } - } - } - - // Build state for all repeaters with O(1) lookups - var state: [UUID: RepeaterPathInfo] = [:] - state.reserveCapacity(repeaters.count) - for repeater in repeaters { - if let info = pathLookup[repeater.id] { - state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) - } else { - state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - } - } - return state - } + /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. + private(set) var pathState: [UUID: RepeaterPathInfo] = [:] // MARK: - Computed Properties @@ -119,32 +89,67 @@ final class TracePathMapViewModel { func updateUserLocation(_ location: CLLocation?) { self.userLocation = location + + // Only rebuild if the path is non-empty and user moved meaningfully + guard traceViewModel?.outboundPath.isEmpty == false else { return } + if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } + lastRebuildLocation = location rebuildOverlays() } - // MARK: - Path Building + // MARK: - Path State Rebuild - /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. - private func findRepeater(for hop: PathHop) -> ContactDTO? { - RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) - } + /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. + func rebuildPathState() { + let repeaters = repeatersWithLocation - /// Whether a hop matches a specific repeater. - private func hopMatches(_ hop: PathHop, repeater: ContactDTO) -> Bool { - findRepeater(for: hop)?.publicKey == repeater.publicKey + var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] + if let path = traceViewModel?.outboundPath { + for (index, hop) in path.enumerated() { + if let repeater = findRepeater(for: hop) { + pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) + } + } + } + + var state: [UUID: RepeaterPathInfo] = [:] + state.reserveCapacity(repeaters.count) + for repeater in repeaters { + if let info = pathLookup[repeater.id] { + state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) + } else { + state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) + } + } + pathState = state + rebuildMapPoints(repeaters: repeaters) } - /// Check if a repeater is currently in the path - func isRepeaterInPath(_ repeater: ContactDTO) -> Bool { - guard let path = traceViewModel?.outboundPath else { return false } - return path.contains { hopMatches($0, repeater: repeater) } + private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { + let nodes = repeaters ?? repeatersWithLocation + var points: [MapPoint] = [] + for repeater in nodes { + let info = pathState[repeater.id] + let inPath = info?.inPath ?? false + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: inPath ? .repeaterRingWhite : .repeater, + label: showLabels ? repeater.displayName : nil, + isClusterable: false, + hopIndex: info?.hopIndex, + badgeText: nil + )) + } + points.append(contentsOf: badgePoints) + mapPoints = points } - /// Check if repeater is the last hop (can be removed) - func isLastHop(_ repeater: ContactDTO) -> Bool { - guard let path = traceViewModel?.outboundPath, - let lastHop = path.last else { return false } - return hopMatches(lastHop, repeater: repeater) + // MARK: - Path Building + + /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. + private func findRepeater(for hop: PathHop) -> ContactDTO? { + RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) } enum RepeaterTapResult { @@ -159,19 +164,17 @@ final class TracePathMapViewModel { func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { guard let traceViewModel else { return .ignored } + let info = pathState[repeater.id] let result: RepeaterTapResult - if isLastHop(repeater) { - // Remove last hop + if info?.isLastHop == true { if let lastIndex = traceViewModel.outboundPath.indices.last { traceViewModel.removeRepeater(at: lastIndex) } result = .removed - } else if !isRepeaterInPath(repeater) { - // Add to path + } else if info?.inPath != true { traceViewModel.addNode(repeater) result = .added } else { - // Tapping middle hop - provide feedback that this action is not allowed result = .rejectedMiddleHop } @@ -183,6 +186,7 @@ final class TracePathMapViewModel { func clearPath() { traceViewModel?.clearPath() clearOverlays() + rebuildPathState() } // MARK: - Trace Execution @@ -198,101 +202,120 @@ final class TracePathMapViewModel { } func generatePathName() -> String { - traceViewModel?.generatePathName() ?? "Path" + traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName } // MARK: - Overlay Management - /// Rebuild line overlays and badge annotations based on current path + /// Rebuild map lines based on current path func rebuildOverlays() { clearOverlays() + rebuildPathState() guard let traceViewModel, !traceViewModel.outboundPath.isEmpty else { return } - // Start from user location or default var previousCoordinate: CLLocationCoordinate2D? if let userLocation { previousCoordinate = userLocation.coordinate } - // Build overlays for each hop for (index, hop) in traceViewModel.outboundPath.enumerated() { - // Find repeater location guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { - logger.warning("Hop \(index) has no location data, skipping line segment") - continue - } + repeater.hasLocation else { continue } let hopCoordinate = CLLocationCoordinate2D( latitude: repeater.latitude, longitude: repeater.longitude ) - // Validate coordinate - guard CLLocationCoordinate2DIsValid(hopCoordinate) else { - logger.warning("Invalid coordinate for hop \(index): (\(repeater.latitude), \(repeater.longitude))") - continue - } + guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } - // Create line from previous point if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { - let overlay = PathLineOverlay.line( - from: prevCoord, - to: hopCoordinate, - segmentIndex: index - ) - lineOverlays.append(overlay) + mapLines.append(MapLine( + id: "trace-\(index)", + coordinates: [prevCoord, hopCoordinate], + style: .traceUntraced, + opacity: 1.0, + pathIndex: index + )) } previousCoordinate = hopCoordinate } - - logger.debug("Rebuilt \(self.lineOverlays.count) line overlays") } - /// Update overlays with trace results (creates new overlays since they're immutable) + /// Update lines with trace results and add badge points at segment midpoints func updateOverlaysWithResults() { guard let result = traceViewModel?.result, result.success else { return } - // Clear existing badges - badgeAnnotations.removeAll() + badgePoints.removeAll() - // Create new overlays with signal quality (immutable pattern) - var updatedOverlays: [PathLineOverlay] = [] - for (index, overlay) in lineOverlays.enumerated() { - // Find corresponding hop SNR (index + 1 because 0 is start node) - let hopIndex = index + 1 + var updatedLines: [MapLine] = [] + for line in mapLines { + guard let pathIndex = line.pathIndex else { + updatedLines.append(line) + continue + } + let hopIndex = pathIndex + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let quality = SNRQuality(snr: hop.snr) - - // Create new overlay with signal quality - let updatedOverlay = overlay.withSignalQuality(quality, snr: hop.snr) - updatedOverlays.append(updatedOverlay) - - // Add badge annotation at midpoint - let badge = StatsBadgeAnnotation( - coordinate: updatedOverlay.midpoint, - distanceMeters: updatedOverlay.distanceMeters, - snr: hop.snr, - segmentIndex: index - ) - badgeAnnotations.append(badge) + let style = lineStyle(for: hop.snr) + + updatedLines.append(MapLine( + id: line.id, + coordinates: line.coordinates, + style: style, + opacity: 1.0, + pathIndex: pathIndex + )) + + // Badge at midpoint + if line.coordinates.count >= 2 { + let mid = CLLocationCoordinate2D( + latitude: (line.coordinates[0].latitude + line.coordinates[1].latitude) / 2, + longitude: (line.coordinates[0].longitude + line.coordinates[1].longitude) / 2 + ) + let distance = CLLocation(latitude: line.coordinates[0].latitude, longitude: line.coordinates[0].longitude) + .distance(from: CLLocation(latitude: line.coordinates[1].latitude, longitude: line.coordinates[1].longitude)) + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) + + badgePoints.append(MapPoint( + id: UUID(hopIndex: hopIndex), + coordinate: mid, + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: "\(distFormatted) · \(snrFormatted) dB" + )) + } } else { - updatedOverlays.append(overlay) + updatedLines.append(line) } } - lineOverlays = updatedOverlays - logger.debug("Updated overlays with results, added \(self.badgeAnnotations.count) badges") + mapLines = updatedLines + rebuildMapPoints() + } + + // MARK: - Signal Quality + + private func lineStyle(for snr: Double?) -> MapLine.LineStyle { + switch SNRQuality(snr: snr) { + case .excellent, .good: .traceGood + case .fair: .traceMedium + case .poor: .traceWeak + case .unknown: .traceUntraced + } } /// Clear all overlays func clearOverlays() { - lineOverlays.removeAll() - badgeAnnotations.removeAll() + mapLines.removeAll() + badgePoints.removeAll() } // MARK: - Camera @@ -305,41 +328,11 @@ final class TracePathMapViewModel { coordinates.append(userLocation.coordinate) } - for overlay in lineOverlays { - let points = overlay.points() - for i in 0.. Void)? - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private let selectionRing = UIView() - private var numberBadge: UILabel? - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringSize: CGFloat = 44 - - // Selection ring (behind circle) - selectionRing.translatesAutoresizingMaskIntoConstraints = false - selectionRing.backgroundColor = .clear - selectionRing.layer.borderColor = UIColor.white.cgColor - selectionRing.layer.borderWidth = 2 - selectionRing.layer.cornerRadius = ringSize / 2 - selectionRing.isHidden = true - addSubview(selectionRing) - - // Circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = circleSize / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - iconImageView.image = UIImage(systemName: "antenna.radiowaves.left.and.right") - circleView.addSubview(iconImageView) - - // Triangle - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - triangleImageView.tintColor = .systemCyan - addSubview(triangleImageView) - - // All constraints use constant values — activate once - NSLayoutConstraint.activate([ - // Selection ring - selectionRing.widthAnchor.constraint(equalToConstant: ringSize), - selectionRing.heightAnchor.constraint(equalToConstant: ringSize), - selectionRing.centerXAnchor.constraint(equalTo: centerXAnchor), - selectionRing.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - // Circle - circleView.widthAnchor.constraint(equalToConstant: circleSize), - circleView.heightAnchor.constraint(equalToConstant: circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor, constant: 4), - - // Icon - iconImageView.widthAnchor.constraint(equalToConstant: iconSize), - iconImageView.heightAnchor.constraint(equalToConstant: iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - // Triangle - triangleImageView.widthAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.heightAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - let totalHeight = circleSize + triangleSize + 4 - frame = CGRect(x: 0, y: 0, width: ringSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - - canShowCallout = false - - // Tap gesture fires immediately, bypassing MapKit's ~300ms selection delay - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGesture) - } - - @objc private func handleTap() { - onTap?() - } - - // MARK: - Configuration - - func configure( - for repeater: ContactDTO, - inPath: Bool, - hopIndex: Int?, - isLastHop: Bool, - showLabel: Bool - ) { - // Clustering: in-path pins are always visible, others cluster - if inPath { - clusteringIdentifier = nil - displayPriority = .required - } else { - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } - - // Update selection ring - selectionRing.isHidden = !inPath - - // Update number badge - if let index = hopIndex { - showNumberBadge(index) - } else { - hideNumberBadge() - } - - // Update name label - if showLabel { - showNameLabel(repeater.displayName) - } else { - hideNameLabel() - } - - // Accessibility - isAccessibilityElement = true - if inPath { - if isLastHop { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.inPathLabel(repeater.displayName, hopIndex ?? 0) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.removableHint - accessibilityTraits = [.button, .selected] - } else { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.inPathLabel(repeater.displayName, hopIndex ?? 0) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.notRemovableHint - accessibilityTraits = [.button, .selected, .notEnabled] - } - } else { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.availableLabel(repeater.displayName) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.addHint - accessibilityTraits = .button - } - } - - // MARK: - Number Badge - - private func showNumberBadge(_ number: Int) { - if numberBadge == nil { - let badge = UILabel() - badge.translatesAutoresizingMaskIntoConstraints = false - // Dynamic Type with caption2 style - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .bold) - badge.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - badge.adjustsFontForContentSizeCategory = true - badge.textColor = .black - badge.textAlignment = .center - badge.backgroundColor = .white - badge.layer.cornerRadius = 9 - badge.layer.masksToBounds = true - addSubview(badge) - - NSLayoutConstraint.activate([ - badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.heightAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.trailingAnchor.constraint(equalTo: circleView.trailingAnchor, constant: 4), - badge.topAnchor.constraint(equalTo: circleView.topAnchor, constant: -4) - ]) - - numberBadge = badge - } - - numberBadge?.text = "\(number)" - numberBadge?.isHidden = false - } - - private func hideNumberBadge() { - numberBadge?.isHidden = true - } - - // MARK: - Name Label - - private func showNameLabel(_ name: String) { - if nameLabelContainer == nil { - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.layer.masksToBounds = true - addSubview(blur) - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - // Dynamic Type with caption2 style - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - blur.contentView.addSubview(label) - - // Internal + position constraints (all set once at creation time) - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: blur.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.trailingAnchor, constant: -8), - blur.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - blur.bottomAnchor.constraint(equalTo: circleView.topAnchor, constant: -4) - ]) - - nameLabelContainer = blur - nameLabel = label - } - - nameLabel?.text = name - nameLabelContainer?.isHidden = false - } - - private func hideNameLabel() { - nameLabelContainer?.isHidden = true - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onTap = nil - selectionRing.isHidden = true - hideNumberBadge() - hideNameLabel() - accessibilityLabel = nil - accessibilityHint = nil - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } -} diff --git a/MC1/Views/Contacts/TraceResultHopRow.swift b/MC1/Views/Contacts/TraceResultHopRow.swift new file mode 100644 index 000000000..4667bd4f8 --- /dev/null +++ b/MC1/Views/Contacts/TraceResultHopRow.swift @@ -0,0 +1,95 @@ +import SwiftUI +import MC1Services + +// MARK: - Result Hop Row + +/// Row for displaying a hop in the trace results +struct TraceResultHopRow: View { + let hop: TraceHop + let hopIndex: Int + var batchStats: (avg: Double, min: Double, max: Double)? + var latestSNR: Double? + var isBatchInProgress: Bool = false + + /// SNR value to use for signal bars (latest during progress, average when complete) + private var displaySNR: Double { + if isBatchInProgress { + return latestSNR ?? hop.snr + } else if let stats = batchStats { + return stats.avg + } else { + return hop.snr + } + } + + private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } + + private var signalLevel: Double { snrQuality.barLevel } + + private var signalColor: Color { snrQuality.color } + + var body: some View { + HStack { + VStack(alignment: .leading) { + // Node identifier + if hop.isStartNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + Text(L10n.Contacts.Contacts.Results.Hop.started) + .font(.caption) + .foregroundStyle(.secondary) + } else if hop.isEndNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.Hop.received) + .font(.caption) + .foregroundStyle(.secondary) + } else if let hashDisplay = hop.hashDisplayString { + HStack { + Text(hashDisplay) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + if let name = hop.resolvedName { + Text(name) + } + } + Text(L10n.Contacts.Contacts.Results.Hop.repeated) + .font(.caption) + .foregroundStyle(.secondary) + } + + // SNR display - batch mode shows avg with range, single shows plain SNR + if !hop.isStartNode { + if let stats = batchStats { + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + } else { + Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + + // Signal strength indicator (not for start node - it didn't receive) + if !hop.isStartNode { + Image(systemName: "cellularbars", variableValue: signalLevel) + .foregroundStyle(signalColor) + .font(.title2) + } + } + .padding(.vertical, 4) + } +} diff --git a/MC1/Views/Contacts/TraceResultsSectionView.swift b/MC1/Views/Contacts/TraceResultsSectionView.swift new file mode 100644 index 000000000..15e0f805e --- /dev/null +++ b/MC1/Views/Contacts/TraceResultsSectionView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import MC1Services + +/// Section displaying trace result hops, RTT info, distance, and save action +struct TraceResultsSectionView: View { + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + @Binding var showingDistanceInfo: Bool + + var body: some View { + Section { + if result.success { + ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in + TraceResultHopRow( + hop: hop, + hopIndex: index, + batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, + latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, + isBatchInProgress: viewModel.isBatchInProgress + ) + } + + // Batch status row (progress or completion) + if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { + HStack { + if viewModel.isBatchComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel( + viewModel.isBatchComplete + ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) + : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) + ) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } + + // Duration row with batch or single display + if viewModel.batchEnabled && viewModel.successCount > 0 { + BatchRTTRow(viewModel: viewModel) + } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { + ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) + } else { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.durationMs) ms") + .font(.body.monospacedDigit()) + } + } + + // Total distance row + TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) + + // Save path action (only for successful traces when not running a saved path) + if !viewModel.isRunningSavedPath { + SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) + } + } else if let error = result.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + } +} diff --git a/MC1/Views/Contacts/TraceResultsSheet.swift b/MC1/Views/Contacts/TraceResultsSheet.swift index 6e9bcc53c..bb2bba4d7 100644 --- a/MC1/Views/Contacts/TraceResultsSheet.swift +++ b/MC1/Views/Contacts/TraceResultsSheet.swift @@ -8,8 +8,6 @@ struct TraceResultsSheet: View { @Environment(\.dismiss) private var dismiss // Save dialog state - @State private var showingSaveDialog = false - @State private var savePathName = "" @State private var saveHapticTrigger = 0 @State private var copyHapticTrigger = 0 @State private var showingDistanceInfo = false @@ -17,7 +15,12 @@ struct TraceResultsSheet: View { var body: some View { NavigationStack { List { - resultsSection + TraceResultsSectionView( + result: result, + viewModel: viewModel, + saveHapticTrigger: $saveHapticTrigger, + showingDistanceInfo: $showingDistanceInfo + ) roundTripPathSection } .navigationTitle(L10n.Contacts.Contacts.Results.title) @@ -56,389 +59,4 @@ struct TraceResultsSheet: View { Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } } - - // MARK: - Results Section - - private var resultsSection: some View { - Section { - if result.success { - ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in - TraceResultHopRow( - hop: hop, - hopIndex: index, - batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, - latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, - isBatchInProgress: viewModel.isBatchInProgress - ) - } - - // Batch status row (progress or completion) - if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { - HStack { - if viewModel.isBatchComplete { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) - .foregroundStyle(.secondary) - } else { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel( - viewModel.isBatchComplete - ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) - : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) - ) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - - // Duration row with batch or single display - if viewModel.batchEnabled && viewModel.successCount > 0 { - batchRTTRow - } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { - comparisonRow(currentMs: result.durationMs, previousRun: previous) - } else { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.durationMs) ms") - .font(.body.monospacedDigit()) - } - } - - // Total distance row - totalDistanceRow - - // Save path action (only for successful traces when not running a saved path) - if !viewModel.isRunningSavedPath { - savePathRow - } - } else if let error = result.errorMessage { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } - } - } - - // MARK: - Save Path Row - - @ViewBuilder - private var savePathRow: some View { - if showingSaveDialog { - VStack(alignment: .leading, spacing: 8) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) - .textFieldStyle(.roundedBorder) - - HStack { - Button(L10n.Contacts.Contacts.Common.cancel) { - showingSaveDialog = false - savePathName = "" - } - .buttonStyle(.bordered) - - Spacer() - - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await viewModel.savePath(name: savePathName) - if success { - saveHapticTrigger += 1 - } - showingSaveDialog = false - savePathName = "" - } - } - .buttonStyle(.borderedProminent) - .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) - } - } - .padding(.vertical, 4) - } else { - Button { - savePathName = viewModel.generatePathName() - showingSaveDialog = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } - } - .foregroundStyle(.primary) - .disabled(!viewModel.canSavePath) - } - } - - // MARK: - Comparison Row - - @ViewBuilder - private func comparisonRow(currentMs: Int, previousRun: TracePathRunDTO) -> some View { - let diff = currentMs - previousRun.roundTripMs - let percentChange = previousRun.roundTripMs > 0 - ? Double(diff) / Double(previousRun.roundTripMs) * 100 - : 0 - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(currentMs) ms") - .font(.body.monospacedDigit()) - - // Change indicator - if diff != 0 { - Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") - .foregroundStyle(diff > 0 ? .red : .green) - Text(abs(percentChange), format: .number.precision(.fractionLength(0))) - .font(.caption.monospacedDigit()) - + Text("%") - .font(.caption) - } - } - - Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) - .font(.caption) - .foregroundStyle(.secondary) - } - - // Sparkline with history link - if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { - HStack { - MiniSparkline(values: savedPath.recentRTTs) - .frame(height: 20) - - Spacer() - - NavigationLink { - SavedPathDetailView(savedPath: savedPath) - } label: { - Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) - .font(.caption) - } - } - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - } - - // MARK: - Batch RTT Row - - @ViewBuilder - private var batchRTTRow: some View { - if let avg = viewModel.averageRTT, - let min = viewModel.minRTT, - let max = viewModel.maxRTT { - HStack { - Text(L10n.Contacts.Contacts.Results.avgRoundTrip) - .foregroundStyle(.secondary) - Spacer() - VStack(alignment: .trailing) { - Text("\(avg) ms") - .font(.body.monospacedDigit()) - Text("(\(min) – \(max))") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) - } - } - - // MARK: - Total Distance Row - - private func formatDistance(_ meters: Double) -> String { - let measurement = Measurement(value: meters, unit: UnitLength.meters) - return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) - } - - @ViewBuilder - private var totalDistanceRow: some View { - HStack { - Text(L10n.Contacts.Contacts.Results.totalDistance) - .foregroundStyle(.secondary) - Spacer() - - if let distance = viewModel.totalPathDistance { - HStack { - Text(formatDistance(distance)) - .font(.body.monospacedDigit()) - if viewModel.isDistanceUsingFallback { - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) - } - } - } else { - HStack { - Text(L10n.Contacts.Contacts.Results.unavailable) - .foregroundStyle(.secondary) - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) - } - } - } - .sheet(isPresented: $showingDistanceInfo) { - distanceInfoSheet - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - } - - private var distanceInfoSheet: some View { - NavigationStack { - List { - if viewModel.isDistanceUsingFallback { - Section { - Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) - } header: { - Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") - } - Section { - Text(L10n.Contacts.Contacts.Results.fullPathTip) - } header: { - Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") - } - } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { - Section { - Text(L10n.Contacts.Contacts.Results.needsRepeaters) - } - } else if viewModel.repeatersWithoutLocation.isEmpty { - Section { - Text(L10n.Contacts.Contacts.Results.distanceError) - } - } else { - Section { - Text(L10n.Contacts.Contacts.Results.missingLocations) - } - Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { - ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in - Text(name) - } - } - } - } - .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Common.done) { - showingDistanceInfo = false - } - } - } - } - } -} - -// MARK: - Result Hop Row - -/// Row for displaying a hop in the trace results -struct TraceResultHopRow: View { - let hop: TraceHop - let hopIndex: Int - var batchStats: (avg: Double, min: Double, max: Double)? - var latestSNR: Double? - var isBatchInProgress: Bool = false - - /// SNR value to use for signal bars (latest during progress, average when complete) - private var displaySNR: Double { - if isBatchInProgress { - return latestSNR ?? hop.snr - } else if let stats = batchStats { - return stats.avg - } else { - return hop.snr - } - } - - private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } - - private var signalLevel: Double { snrQuality.barLevel } - - private var signalColor: Color { snrQuality.color } - - var body: some View { - HStack { - VStack(alignment: .leading) { - // Node identifier - if hop.isStartNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - Text(L10n.Contacts.Contacts.Results.Hop.started) - .font(.caption) - .foregroundStyle(.secondary) - } else if hop.isEndNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.Hop.received) - .font(.caption) - .foregroundStyle(.secondary) - } else if let hashDisplay = hop.hashDisplayString { - HStack { - Text(hashDisplay) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - if let name = hop.resolvedName { - Text(name) - } - } - Text(L10n.Contacts.Contacts.Results.Hop.repeated) - .font(.caption) - .foregroundStyle(.secondary) - } - - // SNR display - batch mode shows avg with range, single shows plain SNR - if !hop.isStartNode { - if let stats = batchStats { - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) - Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - } else { - Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Spacer() - - // Signal strength indicator (not for start node - it didn't receive) - if !hop.isStartNode { - Image(systemName: "cellularbars", variableValue: signalLevel) - .foregroundStyle(signalColor) - .font(.title2) - } - } - .padding(.vertical, 4) - } } diff --git a/MC1/Views/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/LineOfSight/AddRepeaterRowView.swift new file mode 100644 index 000000000..3bdcbabb8 --- /dev/null +++ b/MC1/Views/LineOfSight/AddRepeaterRowView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct AddRepeaterRowView: View { + let onAdd: () -> Void + + var body: some View { + Button { + onAdd() + } label: { + HStack { + // Purple R marker (matches full row) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + Text(L10n.Tools.Tools.LineOfSight.addRepeater) + .font(.subheadline) + + Spacer() + + Image(systemName: "plus.circle.fill") + .foregroundStyle(.purple) + } + .padding(.vertical, 8) + } + .liquidGlassSecondaryButtonStyle() + } +} diff --git a/MC1/Views/LineOfSight/AnalysisErrorView.swift b/MC1/Views/LineOfSight/AnalysisErrorView.swift new file mode 100644 index 000000000..129562b9c --- /dev/null +++ b/MC1/Views/LineOfSight/AnalysisErrorView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct AnalysisErrorView: View { + let message: String + let hasRepeater: Bool + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + + Text(L10n.Tools.Tools.LineOfSight.analysisFailed) + .font(.headline) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button(L10n.Tools.Tools.LineOfSight.retry) { + onRetry() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding() + } +} diff --git a/MC1/Views/LineOfSight/HeightEditorGrid.swift b/MC1/Views/LineOfSight/HeightEditorGrid.swift new file mode 100644 index 000000000..029c3b302 --- /dev/null +++ b/MC1/Views/LineOfSight/HeightEditorGrid.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct HeightEditorGrid: View { + let groundElevation: Double? + @Binding var additionalHeight: Int + let range: ClosedRange + var onHeightChanged: (() -> Void)? + + var body: some View { + Grid(alignment: .leading, verticalSpacing: 8) { + if let groundElevation { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + } else { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + ProgressView() + .controlSize(.mini) + } + } + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.additionalHeight) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Stepper(value: $additionalHeight, in: range) { + Text(Measurement(value: Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + .controlSize(.small) + .onChange(of: additionalHeight) { + onHeightChanged?() + } + } + + if let groundElevation { + Divider() + .gridCellColumns(2) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.totalHeight) + .font(.caption) + .bold() + + Spacer() + + Text(Measurement(value: groundElevation + Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + .bold() + } + } + } + } +} diff --git a/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift b/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift new file mode 100644 index 000000000..e2e1076f3 --- /dev/null +++ b/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift @@ -0,0 +1,5 @@ +enum LineOfSightLayoutMode { + case map + case panel + case mapWithSheet +} diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index a33bfeb16..856a8ca20 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -6,48 +6,6 @@ import SwiftUI private let analysisSheetDetentCollapsed: PresentationDetent = .fraction(0.25) private let analysisSheetDetentHalf: PresentationDetent = .fraction(0.5) private let analysisSheetDetentExpanded: PresentationDetent = .large -private let analysisSheetBottomInsetPadding: CGFloat = 16 - -enum LineOfSightLayoutMode { - case map - case panel - case mapWithSheet -} - -// MARK: - PointID Identifiable Conformance - -extension PointID: Identifiable { - var id: Self { self } -} - -// MARK: - Map Style Selection - -/// Map style selection for Picker, maps to MKMapType -private enum LOSMapStyleSelection: String, CaseIterable, Hashable { - case standard - case terrain - - var label: String { - switch self { - case .standard: L10n.Tools.Tools.LineOfSight.MapStyle.standard - case .terrain: L10n.Tools.Tools.LineOfSight.MapStyle.terrain - } - } - - var icon: String { - switch self { - case .standard: "map" - case .terrain: "mountain.2" - } - } - - var mkMapType: MKMapType { - switch self { - case .standard: .standard - case .terrain: .hybridFlyover - } - } -} // MARK: - Line of Sight View @@ -59,19 +17,16 @@ struct LineOfSightView: View { @State private var viewModel: LineOfSightViewModel @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed @State private var enableHalfDetent = false - @State private var screenHeight: CGFloat = 0 - @State private var baseScreenHeight: CGFloat = 0 @State private var showAnalysisSheet: Bool @State private var editingPoint: PointID? @State private var isDropPinMode = false - @State private var mapStyleSelection: LOSMapStyleSelection = .terrain + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .topo + @AppStorage("mapShowLabels") private var showLabels = true @State private var sheetBottomInset: CGFloat = 220 @State private var isResultsExpanded = false @State private var isRFSettingsExpanded = false @State private var showingMapStyleMenu = false - @State private var showLabels = true @State private var copyHapticTrigger = 0 - @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 private let layoutMode: LineOfSightLayoutMode @@ -141,29 +96,10 @@ struct LineOfSightView: View { showLabels: $showLabels, isDropPinMode: $isDropPinMode, mapOverlayBottomPadding: mapOverlayBottomPadding, + cameraBottomSheetFraction: showSheet ? 0.25 : 0, onRepeaterTap: { handleRepeaterTap($0) }, onMapTap: { handleMapTap(at: $0) } ) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - if height > 0 { - screenHeight = height - - if baseScreenHeight == 0 || height > baseScreenHeight || height < baseScreenHeight * 0.7 { - baseScreenHeight = height - } - } - - if showSheet, showAnalysisSheet { - updateSheetBottomInset() - } - } - .onChange(of: sheetDetent) { _, _ in - if showSheet, showAnalysisSheet { - updateSheetBottomInset() - } - } .onChange(of: viewModel.pointA) { oldValue, newValue in if oldValue == nil, newValue != nil, viewModel.pointB != nil { if showSheet { @@ -231,9 +167,13 @@ struct LineOfSightView: View { .task { appState.locationService.requestPermissionIfNeeded() viewModel.configure(appState: appState) + viewModel.showLabels = showLabels await viewModel.loadRepeaters() viewModel.centerOnAllRepeaters() } + .onChange(of: showLabels) { _, newValue in + viewModel.showLabels = newValue + } if showSheet { base @@ -255,6 +195,13 @@ struct LineOfSightView: View { } .sheet(isPresented: $showAnalysisSheet) { analysisSheet + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height - proxy.safeAreaInsets.bottom + 15 + } action: { inset in + if sheetDetent == analysisSheetDetentCollapsed { + sheetBottomInset = max(0, inset) + } + } .presentationDetents(availableSheetDetents, selection: $sheetDetent) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled) @@ -275,17 +222,14 @@ struct LineOfSightView: View { showAnalysisSheet = false viewModel.relocatingPoint = nil + // Yield to let showAnalysisSheet = false commit before dismiss fires, + // avoiding a sheet-dismissal animation conflict. Task { @MainActor in await Task.yield() dismiss() } } - private var collapsedSheetFraction: Double { - guard showAnalysisSheet else { return 0 } - return 0.30 - } - // MARK: - Analysis Sheet private var analysisSheet: some View { @@ -294,23 +238,23 @@ struct LineOfSightView: View { analysisSheetContent } .scrollDismissesKeyboard(.immediately) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) } } private var analysisSheetContent: some View { - analysisSheetVStack - .padding() - } - - private var analysisSheetVStack: some View { VStack(alignment: .leading, spacing: 16) { - pointsSummarySection + PointsSummarySectionView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } + ) // Before analysis: show analyze button, then RF settings if viewModel.canAnalyze, !hasAnalysisResult { analyzeButtonSection - rfSettingsSection + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } // After analysis: show button, results, terrain, then RF settings @@ -320,8 +264,12 @@ struct LineOfSightView: View { resultSummarySection(result) if shouldShowExpandedAnalysis { - terrainProfileSection - rfSettingsSection + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } @@ -332,577 +280,42 @@ struct LineOfSightView: View { RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) if shouldShowExpandedAnalysis { - terrainProfileSection - rfSettingsSection - } - } - - if case .error(let message) = viewModel.analysisStatus { - errorSection(message) - } - } - } - - // MARK: - Points Summary Section - - private var pointsSummarySection: some View { - VStack(alignment: .leading, spacing: 12) { - // Header with optional cancel button - HStack { - Text(L10n.Tools.Tools.LineOfSight.points) - .font(.headline) - - Spacer() - - if isRelocating { - Button(L10n.Tools.Tools.LineOfSight.cancel) { - viewModel.relocatingPoint = nil - } - .glassButtonStyle() - .controlSize(.small) - } - } - - // Show relocating message OR point rows - if let relocatingPoint = viewModel.relocatingPoint { - relocatingMessageView(for: relocatingPoint) - } else { - // Point A row - pointRow( - label: "A", - color: .blue, - point: viewModel.pointA, - pointID: .pointA, - onClear: { viewModel.clearPointA() } - ) - - // Repeater row (placeholder or full, positioned between A and B) - // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency - if let repeater = viewModel.repeaterPoint { - repeaterRow - .id("repeater-\(repeater.coordinate.latitude)-\(repeater.coordinate.longitude)") - } else if viewModel.shouldShowRepeaterPlaceholder { - addRepeaterRow - } - - // Point B row - pointRow( - label: "B", - color: .green, - point: viewModel.pointB, - pointID: .pointB, - onClear: { viewModel.clearPointB() } - ) - - if viewModel.pointA == nil || viewModel.pointB == nil { - Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) - .font(.caption) - .foregroundStyle(.secondary) - } - - if viewModel.elevationFetchFailed { - Label( - L10n.Tools.Tools.LineOfSight.elevationUnavailable, - systemImage: "exclamationmark.triangle.fill" + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter ) - .font(.caption) - .foregroundStyle(.orange) - } - } - } - } - - @ViewBuilder - private func relocatingMessageView(for pointID: PointID) -> some View { - let pointName: String = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeater - } - - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) - .font(.subheadline) - .bold() - - Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") - } - - @ViewBuilder - private func pointRow( - label: String, - color: Color, - point: SelectedPoint?, - pointID: PointID, - onClear: @escaping () -> Void - ) -> some View { - let isEditing = editingPoint == pointID - - VStack(alignment: .leading, spacing: 12) { - // Header row (always visible) - HStack { - // Point marker - Circle() - .fill(point != nil ? color : .gray.opacity(0.3)) - .frame(width: 24, height: 24) - .overlay { - Text(label) - .font(.caption) - .bold() - .foregroundStyle(.white) - } - - // Point info - if let point { - VStack(alignment: .leading, spacing: 2) { - Text(point.displayName) - .font(.subheadline) - .lineLimit(1) - - if point.isLoadingElevation { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - Text(L10n.Tools.Tools.LineOfSight.loadingElevation) - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let elevation = point.groundElevation { - Text("\(Int(elevation) + point.additionalHeight)m") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - pointRowButtons( - pointID: pointID, - isEditing: isEditing, - onClear: onClear - ) - } else { - Text(L10n.Tools.Tools.LineOfSight.notSelected) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() - } - } - - // Expanded editor (when editing) - if isEditing, let point { - Divider() - - pointHeightEditor(point: point, pointID: pointID) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) - } - - @ViewBuilder - private func pointRowButtons( - pointID: PointID, - isEditing: Bool, - onClear: @escaping () -> Void - ) -> some View { - let point = pointID == .pointA ? viewModel.pointA : viewModel.pointB - - // Share menu - Menu { - if let coord = point?.coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = pointID == .pointA ? L10n.Tools.Tools.LineOfSight.pointA : L10n.Tools.Tools.LineOfSight.pointB - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - UIPasteboard.general.string = coordText - } - - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == pointID { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = pointID - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) - - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : pointID - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - - // Clear button - Button(action: onClear) { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - } - - @ViewBuilder - private func pointHeightEditor(point: SelectedPoint, pointID: PointID) -> some View { - Grid(alignment: .leading, verticalSpacing: 8) { - // Ground elevation row - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - if let elevation = point.groundElevation { - Text("\(Int(elevation)) m") - .font(.caption) - .monospacedDigit() - } else { - ProgressView() - .controlSize(.mini) - } - } - - // Additional height row - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - Stepper( - value: Binding( - get: { point.additionalHeight }, - set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } - ), - in: 0...200 - ) { - Text("\(point.additionalHeight) m") - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - } - - // Total row - if let elevation = point.groundElevation { - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() - - Spacer() - - Text("\(Int(elevation) + point.additionalHeight) m") - .font(.caption) - .monospacedDigit() - .bold() - } - } - } - } - - // MARK: - Repeater Row - - @ViewBuilder - private var repeaterRow: some View { - let isEditing = editingPoint == .repeater - - VStack(alignment: .leading, spacing: 12) { - // Header row - HStack { - // Repeater marker (purple) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } - - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Tools.Tools.LineOfSight.repeater) - .font(.subheadline) - .lineLimit(1) - - if let elevation = viewModel.repeaterGroundElevation { - let totalHeight = Int(elevation) + (viewModel.repeaterPoint?.additionalHeight ?? 0) - Text("\(totalHeight)m") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - // Share menu - Menu { - if let coord = viewModel.repeaterPoint?.coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = L10n.Tools.Tools.LineOfSight.repeaterLocation - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - UIPasteboard.general.string = coordText - } - - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == .repeater { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = .repeater - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != .repeater) - - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : .repeater - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - - // Clear button - Button { - viewModel.clearRepeater() - } label: { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - } - - // Expanded editor - if isEditing, let repeaterPoint = viewModel.repeaterPoint { - Divider() - repeaterHeightEditor(repeaterPoint: repeaterPoint) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) - } - - @ViewBuilder - private func repeaterHeightEditor(repeaterPoint: RepeaterPoint) -> some View { - Grid(alignment: .leading, verticalSpacing: 8) { - if let groundElevation = viewModel.repeaterGroundElevation { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text("\(Int(groundElevation)) m") - .font(.caption) - .monospacedDigit() + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Stepper( - value: Binding( - get: { repeaterPoint.additionalHeight }, - set: { - viewModel.updateRepeaterHeight(meters: $0) + if case .error(let message) = viewModel.analysisStatus { + AnalysisErrorView( + message: message, + hasRepeater: viewModel.repeaterPoint != nil, + onRetry: { + if viewModel.repeaterPoint != nil { viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() } - ), - in: 0...200 - ) { - Text("\(repeaterPoint.additionalHeight) m") - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - } - - if let groundElevation = viewModel.repeaterGroundElevation { - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() - Spacer() - Text("\(Int(groundElevation) + repeaterPoint.additionalHeight) m") - .font(.caption) - .monospacedDigit() - .bold() - } - } - } - } - - // MARK: - Add Repeater Row (Placeholder) - - /// Placeholder row shown when analysis is marginal/obstructed but no repeater exists yet - private var addRepeaterRow: some View { - Button { - viewModel.addRepeater() - viewModel.analyzeWithRepeater() - } label: { - HStack { - // Purple R marker (matches full row) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) } - - Text(L10n.Tools.Tools.LineOfSight.addRepeater) - .font(.subheadline) - - Spacer() - - Image(systemName: "plus.circle.fill") - .foregroundStyle(.purple) + ) } - .padding(.vertical, 8) } - .glassButtonStyle() + .padding() } // MARK: - Analyze Button Section private var analyzeButtonSection: some View { - Button { - viewModel.shouldAutoZoomOnNextResult = true - - withAnimation { - sheetDetent = analysisSheetDetentExpanded - } - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } label: { - if viewModel.isAnalyzing { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Tools.Tools.LineOfSight.analyzing) - } - .frame(maxWidth: .infinity) - } else { - Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") - .frame(maxWidth: .infinity) + AnalyzeButton( + viewModel: viewModel, + hasAnalysisResult: hasAnalysisResult, + onAnalyze: { + withAnimation { sheetDetent = analysisSheetDetentExpanded } } - } - .glassProminentButtonStyle() - .controlSize(.large) - .disabled(viewModel.isAnalyzing || hasAnalysisResult) + ) } // MARK: - Result Summary Section @@ -912,122 +325,6 @@ struct LineOfSightView: View { ResultsCardView(result: result, isExpanded: $isResultsExpanded) } - // MARK: - Terrain Profile Section - - private var terrainProfileSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(L10n.Tools.Tools.LineOfSight.terrainProfile) - .font(.headline) - - Spacer() - - Label( - L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), - systemImage: "globe" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - - TerrainProfileCanvas( - elevationProfile: viewModel.terrainElevationProfile, - profileSamples: viewModel.profileSamples, - profileSamplesRB: viewModel.profileSamplesRB, - // Show repeater marker for both on-path and off-path - repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, - repeaterHeight: viewModel.repeaterPoint.map { Double($0.additionalHeight) }, - // Only enable drag for on-path repeaters - onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in - viewModel.updateRepeaterPosition(pathFraction: pathFraction) - viewModel.analyzeWithRepeater() - } : nil, - onRepeaterMarkerPosition: { center in - repeaterMarkerCenter = center - }, - // Off-path segment distances for separator and labels - segmentARDistanceMeters: viewModel.segmentARDistanceMeters, - segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters - ) - .overlay { - if showDragHint, let center = repeaterMarkerCenter { - Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) - .font(.caption) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .transition(.opacity.combined(with: .scale)) - .position(x: center.x, y: center.y + 30) - } - } - } - } - - // MARK: - RF Settings Section - - private var rfSettingsSection: some View { - DisclosureGroup(isExpanded: $isRFSettingsExpanded) { - VStack(spacing: 12) { - // Frequency input - extracted to separate view for @FocusState to work in sheet - FrequencyInputRow(viewModel: viewModel) - - Divider() - - // Refraction k-factor picker - HStack { - Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") - .foregroundStyle(.secondary) - Spacer() - Picker("", selection: Binding( - get: { viewModel.refractionK }, - set: { viewModel.refractionK = $0 } - )) { - Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) - } - .pickerStyle(.menu) - } - } - .padding(.top, 8) - } label: { - Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") - .font(.headline) - } - .tint(.primary) - } - - // MARK: - Error Section - - private func errorSection(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - - Text(L10n.Tools.Tools.LineOfSight.analysisFailed) - .font(.headline) - - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button(L10n.Tools.Tools.LineOfSight.retry) { - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding() - } - // MARK: - Computed Properties private var analysisResult: PathAnalysisResult? { @@ -1045,23 +342,6 @@ struct LineOfSightView: View { // MARK: - Helper Methods - private func updateSheetBottomInset() { - let fraction: CGFloat - if sheetDetent == analysisSheetDetentExpanded { - // When fullscreen, map is covered - cap inset at 0.9 to avoid layout issues - fraction = 0.9 - } else if sheetDetent == analysisSheetDetentHalf { - fraction = 0.5 - } else { - fraction = 0.25 - } - - let referenceHeight = baseScreenHeight > 0 ? baseScreenHeight : screenHeight - guard referenceHeight > 0 else { return } - - sheetBottomInset = referenceHeight * fraction + analysisSheetBottomInsetPadding - } - private func handleMapTap(at coordinate: CLLocationCoordinate2D) { // Handle relocation mode if let relocating = viewModel.relocatingPoint { @@ -1098,9 +378,7 @@ struct LineOfSightView: View { switch status { case .result: if showSheet { - withAnimation { - sheetDetent = analysisSheetDetentExpanded - } + sheetDetent = analysisSheetDetentExpanded } case .relayResult: break @@ -1110,7 +388,7 @@ struct LineOfSightView: View { if viewModel.shouldAutoZoomOnNextResult { viewModel.shouldAutoZoomOnNextResult = false - viewModel.zoomToShowBothPoints(bottomInsetFraction: collapsedSheetFraction) + viewModel.zoomToShowBothPoints() } } @@ -1124,29 +402,41 @@ struct LineOfSightView: View { private struct LOSMapCanvasView: View { @Bindable var viewModel: LineOfSightViewModel let appState: AppState - @Binding var mapStyleSelection: LOSMapStyleSelection + @Environment(\.colorScheme) private var colorScheme + @Binding var mapStyleSelection: MapStyleSelection @Binding var showingMapStyleMenu: Bool @Binding var showLabels: Bool @Binding var isDropPinMode: Bool let mapOverlayBottomPadding: CGFloat + let cameraBottomSheetFraction: CGFloat? let onRepeaterTap: (ContactDTO) -> Void let onMapTap: (CLLocationCoordinate2D) -> Void var body: some View { ZStack { - LOSMKMapView( - repeaters: viewModel.repeatersWithLocation, - pointA: viewModel.pointA, - pointB: viewModel.pointB, - repeaterTarget: viewModel.repeaterPoint, - relocatingPoint: viewModel.relocatingPoint, - mapType: mapStyleSelection.mkMapType, + MC1MapView( + points: viewModel.mapPoints, + lines: viewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, cameraRegion: $viewModel.cameraRegion, cameraRegionVersion: viewModel.cameraRegionVersion, - selectionState: { [viewModel] in viewModel.selectionState }, - onRepeaterTap: onRepeaterTap, - onMapTap: onMapTap + cameraBottomSheetFraction: cameraBottomSheetFraction, + onPointTap: { point, _ in + if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + onRepeaterTap(repeater) + } + }, + onMapTap: onMapTap, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + }, ) .ignoresSafeArea() @@ -1158,39 +448,29 @@ private struct LOSMapCanvasView: View { onLocationTap: { Task { if let location = try? await appState.locationService.requestCurrentLocation() { - viewModel.cameraRegion = MKCoordinateRegion( + viewModel.setCameraRegion(MKCoordinateRegion( center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - viewModel.cameraRegionVersion += 1 + )) } } }, - showingLayersMenu: $showingMapStyleMenu - ) { - Button { - showLabels.toggle() - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) + showingLayersMenu: $showingMapStyleMenu, + topContent: { + NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) } - .buttonStyle(.plain) - .accessibilityLabel(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels) + ) { + LabelsToggleButton(showLabels: $showLabels) - Button { + Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { isDropPinMode.toggle() - } label: { - Image(systemName: isDropPinMode ? "mappin.slash" : "mappin") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(isDropPinMode ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) } + .font(.body.weight(.medium)) + .foregroundStyle(isDropPinMode ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) - .accessibilityLabel(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin) + .labelStyle(.iconOnly) } } } @@ -1198,48 +478,22 @@ private struct LOSMapCanvasView: View { if showingMapStyleMenu { Button { - withAnimation { - showingMapStyleMenu = false - } + withAnimation { showingMapStyleMenu = false } } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() + Color.black.opacity(0.3).ignoresSafeArea() } .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) VStack { Spacer() HStack { Spacer() - VStack(spacing: 0) { - ForEach(LOSMapStyleSelection.allCases, id: \.self) { style in - Button { - mapStyleSelection = style - withAnimation { - showingMapStyleMenu = false - } - } label: { - HStack { - Text(style.label) - .foregroundStyle(.primary) - Spacer() - if mapStyleSelection == style { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - - if style != LOSMapStyleSelection.allCases.last { - Divider() - } - } - } - .frame(width: 140) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .shadow(radius: 8) + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingMapStyleMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) .padding(.trailing) } } @@ -1247,6 +501,7 @@ private struct LOSMapCanvasView: View { } } } + } // MARK: - Frequency Input Row @@ -1254,7 +509,7 @@ private struct LOSMapCanvasView: View { /// Extracted view for frequency input with its own @FocusState /// This is necessary because @FocusState doesn't work properly when declared in a parent view /// and used in sheet content. -private struct FrequencyInputRow: View { +struct FrequencyInputRow: View { @Bindable var viewModel: LineOfSightViewModel @FocusState private var isFocused: Bool @State private var text: String = "" @@ -1315,25 +570,38 @@ private struct FrequencyInputRow: View { } } -// MARK: - Glass Button Style Helpers +// MARK: - Analyze Button -extension View { - @ViewBuilder - func glassButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } - } +private struct AnalyzeButton: View { + var viewModel: LineOfSightViewModel + let hasAnalysisResult: Bool + let onAnalyze: () -> Void - @ViewBuilder - func glassProminentButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) + var body: some View { + Button { + viewModel.shouldAutoZoomOnNextResult = true + onAnalyze() + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() + } + } label: { + if viewModel.isAnalyzing { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Tools.Tools.LineOfSight.analyzing) + } + .frame(maxWidth: .infinity) + } else { + Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") + .frame(maxWidth: .infinity) + } } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .disabled(viewModel.isAnalyzing) } } diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 4143807e7..32ff98e78 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -15,6 +15,12 @@ enum PointID: Hashable { case repeater } +// MARK: - PointID Identifiable Conformance + +extension PointID: Identifiable { + var id: Self { self } +} + // MARK: - Repeater Point /// A repeater point for relay analysis. @@ -114,17 +120,29 @@ struct LOSRepeaterSelectionInfo { @MainActor @Observable final class LineOfSightViewModel { + // MARK: - Stable Map IDs + + let pointAMapID = UUID() + let pointBMapID = UUID() + let repeaterTargetMapID = UUID() + // MARK: - Point Selection State - var pointA: SelectedPoint? - var pointB: SelectedPoint? - var relocatingPoint: PointID? + var pointA: SelectedPoint? { + didSet { rebuildSelectionState() } + } + var pointB: SelectedPoint? { + didSet { rebuildSelectionState() } + } + var relocatingPoint: PointID? { + didSet { rebuildMapLines() } + } var shouldAutoZoomOnNextResult = false // MARK: - Camera State (MKMapView) var cameraRegion: MKCoordinateRegion? - var cameraRegionVersion = 0 + private(set) var cameraRegionVersion = 0 // MARK: - RF Parameters @@ -145,14 +163,30 @@ final class LineOfSightViewModel { reanalyzeWithCachedProfileIfNeeded() } + // MARK: - Map Display State + + var isNorthLocked = false + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + private(set) var mapPoints: [MapPoint] = [] + private(set) var mapLines: [MapLine] = [] + // MARK: - Repeaters State - private(set) var repeatersWithLocation: [ContactDTO] = [] + private(set) var repeatersWithLocation: [ContactDTO] = [] { + didSet { rebuildSelectionState() } + } // MARK: - Repeater State /// The active repeater point (nil when not in use) - var repeaterPoint: RepeaterPoint? + var repeaterPoint: RepeaterPoint? { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } /// Whether repeater row should be visible (analysis shows marginal or worse) var shouldShowRepeaterRow: Bool { @@ -242,6 +276,13 @@ final class LineOfSightViewModel { private var pointBElevationTask: Task? private var repeaterElevationTask: Task? + isolated deinit { + analysisTask?.cancel() + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + repeaterElevationTask?.cancel() + } + // MARK: - Dependencies private let elevationService: ElevationServiceProtocol @@ -254,9 +295,10 @@ final class LineOfSightViewModel { pointA?.groundElevation != nil && pointB?.groundElevation != nil } - /// Pre-computes selection state for all repeaters in a single O(N) pass. - /// Returns a dictionary mapping repeater ID to its selection info. - var selectionState: [UUID: LOSRepeaterSelectionInfo] { + /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. + private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] + + private func rebuildSelectionState() { var result = [UUID: LOSRepeaterSelectionInfo]() result.reserveCapacity(repeatersWithLocation.count) @@ -274,62 +316,120 @@ final class LineOfSightViewModel { } result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) } - return result + selectionState = result + rebuildMapPoints() + rebuildMapLines() + } + + // MARK: - Map Data Rebuild + + func rebuildMapPoints() { + var points: [MapPoint] = [] + + for repeater in repeatersWithLocation { + let selectedAs = selectionState[repeater.id]?.selectedAs + let style: MapPoint.PinStyle = switch selectedAs { + case .pointA: .repeaterRingBlue + case .pointB: .repeaterRingGreen + case .repeater, nil: .repeater + } + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: style, + label: showLabels ? repeater.displayName : nil, + isClusterable: selectedAs == nil, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointA, pointA.contact == nil { + points.append(MapPoint( + id: pointAMapID, + coordinate: pointA.coordinate, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointB, pointB.contact == nil { + points.append(MapPoint( + id: pointBMapID, + coordinate: pointB.coordinate, + pinStyle: .pointB, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let target = repeaterPoint { + points.append(MapPoint( + id: repeaterTargetMapID, + coordinate: target.coordinate, + pinStyle: .crosshair, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + mapPoints = points + } + + func rebuildMapLines() { + guard let a = pointA?.coordinate, + let b = pointB?.coordinate else { + mapLines = [] + return + } + + let activeOpacity = 0.7 + let dimOpacity = 0.3 + + if let r = repeaterPoint?.coordinate { + let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity + let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity + mapLines = [ + MapLine(id: "los-ar", coordinates: [a, r], style: .los, + opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: [r, b], style: .los, + opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) + ] + } else { + let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity + mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + } } // MARK: - Camera Methods func centerOnAllRepeaters() { - let coordinates = repeatersWithLocation.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } + let coordinates = repeatersWithLocation.map(\.coordinate) setCameraRegion(fitting: coordinates) } - func zoomToShowBothPoints(bottomInsetFraction: Double = 0) { + func zoomToShowBothPoints() { guard let pointA, let pointB else { return } - setCameraRegion( - fitting: [pointA.coordinate, pointB.coordinate], - bottomInsetFraction: bottomInsetFraction - ) + setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) } - private func setCameraRegion( - fitting coordinates: [CLLocationCoordinate2D], - paddingMultiplier: Double = 1.5, - bottomInsetFraction: Double = 0 - ) { - guard !coordinates.isEmpty else { return } - let lats = coordinates.map(\.latitude) - let lons = coordinates.map(\.longitude) - let latDelta = max(0.01, (lats.max()! - lats.min()!) * paddingMultiplier) - let lonDelta = max(0.01, (lons.max()! - lons.min()!) * paddingMultiplier) - - var centerLat = (lats.min()! + lats.max()!) / 2 - var adjustedLatDelta = latDelta - - // Expand region south so content fits above the bottom sheet - if bottomInsetFraction > 0, bottomInsetFraction < 1 { - let southExtra = latDelta * bottomInsetFraction / (1 - bottomInsetFraction) - adjustedLatDelta = latDelta + southExtra - centerLat -= southExtra / 2 - } - - // Clamp to valid MKCoordinateRegion bounds to prevent MKMapView crash - let clampedLatDelta = min(adjustedLatDelta, 180) - let clampedLonDelta = min(lonDelta, 360) - let clampedCenterLat = centerLat.clamped(to: -90...90) - - cameraRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: clampedCenterLat, - longitude: (lons.min()! + lons.max()!) / 2 - ), - span: MKCoordinateSpan(latitudeDelta: clampedLatDelta, longitudeDelta: clampedLonDelta) - ) + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region cameraRegionVersion += 1 } + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } + /// Returns the elevation profile to display in terrain visualization. /// For on-path or no repeater: returns cached A-B profile. /// For off-path: returns concatenated A→R→B profiles. @@ -426,7 +526,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointAElevationTask = Task { @MainActor in + pointAElevationTask = Task { await fetchElevationForPointA() } } @@ -454,7 +554,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointBElevationTask = Task { @MainActor in + pointBElevationTask = Task { await fetchElevationForPointB() } } @@ -487,7 +587,7 @@ final class LineOfSightViewModel { /// - If contact is already selected as A or B, clear that point /// - Otherwise, auto-assign to A (if empty) or B func toggleContact(_ contact: ContactDTO) { - let coordinate = CLLocationCoordinate2D(latitude: contact.latitude, longitude: contact.longitude) + let coordinate = contact.coordinate // Check if already selected as point A if let pointA, pointA.contact?.id == contact.id { @@ -689,9 +789,7 @@ final class LineOfSightViewModel { to: repeaterCoord, sampleCount: sampleCountAR ) - let profileAR = try await elevationService.fetchElevations(along: sampleCoordsAR) - - // Fetch R→B profile + // Fetch R→B profile (computed before async let to avoid capture issues) let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) let sampleCoordsRB = ElevationService.sampleCoordinates( @@ -699,7 +797,11 @@ final class LineOfSightViewModel { to: pointBCoord, sampleCount: sampleCountRB ) - let profileRB = try await elevationService.fetchElevations(along: sampleCoordsRB) + + // Fetch both elevation profiles in parallel + async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) + async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) + let (profileAR, profileRB) = try await (profileARTask, profileRBTask) // Offset R→B profile distances to continue from A→R endpoint // (fetchElevations returns distances relative to segment start, not global A) @@ -828,24 +930,16 @@ final class LineOfSightViewModel { analysisTask = Task { do { - // Calculate optimal sample count based on distance let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) - - // Generate sample coordinates along the path let sampleCoordinates = ElevationService.sampleCoordinates( from: pointACoord, to: pointBCoord, sampleCount: sampleCount ) - - // Fetch elevation profile (async network call) let profile = try await elevationService.fetchElevations(along: sampleCoordinates) - - // Check for cancellation if Task.isCancelled { return } - // Run path analysis off main actor to avoid UI hitching let result = await Task.detached { RFCalculator.analyzePath( elevationProfile: profile, @@ -858,7 +952,6 @@ final class LineOfSightViewModel { if Task.isCancelled { return } - // Update state on MainActor elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( from: profile, @@ -934,7 +1027,6 @@ final class LineOfSightViewModel { let k = refractionK analysisTask = Task { - // Run path analysis off main actor let result = await Task.detached { RFCalculator.analyzePath( elevationProfile: profile, diff --git a/MC1/Views/LineOfSight/Map/LOSAnnotations.swift b/MC1/Views/LineOfSight/Map/LOSAnnotations.swift deleted file mode 100644 index 2e672241e..000000000 --- a/MC1/Views/LineOfSight/Map/LOSAnnotations.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CoreLocation -import MapKit -import MC1Services - -// MARK: - Repeater Annotation - -/// MKAnnotation wrapper for repeater contacts on the line of sight map -final class LOSRepeaterAnnotation: NSObject, MKAnnotation { - let repeater: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: repeater.latitude, longitude: repeater.longitude) - } - - var title: String? { repeater.displayName } - - init(repeater: ContactDTO) { - self.repeater = repeater - super.init() - } -} - -// MARK: - Point Annotation - -/// MKAnnotation for dropped-pin A/B markers -final class LOSPointAnnotation: NSObject, MKAnnotation { - let pointID: PointID - let label: String - - dynamic var coordinate: CLLocationCoordinate2D - - init(pointID: PointID, label: String, coordinate: CLLocationCoordinate2D) { - self.pointID = pointID - self.label = label - self.coordinate = coordinate - super.init() - } -} - -// MARK: - Repeater Target Annotation - -/// MKAnnotation for the crosshairs repeater target marker -final class LOSRepeaterTargetAnnotation: NSObject, MKAnnotation { - dynamic var coordinate: CLLocationCoordinate2D - - init(coordinate: CLLocationCoordinate2D) { - self.coordinate = coordinate - super.init() - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSMKMapView.swift b/MC1/Views/LineOfSight/Map/LOSMKMapView.swift deleted file mode 100644 index 412596b56..000000000 --- a/MC1/Views/LineOfSight/Map/LOSMKMapView.swift +++ /dev/null @@ -1,497 +0,0 @@ -import MapKit -import MC1Services -import SwiftUI - -/// UIViewRepresentable for line of sight map with custom overlays and interactions -struct LOSMKMapView: UIViewRepresentable { - let repeaters: [ContactDTO] - let pointA: SelectedPoint? - let pointB: SelectedPoint? - let repeaterTarget: RepeaterPoint? - let relocatingPoint: PointID? - let mapType: MKMapType - let showLabels: Bool - - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - - /// Closure-wrapped to defer computation to updateUIView, avoiding SwiftUI observation overhead - let selectionState: () -> [UUID: LOSRepeaterSelectionInfo] - let onRepeaterTap: (ContactDTO) -> Void - let onMapTap: (CLLocationCoordinate2D) -> Void - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - mapView.showsUserLocation = true - mapView.showsCompass = true - - let scaleView = MKScaleView(mapView: mapView) - scaleView.translatesAutoresizingMaskIntoConstraints = false - scaleView.scaleVisibility = .adaptive - mapView.addSubview(scaleView) - NSLayoutConstraint.activate([ - scaleView.leadingAnchor.constraint(equalTo: mapView.leadingAnchor, constant: 16), - scaleView.bottomAnchor.constraint(equalTo: mapView.safeAreaLayoutGuide.bottomAnchor, constant: -8), - ]) - - mapView.register( - LOSRepeaterPinView.self, - forAnnotationViewWithReuseIdentifier: LOSRepeaterPinView.reuseIdentifier - ) - mapView.register( - TracePathClusterView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - mapView.register( - LOSPointPinView.self, - forAnnotationViewWithReuseIdentifier: LOSPointPinView.reuseIdentifier - ) - mapView.register( - LOSRepeaterTargetPinView.self, - forAnnotationViewWithReuseIdentifier: LOSRepeaterTargetPinView.reuseIdentifier - ) - - // Map tap gesture for drop pin / relocation - let tapGesture = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleMapTap(_:)) - ) - tapGesture.delegate = context.coordinator - mapView.addGestureRecognizer(tapGesture) - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Evaluate selection state once - let selState = selectionState() - coordinator.selectionState = selState - coordinator.onRepeaterTap = onRepeaterTap - coordinator.onMapTap = onMapTap - coordinator.relocatingPoint = relocatingPoint - coordinator.showLabels = showLabels - - // Update map type - mapView.mapType = mapType - - // Update repeater annotations - updateRepeaterAnnotations(in: mapView, coordinator: coordinator, selectionState: selState) - - // Update point A/B annotations - updatePointAnnotations(in: mapView, coordinator: coordinator) - - // Update repeater target annotation - updateRepeaterTargetAnnotation(in: mapView, coordinator: coordinator) - - // Update path overlays - updatePathOverlays(in: mapView, coordinator: coordinator) - - // Update visible pin views with current state - updateVisiblePinViews(in: mapView, coordinator: coordinator, selectionState: selState) - - // Update path overlay opacity when relocatingPoint changes - if relocatingPoint != coordinator.lastRelocatingPoint { - coordinator.lastRelocatingPoint = relocatingPoint - for overlay in mapView.overlays { - if let pathOverlay = overlay as? LOSPathOverlay, - let renderer = mapView.renderer(for: pathOverlay) as? LOSPathRenderer { - renderer.alpha = coordinator.lineOpacity(connectsTo: pathOverlay.connectsTo) - renderer.setNeedsDisplay() - } - } - } - - // Update region only when version changes - if cameraRegionVersion != coordinator.lastAppliedRegionVersion, - let region = cameraRegion { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - coordinator.hasPendingProgrammaticRegion = true - let animated = coordinator.lastAppliedRegion != nil - let fittedRegion = mapView.regionThatFits(region) - mapView.setRegion(fittedRegion, animated: animated) - - coordinator.lastAppliedRegion = fittedRegion - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(setCameraRegion: { cameraRegion = $0 }) - } - - static func dismantleUIView(_ mapView: MKMapView, coordinator: Coordinator) { - coordinator.pendingRegionTask?.cancel() - } - - // MARK: - Repeater Annotation Updates - - private func updateRepeaterAnnotations( - in mapView: MKMapView, - coordinator: Coordinator, - selectionState: [UUID: LOSRepeaterSelectionInfo] - ) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? LOSRepeaterAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.repeater.id }) - let newIDs = Set(repeaters.map { $0.id }) - - // Remove old - let toRemove = currentAnnotations.filter { !newIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toRemove) - - // Add new - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.repeater.id })) - let toAdd = repeaters.filter { !existingIDs.contains($0.id) } - .map { LOSRepeaterAnnotation(repeater: $0) } - mapView.addAnnotations(toAdd) - - // Re-add annotations whose selection state changed (MapKit doesn't pick up clusteringIdentifier changes) - let currentSelectedIDs = Set(selectionState.filter { $0.value.selectedAs != nil }.map { $0.key }) - let previousSelectedIDs = coordinator.previousSelectedIDs - let changedIDs = currentSelectedIDs.symmetricDifference(previousSelectedIDs) - coordinator.previousSelectedIDs = currentSelectedIDs - - if !changedIDs.isEmpty { - let toReAdd = mapView.annotations - .compactMap { $0 as? LOSRepeaterAnnotation } - .filter { changedIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toReAdd) - mapView.addAnnotations(toReAdd) - } - } - - // MARK: - Point Annotation Updates - - private func updatePointAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let existingPoints = mapView.annotations.compactMap { $0 as? LOSPointAnnotation } - - // Point A (only if dropped pin, not contact) - let existingA = existingPoints.first { $0.pointID == .pointA } - if let pointA, pointA.contact == nil { - if let existing = existingA { - // Update coordinate if changed - if existing.coordinate.latitude != pointA.coordinate.latitude || - existing.coordinate.longitude != pointA.coordinate.longitude { - existing.coordinate = pointA.coordinate - } - } else { - let annotation = LOSPointAnnotation( - pointID: .pointA, - label: "A", - coordinate: pointA.coordinate - ) - mapView.addAnnotation(annotation) - } - } else if let existing = existingA { - mapView.removeAnnotation(existing) - } - - // Point B (only if dropped pin, not contact) - let existingB = existingPoints.first { $0.pointID == .pointB } - if let pointB, pointB.contact == nil { - if let existing = existingB { - if existing.coordinate.latitude != pointB.coordinate.latitude || - existing.coordinate.longitude != pointB.coordinate.longitude { - existing.coordinate = pointB.coordinate - } - } else { - let annotation = LOSPointAnnotation( - pointID: .pointB, - label: "B", - coordinate: pointB.coordinate - ) - mapView.addAnnotation(annotation) - } - } else if let existing = existingB { - mapView.removeAnnotation(existing) - } - } - - // MARK: - Repeater Target Annotation Updates - - private func updateRepeaterTargetAnnotation(in mapView: MKMapView, coordinator: Coordinator) { - let existing = mapView.annotations.compactMap { $0 as? LOSRepeaterTargetAnnotation }.first - - if let repeaterTarget { - if let existing { - if existing.coordinate.latitude != repeaterTarget.coordinate.latitude || - existing.coordinate.longitude != repeaterTarget.coordinate.longitude { - existing.coordinate = repeaterTarget.coordinate - } - } else { - let annotation = LOSRepeaterTargetAnnotation(coordinate: repeaterTarget.coordinate) - mapView.addAnnotation(annotation) - } - } else if let existing { - mapView.removeAnnotation(existing) - } - } - - // MARK: - Path Overlay Updates - - private func updatePathOverlays(in mapView: MKMapView, coordinator: Coordinator) { - // Build desired path segments - var newOverlays: [LOSPathOverlay] = [] - - if let pointA, let pointB { - if let repeaterTarget { - // A -> R - let coordsAR = [pointA.coordinate, repeaterTarget.coordinate] - let overlayAR = LOSPathOverlay(coordinates: coordsAR, count: coordsAR.count) - overlayAR.connectsTo = .pointA - newOverlays.append(overlayAR) - - // R -> B - let coordsRB = [repeaterTarget.coordinate, pointB.coordinate] - let overlayRB = LOSPathOverlay(coordinates: coordsRB, count: coordsRB.count) - overlayRB.connectsTo = .pointB - newOverlays.append(overlayRB) - } else { - // A -> B - let coords = [pointA.coordinate, pointB.coordinate] - let overlay = LOSPathOverlay(coordinates: coords, count: coords.count) - overlay.connectsTo = .pointA - newOverlays.append(overlay) - } - } - - // Check if overlays need updating - let existingOverlays = mapView.overlays.compactMap { $0 as? LOSPathOverlay } - let needsUpdate = existingOverlays.count != newOverlays.count || - !coordinatesEqual(coordinator.lastOverlayPointACoord, pointA?.coordinate) || - !coordinatesEqual(coordinator.lastOverlayPointBCoord, pointB?.coordinate) || - !coordinatesEqual(coordinator.lastOverlayRepeaterCoord, repeaterTarget?.coordinate) - - if needsUpdate { - mapView.removeOverlays(existingOverlays) - mapView.addOverlays(newOverlays) - coordinator.lastOverlayPointACoord = pointA?.coordinate - coordinator.lastOverlayPointBCoord = pointB?.coordinate - coordinator.lastOverlayRepeaterCoord = repeaterTarget?.coordinate - } - } - - // MARK: - Update Visible Pin Views - - private func updateVisiblePinViews(in mapView: MKMapView, coordinator: Coordinator, selectionState: [UUID: LOSRepeaterSelectionInfo]) { - for annotation in mapView.annotations { - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation, - let view = mapView.view(for: repeaterAnnotation) as? LOSRepeaterPinView { - let info = selectionState[repeaterAnnotation.repeater.id] - let selectedAs = info?.selectedAs - let opacity = coordinator.markerOpacity(for: selectedAs) - view.configure(selectedAs: selectedAs, opacity: opacity, showLabel: coordinator.showLabels) - } - - if let pointAnnotation = annotation as? LOSPointAnnotation, - let view = mapView.view(for: pointAnnotation) as? LOSPointPinView { - let color: UIColor = pointAnnotation.pointID == .pointA ? .systemBlue : .systemGreen - let opacity = coordinator.markerOpacity(for: pointAnnotation.pointID) - view.configure(label: pointAnnotation.label, color: color, opacity: opacity) - } - - if annotation is LOSRepeaterTargetAnnotation, - let view = mapView.view(for: annotation) as? LOSRepeaterTargetPinView { - let opacity = coordinator.markerOpacity(for: .repeater) - view.configure(opacity: opacity) - } - } - } - - // MARK: - Coordinate Comparison - - private func coordinatesEqual(_ lhs: CLLocationCoordinate2D?, _ rhs: CLLocationCoordinate2D?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): return true - case (nil, _), (_, nil): return false - case let (l?, r?): return l.latitude == r.latitude && l.longitude == r.longitude - } - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate { - var setCameraRegion: (MKCoordinateRegion?) -> Void - - var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] - var onRepeaterTap: ((ContactDTO) -> Void)? - var onMapTap: ((CLLocationCoordinate2D) -> Void)? - var relocatingPoint: PointID? - var showLabels = true - - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var lastAppliedRegionVersion = -1 - var hasPendingProgrammaticRegion = false - - // Change detection - var previousSelectedIDs: Set = [] - var lastOverlayPointACoord: CLLocationCoordinate2D? - var lastOverlayPointBCoord: CLLocationCoordinate2D? - var lastOverlayRepeaterCoord: CLLocationCoordinate2D? - var lastRelocatingPoint: PointID? - - private var hasReceivedInitialRegion = false - var pendingRegionTask: Task? - - lazy var mapView: MKMapView = NoDoubleTapMapView() - - init(setCameraRegion: @escaping (MKCoordinateRegion?) -> Void) { - self.setCameraRegion = setCameraRegion - } - - // MARK: - Map Tap Handling - - @objc func handleMapTap(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: mapView) - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - } - - // Avoid intercepting annotation view taps - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { - !(touch.view is MKAnnotationView) - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - if annotation is MKUserLocation { - return nil - } - - if let clusterAnnotation = annotation as? MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? TracePathClusterView ?? TracePathClusterView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.configure(with: clusterAnnotation) - return view - } - - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSRepeaterPinView.reuseIdentifier, - for: annotation - ) as? LOSRepeaterPinView ?? LOSRepeaterPinView( - annotation: annotation, - reuseIdentifier: LOSRepeaterPinView.reuseIdentifier - ) - - let info = selectionState[repeaterAnnotation.repeater.id] - let selectedAs = info?.selectedAs - view.configure(selectedAs: selectedAs, opacity: markerOpacity(for: selectedAs), showLabel: showLabels) - - view.onTap = { [weak self] in - self?.onRepeaterTap?(repeaterAnnotation.repeater) - } - - return view - } - - if let pointAnnotation = annotation as? LOSPointAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSPointPinView.reuseIdentifier, - for: annotation - ) as? LOSPointPinView ?? LOSPointPinView( - annotation: annotation, - reuseIdentifier: LOSPointPinView.reuseIdentifier - ) - - let color: UIColor = pointAnnotation.pointID == .pointA ? .systemBlue : .systemGreen - view.configure(label: pointAnnotation.label, color: color, opacity: markerOpacity(for: pointAnnotation.pointID)) - return view - } - - if annotation is LOSRepeaterTargetAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSRepeaterTargetPinView.reuseIdentifier, - for: annotation - ) as? LOSRepeaterTargetPinView ?? LOSRepeaterTargetPinView( - annotation: annotation, - reuseIdentifier: LOSRepeaterTargetPinView.reuseIdentifier - ) - - view.configure(opacity: markerOpacity(for: .repeater)) - return view - } - - return nil - } - - func mapView(_ mapView: MKMapView, rendererFor overlay: any MKOverlay) -> MKOverlayRenderer { - if let pathOverlay = overlay as? LOSPathOverlay { - let opacity = lineOpacity(connectsTo: pathOverlay.connectsTo) - return LOSPathRenderer(overlay: pathOverlay, opacity: opacity) - } - return MKOverlayRenderer(overlay: overlay) - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - mapView.deselectAnnotation(annotation, animated: false) - - if let cluster = annotation as? MKClusterAnnotation { - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { return } - - if hasPendingProgrammaticRegion { - hasPendingProgrammaticRegion = false - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - if !hasReceivedInitialRegion { - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - lastAppliedRegion = mapView.region - - pendingRegionTask?.cancel() - pendingRegionTask = Task { @MainActor in - guard !Task.isCancelled else { return } - self.setCameraRegion(mapView.region) - } - } - - // MARK: - Opacity Helpers - - func markerOpacity(for pointID: PointID?) -> CGFloat { - guard let relocating = relocatingPoint else { return 1.0 } - guard let pointID else { return 1.0 } - return relocating == pointID ? 0.4 : 1.0 - } - - func lineOpacity(connectsTo pointID: PointID) -> CGFloat { - guard let relocating = relocatingPoint else { return 0.7 } - - if relocating == .repeater { return 0.3 } - - switch pointID { - case .pointA: - return relocating == .pointA ? 0.3 : 0.7 - case .pointB: - return relocating == .pointB ? 0.3 : 0.7 - case .repeater: - return 0.7 - } - } - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift b/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift deleted file mode 100644 index 58a218331..000000000 --- a/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift +++ /dev/null @@ -1,18 +0,0 @@ -import MapKit - -/// MKPolyline overlay for path lines between points A, R, and B -final class LOSPathOverlay: MKPolyline { - /// Which point this line connects to (used for opacity calculation during relocation) - var connectsTo: PointID = .pointA -} - -/// Renderer for LOS path overlays - blue dashed lines -final class LOSPathRenderer: MKPolylineRenderer { - init(overlay: LOSPathOverlay, opacity: CGFloat) { - super.init(overlay: overlay) - strokeColor = .systemBlue - lineWidth = 3 - lineDashPattern = [8, 4] - alpha = opacity - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSPointPinView.swift b/MC1/Views/LineOfSight/Map/LOSPointPinView.swift deleted file mode 100644 index 24d057123..000000000 --- a/MC1/Views/LineOfSight/Map/LOSPointPinView.swift +++ /dev/null @@ -1,88 +0,0 @@ -import MapKit -import UIKit - -/// Pin view for dropped-pin A/B markers on the line of sight map -final class LOSPointPinView: MKAnnotationView { - static let reuseIdentifier = "LOSPointPinView" - - // MARK: - UI Components - - private let circleView = UIView() - private let labelView = UILabel() - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let size: CGFloat = 32 - - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.layer.cornerRadius = size / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - labelView.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, - weight: .bold - ) - labelView.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: baseFont) - labelView.adjustsFontForContentSizeCategory = true - labelView.textColor = .white - labelView.textAlignment = .center - circleView.addSubview(labelView) - - NSLayoutConstraint.activate([ - circleView.widthAnchor.constraint(equalToConstant: size), - circleView.heightAnchor.constraint(equalToConstant: size), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.centerYAnchor.constraint(equalTo: centerYAnchor), - - labelView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - labelView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - frame = CGRect(x: 0, y: 0, width: size, height: size) - centerOffset = .zero - canShowCallout = false - displayPriority = .required - } - - // MARK: - Configuration - - func configure(label: String, color: UIColor, opacity: CGFloat) { - circleView.backgroundColor = color - labelView.text = label - alpha = opacity - - isAccessibilityElement = true - accessibilityTraits = .image - accessibilityLabel = label == "A" - ? L10n.Tools.Tools.LineOfSight.pointA - : L10n.Tools.Tools.LineOfSight.pointB - accessibilityHint = L10n.Tools.Tools.LineOfSight.PointPin.accessibilityHint - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - alpha = 1.0 - labelView.text = nil - accessibilityLabel = nil - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift b/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift deleted file mode 100644 index 0c5210a8c..000000000 --- a/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import MapKit -import UIKit -import MC1Services - -/// Custom pin view for repeaters in line of sight map with selection state and clustering -final class LOSRepeaterPinView: MKAnnotationView { - static let reuseIdentifier = "LOSRepeaterPinView" - static let clusteringID = "losRepeater" - - // MARK: - Tap Handling - - var onTap: (() -> Void)? - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private let selectionRing = UIView() - private var pointBadge: UILabel? - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringSize: CGFloat = 44 - - // Selection ring (behind circle) - selectionRing.translatesAutoresizingMaskIntoConstraints = false - selectionRing.backgroundColor = .clear - selectionRing.layer.borderWidth = 3 - selectionRing.layer.cornerRadius = ringSize / 2 - selectionRing.isHidden = true - addSubview(selectionRing) - - // Circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = circleSize / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - iconImageView.image = UIImage(systemName: "antenna.radiowaves.left.and.right") - circleView.addSubview(iconImageView) - - // Triangle - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - triangleImageView.tintColor = .systemCyan - addSubview(triangleImageView) - - NSLayoutConstraint.activate([ - selectionRing.widthAnchor.constraint(equalToConstant: ringSize), - selectionRing.heightAnchor.constraint(equalToConstant: ringSize), - selectionRing.centerXAnchor.constraint(equalTo: centerXAnchor), - selectionRing.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - circleView.widthAnchor.constraint(equalToConstant: circleSize), - circleView.heightAnchor.constraint(equalToConstant: circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor, constant: 4), - - iconImageView.widthAnchor.constraint(equalToConstant: iconSize), - iconImageView.heightAnchor.constraint(equalToConstant: iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - triangleImageView.widthAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.heightAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - let totalHeight = circleSize + triangleSize + 4 - frame = CGRect(x: 0, y: 0, width: ringSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - - canShowCallout = false - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGesture) - } - - @objc private func handleTap() { - onTap?() - } - - // MARK: - Configuration - - func configure(selectedAs: PointID?, opacity: CGFloat, showLabel: Bool = false) { - let isSelected = selectedAs != nil - - // Clustering: selected pins always visible, others cluster - if isSelected { - clusteringIdentifier = nil - displayPriority = .required - } else { - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } - - // Selection ring color: blue for A, green for B - if let selectedAs { - selectionRing.isHidden = false - selectionRing.layer.borderColor = (selectedAs == .pointA ? UIColor.systemBlue : UIColor.systemGreen).cgColor - showPointBadge(selectedAs == .pointA ? "A" : "B", color: selectedAs == .pointA ? .systemBlue : .systemGreen) - } else { - selectionRing.isHidden = true - hidePointBadge() - } - - alpha = opacity - - // Name label - if showLabel, let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - showNameLabel(repeaterAnnotation.repeater.displayName) - } else { - hideNameLabel() - } - - // Accessibility - isAccessibilityElement = true - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - if isSelected { - accessibilityLabel = repeaterAnnotation.repeater.displayName - accessibilityTraits = [.button, .selected] - } else { - accessibilityLabel = repeaterAnnotation.repeater.displayName - accessibilityTraits = .button - } - accessibilityHint = L10n.Tools.Tools.LineOfSight.RepeaterPin.accessibilityHint - } - } - - // MARK: - Point Badge - - private func showPointBadge(_ text: String, color: UIColor) { - if pointBadge == nil { - let badge = UILabel() - badge.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .bold - ) - badge.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - badge.adjustsFontForContentSizeCategory = true - badge.textColor = .white - badge.textAlignment = .center - badge.layer.cornerRadius = 9 - badge.layer.masksToBounds = true - addSubview(badge) - - NSLayoutConstraint.activate([ - badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.heightAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.centerXAnchor.constraint(equalTo: centerXAnchor), - badge.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: 8) - ]) - - pointBadge = badge - } - - pointBadge?.text = text - pointBadge?.backgroundColor = color - pointBadge?.isHidden = false - } - - private func hidePointBadge() { - pointBadge?.isHidden = true - } - - // MARK: - Name Label - - private func showNameLabel(_ name: String) { - if nameLabelContainer == nil { - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.clipsToBounds = true - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .medium - ) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - blur.contentView.addSubview(label) - - addSubview(blur) - - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: blur.contentView.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.contentView.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.contentView.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.contentView.trailingAnchor, constant: -8), - - blur.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - blur.bottomAnchor.constraint(equalTo: circleView.topAnchor, constant: -4), - ]) - - nameLabelContainer = blur - nameLabel = label - } - - nameLabel?.text = name - nameLabelContainer?.isHidden = false - } - - private func hideNameLabel() { - nameLabelContainer?.isHidden = true - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onTap = nil - selectionRing.isHidden = true - hidePointBadge() - hideNameLabel() - alpha = 1.0 - accessibilityLabel = nil - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift b/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift deleted file mode 100644 index ebff33c1b..000000000 --- a/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import MapKit -import UIKit - -/// Crosshairs pin view for the simulated repeater target on the line of sight map -final class LOSRepeaterTargetPinView: MKAnnotationView { - static let reuseIdentifier = "LOSRepeaterTargetPinView" - - // MARK: - UI Components - - private let crosshairLayer = CAShapeLayer() - private var badgeLabel: UILabel? - private var badgeBackground: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let size: CGFloat = 44 - let gapRadius: CGFloat = 4 - let outerRadius = size / 2 - - frame = CGRect(x: 0, y: 0, width: size, height: size + 24) - centerOffset = CGPoint(x: 0, y: 12) - canShowCallout = false - displayPriority = .required - - // Crosshair lines - let center = CGPoint(x: size / 2, y: size / 2) - let path = UIBezierPath() - - // Top - path.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - path.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - // Bottom - path.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - path.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - // Left - path.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - path.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - // Right - path.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - path.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - - crosshairLayer.path = path.cgPath - crosshairLayer.strokeColor = UIColor.systemPurple.cgColor - crosshairLayer.lineWidth = 2 - crosshairLayer.fillColor = nil - crosshairLayer.shadowColor = UIColor.black.cgColor - crosshairLayer.shadowOpacity = 0.3 - crosshairLayer.shadowRadius = 2 - crosshairLayer.shadowOffset = CGSize(width: 0, height: 2) - layer.addSublayer(crosshairLayer) - - // "R" badge below - let bg = UIView() - bg.translatesAutoresizingMaskIntoConstraints = false - bg.backgroundColor = .systemPurple - bg.layer.cornerRadius = 9 - addSubview(bg) - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .bold - ) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .white - label.textAlignment = .center - label.text = "R" - bg.addSubview(label) - - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: bg.topAnchor, constant: 2), - label.bottomAnchor.constraint(equalTo: bg.bottomAnchor, constant: -2), - label.leadingAnchor.constraint(equalTo: bg.leadingAnchor, constant: 6), - label.trailingAnchor.constraint(equalTo: bg.trailingAnchor, constant: -6), - - bg.centerXAnchor.constraint(equalTo: centerXAnchor), - bg.topAnchor.constraint(equalTo: topAnchor, constant: size + 2) - ]) - - badgeLabel = label - badgeBackground = bg - - isAccessibilityElement = true - accessibilityTraits = .image - accessibilityLabel = L10n.Tools.Tools.LineOfSight.repeater - accessibilityHint = L10n.Tools.Tools.LineOfSight.RepeaterTarget.accessibilityHint - } - - // MARK: - Configuration - - func configure(opacity: CGFloat) { - alpha = opacity - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - alpha = 1.0 - } -} diff --git a/MC1/Views/LineOfSight/PointHeightEditorView.swift b/MC1/Views/LineOfSight/PointHeightEditorView.swift new file mode 100644 index 000000000..064200342 --- /dev/null +++ b/MC1/Views/LineOfSight/PointHeightEditorView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct PointHeightEditorView: View { + var viewModel: LineOfSightViewModel + let point: SelectedPoint + let pointID: PointID + + var body: some View { + HeightEditorGrid( + groundElevation: point.groundElevation, + additionalHeight: Binding( + get: { point.additionalHeight }, + set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } + ), + range: 0...200 + ) + } +} diff --git a/MC1/Views/LineOfSight/PointRowButtonsView.swift b/MC1/Views/LineOfSight/PointRowButtonsView.swift new file mode 100644 index 000000000..b3754a6a0 --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -0,0 +1,103 @@ +import CoreLocation +import MapKit +import SwiftUI + +struct PointRowButtonsView: View { + var viewModel: LineOfSightViewModel + let pointID: PointID + let isEditing: Bool + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void + + private let iconButtonSize: CGFloat = 22 + + private var coordinate: CLLocationCoordinate2D? { + switch pointID { + case .pointA: viewModel.pointA?.coordinate + case .pointB: viewModel.pointB?.coordinate + case .repeater: viewModel.repeaterPoint?.coordinate + } + } + + var body: some View { + // Share menu + Menu { + if let coord = coordinate { + Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) + mapItem.name = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation + } + mapItem.openInMaps() + } + + Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = coord.formattedString + } + + ShareLink(item: coord.formattedString) { + Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") + } + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .controlSize(.small) + + // Relocate button (toggles on/off) + Button { + if viewModel.relocatingPoint == pointID { + viewModel.relocatingPoint = nil + } else { + viewModel.relocatingPoint = pointID + onRelocate() + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) + + // Edit/Done toggle + Button { + withAnimation { + editingPoint = isEditing ? nil : pointID + } + } label: { + Group { + if isEditing { + Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") + .labelStyle(.iconOnly) + } else { + Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") + .labelStyle(.iconOnly) + .rotationEffect(.degrees(90)) + } + } + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + + // Clear button + Button(action: onClear) { + Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } +} diff --git a/MC1/Views/LineOfSight/PointRowView.swift b/MC1/Views/LineOfSight/PointRowView.swift new file mode 100644 index 000000000..c94fd256a --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct PointRowView: View { + var viewModel: LineOfSightViewModel + let label: String + let color: Color + let point: SelectedPoint? + let pointID: PointID + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void + + private var isEditing: Bool { editingPoint == pointID } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row (always visible) + HStack { + // Point marker + Circle() + .fill(point != nil ? color : .gray.opacity(0.3)) + .frame(width: 24, height: 24) + .overlay { + Text(label) + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + // Point info + if let point { + VStack(alignment: .leading, spacing: 2) { + Text(point.displayName) + .font(.subheadline) + .lineLimit(1) + + if point.isLoadingElevation { + HStack(spacing: 4) { + ProgressView() + .controlSize(.mini) + Text(L10n.Tools.Tools.LineOfSight.loadingElevation) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let elevation = point.groundElevation { + Text(Measurement( + value: Double(Int(elevation) + point.additionalHeight), + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + PointRowButtonsView( + viewModel: viewModel, + pointID: pointID, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: onClear + ) + } else { + Text(L10n.Tools.Tools.LineOfSight.notSelected) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + } + } + + // Expanded editor (when editing) + if isEditing, let point { + Divider() + + PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) + } + } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } +} diff --git a/MC1/Views/LineOfSight/PointsSummarySectionView.swift b/MC1/Views/LineOfSight/PointsSummarySectionView.swift new file mode 100644 index 000000000..e1646e794 --- /dev/null +++ b/MC1/Views/LineOfSight/PointsSummarySectionView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct PointsSummarySectionView: View { + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + + private var isRelocating: Bool { viewModel.relocatingPoint != nil } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with optional cancel button + HStack { + Text(L10n.Tools.Tools.LineOfSight.points) + .font(.headline) + + Spacer() + + if isRelocating { + Button(L10n.Tools.Tools.LineOfSight.cancel) { + viewModel.relocatingPoint = nil + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } + } + + // Show relocating message OR point rows + if let relocatingPoint = viewModel.relocatingPoint { + relocatingMessageView(for: relocatingPoint) + } else { + // Point A row + PointRowView( + viewModel: viewModel, + label: "A", + color: .blue, + point: viewModel.pointA, + pointID: .pointA, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointA() } + ) + + // Repeater row (placeholder or full, positioned between A and B) + // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency + if let repeater = viewModel.repeaterPoint { + RepeaterRowView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate + ) + .id("repeater-\(repeater.coordinate.latitude)-\(repeater.coordinate.longitude)") + } else if viewModel.shouldShowRepeaterPlaceholder { + AddRepeaterRowView { + viewModel.addRepeater() + viewModel.analyzeWithRepeater() + } + } + + // Point B row + PointRowView( + viewModel: viewModel, + label: "B", + color: .green, + point: viewModel.pointB, + pointID: .pointB, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointB() } + ) + + if viewModel.pointA == nil || viewModel.pointB == nil { + Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) + .font(.caption) + .foregroundStyle(.secondary) + } + + if viewModel.elevationFetchFailed { + Label( + L10n.Tools.Tools.LineOfSight.elevationUnavailable, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } + } + } + + @ViewBuilder + private func relocatingMessageView(for pointID: PointID) -> some View { + let pointName: String = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeater + } + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) + .font(.subheadline) + .bold() + + Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + } +} diff --git a/MC1/Views/LineOfSight/RFSettingsSectionView.swift b/MC1/Views/LineOfSight/RFSettingsSectionView.swift new file mode 100644 index 000000000..4855b08f7 --- /dev/null +++ b/MC1/Views/LineOfSight/RFSettingsSectionView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct RFSettingsSectionView: View { + @Bindable var viewModel: LineOfSightViewModel + @Binding var isRFSettingsExpanded: Bool + + var body: some View { + DisclosureGroup(isExpanded: $isRFSettingsExpanded) { + VStack(spacing: 12) { + // Frequency input - extracted to separate view for @FocusState to work in sheet + FrequencyInputRow(viewModel: viewModel) + + Divider() + + // Refraction k-factor picker + HStack { + Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: Binding( + get: { viewModel.refractionK }, + set: { viewModel.refractionK = $0 } + )) { + Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) + } + .pickerStyle(.menu) + } + } + .padding(.top, 8) + } label: { + Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) + } + .tint(.primary) + } +} diff --git a/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift new file mode 100644 index 000000000..3a68f1747 --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct RepeaterHeightEditorView: View { + var viewModel: LineOfSightViewModel + let repeaterPoint: RepeaterPoint + + var body: some View { + HeightEditorGrid( + groundElevation: viewModel.repeaterGroundElevation, + additionalHeight: Binding( + get: { repeaterPoint.additionalHeight }, + set: { viewModel.updateRepeaterHeight(meters: $0) } + ), + range: 0...200, + onHeightChanged: { viewModel.analyzeWithRepeater() } + ) + } +} diff --git a/MC1/Views/LineOfSight/RepeaterRowView.swift b/MC1/Views/LineOfSight/RepeaterRowView.swift new file mode 100644 index 000000000..3abcf2bd8 --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct RepeaterRowView: View { + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + + private var isEditing: Bool { editingPoint == .repeater } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack { + // Repeater marker (purple) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Tools.Tools.LineOfSight.repeater) + .font(.subheadline) + .lineLimit(1) + + if let elevation = viewModel.repeaterGroundElevation { + let totalHeight = Int(elevation) + (viewModel.repeaterPoint?.additionalHeight ?? 0) + Text(Measurement( + value: Double(totalHeight), + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + PointRowButtonsView( + viewModel: viewModel, + pointID: .repeater, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearRepeater() } + ) + } + + // Expanded editor + if isEditing, let repeaterPoint = viewModel.repeaterPoint { + Divider() + RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) + } + } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } +} diff --git a/MC1/Views/LineOfSight/TerrainProfileSectionView.swift b/MC1/Views/LineOfSight/TerrainProfileSectionView.swift new file mode 100644 index 000000000..45c7075b5 --- /dev/null +++ b/MC1/Views/LineOfSight/TerrainProfileSectionView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct TerrainProfileSectionView: View { + var viewModel: LineOfSightViewModel + @Binding var showDragHint: Bool + @Binding var repeaterMarkerCenter: CGPoint? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.Tools.Tools.LineOfSight.terrainProfile) + .font(.headline) + + Spacer() + + Label( + L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), + systemImage: "globe" + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + + TerrainProfileCanvas( + elevationProfile: viewModel.terrainElevationProfile, + profileSamples: viewModel.profileSamples, + profileSamplesRB: viewModel.profileSamplesRB, + // Show repeater marker for both on-path and off-path + repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, + repeaterHeight: viewModel.repeaterPoint.map { Double($0.additionalHeight) }, + // Only enable drag for on-path repeaters + onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in + viewModel.updateRepeaterPosition(pathFraction: pathFraction) + viewModel.analyzeWithRepeater() + } : nil, + onRepeaterMarkerPosition: { center in + repeaterMarkerCenter = center + }, + // Off-path segment distances for separator and labels + segmentARDistanceMeters: viewModel.segmentARDistanceMeters, + segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters + ) + .overlay { + if showDragHint, let center = repeaterMarkerCenter { + Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) + .font(.caption) + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.regularMaterial, in: .capsule) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .transition(.opacity.combined(with: .scale)) + .position(x: center.x, y: center.y + 30) + } + } + } + } +} diff --git a/MC1/Views/Map/ContactAnnotation.swift b/MC1/Views/Map/ContactAnnotation.swift deleted file mode 100644 index db5b65f52..000000000 --- a/MC1/Views/Map/ContactAnnotation.swift +++ /dev/null @@ -1,41 +0,0 @@ -import MapKit -import MC1Services - -/// MKAnnotation wrapper for ContactDTO to display on MKMapView -final class ContactAnnotation: NSObject, MKAnnotation { - let contact: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: contact.latitude, longitude: contact.longitude) - } - - var title: String? { contact.displayName } - - var subtitle: String? { - switch contact.type { - case .chat: - contact.isFavorite ? L10n.Map.Map.Annotation.favorite : nil - case .repeater: - L10n.Map.Map.Annotation.repeater - case .room: - L10n.Map.Map.Annotation.room - } - } - - init(contact: ContactDTO) { - self.contact = contact - super.init() - } -} - -extension ContactAnnotation { - /// Unique identifier for comparing annotations - override var hash: Int { - contact.id.hashValue - } - - override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? ContactAnnotation else { return false } - return contact.id == other.contact.id - } -} diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 11b5b897c..4dc401f11 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -1,7 +1,7 @@ import SwiftUI import MC1Services -/// SwiftUI content view displayed inside the native MKAnnotationView callout +/// SwiftUI content displayed in a popover callout when a map pin is tapped struct ContactCalloutContent: View { let contact: ContactDTO let onDetail: () -> Void @@ -9,14 +9,17 @@ struct ContactCalloutContent: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - // Type indicator only (name is in native callout title) + Text(contact.displayName) + .font(.headline) + HStack(spacing: 6) { - Image(systemName: typeIconName) - .foregroundStyle(typeColor) + Image(systemName: contact.type.iconSystemName) + .foregroundStyle(contact.type.displayColor) Text(typeDisplayName) .font(.subheadline) .foregroundStyle(.secondary) } + .accessibilityElement(children: .combine) Divider() @@ -24,44 +27,22 @@ struct ContactCalloutContent: View { VStack(spacing: 6) { Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) .buttonStyle(.bordered) - .controlSize(.small) + .accessibilityHint(contact.displayName) if contact.type == .chat || contact.type == .room { Button(L10n.Map.Map.Callout.message, systemImage: "message.fill", action: onMessage) .buttonStyle(.bordered) - .controlSize(.small) + .accessibilityHint(contact.displayName) } } .frame(maxWidth: .infinity) } .padding(12) - .frame(width: 160) + .frame(minWidth: 160) } // MARK: - Computed Properties - private var typeIconName: String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } - - private var typeColor: Color { - switch contact.type { - case .chat: - .blue - case .repeater: - .green - case .room: - .purple - } - } - private var typeDisplayName: String { switch contact.type { case .chat: diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift new file mode 100644 index 000000000..1a535b687 --- /dev/null +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -0,0 +1,209 @@ +import SwiftUI +import MC1Services + +// MARK: - Contact Detail Sheet + +struct ContactDetailSheet: View { + let contact: ContactDTO + let onMessage: () -> Void + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + /// Sheet types for repeater flows + private enum ActiveSheet: Identifiable, Hashable { + case telemetryAuth + case telemetryStatus(RemoteNodeSessionDTO) + case adminAuth + case adminSettings(RemoteNodeSessionDTO) + case roomJoin + + var id: String { + switch self { + case .telemetryAuth: "telemetryAuth" + case .telemetryStatus(let s): "telemetryStatus-\(s.id)" + case .adminAuth: "adminAuth" + case .adminSettings(let s): "adminSettings-\(s.id)" + case .roomJoin: "roomJoin" + } + } + } + + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + + var body: some View { + NavigationStack { + List { + // Basic info section + Section(L10n.Map.Map.Detail.Section.contactInfo) { + LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) + + LabeledContent(L10n.Map.Map.Detail.type) { + HStack { + Image(systemName: contact.type.iconSystemName) + Text(typeDisplayName) + } + .foregroundStyle(contact.type.displayColor) + } + + if contact.isFavorite { + LabeledContent(L10n.Map.Map.Detail.status) { + HStack { + Image(systemName: "star.fill") + Text(L10n.Map.Map.Detail.favorite) + } + .foregroundStyle(.orange) + } + } + + if contact.lastAdvertTimestamp > 0 { + LabeledContent(L10n.Map.Map.Detail.lastAdvert) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) + } + } + } + + // Location section + Section(L10n.Map.Map.Detail.Section.location) { + LabeledContent(L10n.Map.Map.Detail.latitude) { + Text(contact.latitude, format: .number.precision(.fractionLength(6))) + } + + LabeledContent(L10n.Map.Map.Detail.longitude) { + Text(contact.longitude, format: .number.precision(.fractionLength(6))) + } + } + + // Path info section + Section(L10n.Map.Map.Detail.Section.networkPath) { + if contact.isFloodRouted { + LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) + } else { + let hopCount = contact.pathHopCount + LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) + } + } + + // Actions section + Section { + switch contact.type { + case .repeater: + Button { + activeSheet = .telemetryAuth + } label: { + Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: appState.connectionState) + + Button { + activeSheet = .adminAuth + } label: { + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") + } + .radioDisabled(for: appState.connectionState) + + case .room: + Button { + activeSheet = .roomJoin + } label: { + Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") + } + .radioDisabled(for: appState.connectionState) + + case .chat: + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") + } + .radioDisabled(for: appState.connectionState) + } + } + } + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + dismiss() + } + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .telemetryAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet( + contact: contact, + role: role, + customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle + ) { session in + pendingSheet = .telemetryStatus(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .telemetryStatus(let session): + RepeaterStatusView(session: session) + + case .adminAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + pendingSheet = .adminSettings(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .adminSettings(let session): + NavigationStack { + RepeaterSettingsView(session: session) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + activeSheet = nil + } + } + } + } + .presentationSizing(.page) + + case .roomJoin: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + activeSheet = nil + dismiss() + appState.navigation.navigateToRoom(with: session) + } + .presentationSizing(.page) + } + } + } + } + } + + // MARK: - Sheet Management + + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next + } + } + + // MARK: - Computed Properties + + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.NodeKind.chatContact + case .repeater: + L10n.Map.Map.NodeKind.repeater + case .room: + L10n.Map.Map.NodeKind.room + } + } + +} diff --git a/MC1/Views/Map/ContactNameLabel.swift b/MC1/Views/Map/ContactNameLabel.swift deleted file mode 100644 index 462a32539..000000000 --- a/MC1/Views/Map/ContactNameLabel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import SwiftUI -import MC1Services - -/// Small label displaying contact name above map pins -struct ContactNameLabel: View { - let name: String - - var body: some View { - Text(name) - .font(.caption2) - .fontWeight(.medium) - .lineLimit(1) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.25), radius: 3, y: 1.5) - } -} - -#Preview { - VStack(spacing: 20) { - ContactNameLabel(name: "Alice") - ContactNameLabel(name: "Hilltop Repeater Station") - ContactNameLabel(name: "Emergency Room") - } - .padding() -} diff --git a/MC1/Views/Map/ContactPinView.swift b/MC1/Views/Map/ContactPinView.swift deleted file mode 100644 index e0db5ca47..000000000 --- a/MC1/Views/Map/ContactPinView.swift +++ /dev/null @@ -1,298 +0,0 @@ -import MapKit -import SwiftUI -import MC1Services - -/// Custom annotation view displaying a colored circle with icon and pointer triangle -final class ContactPinView: MKAnnotationView { - static let reuseIdentifier = "ContactPinView" - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - private var nameLabelShadow: UIView? - private var hostingController: UIHostingController? - - // MARK: - Configuration - - var showsNameLabel: Bool = false { - didSet { updateNameLabel() } - } - - /// Callbacks for callout actions - var onDetail: (() -> Void)? - var onMessage: (() -> Void)? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - canShowCallout = true - clusteringIdentifier = "contact" - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - // Configure circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Configure icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - circleView.addSubview(iconImageView) - - // Configure triangle pointer - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - addSubview(triangleImageView) - - // Initial layout for unselected state - updateLayout(selected: false) - } - - // MARK: - Configuration - - func configure(for contact: ContactDTO) { - // Set colors based on contact type - let backgroundColor = pinColor(for: contact) - circleView.backgroundColor = backgroundColor - triangleImageView.tintColor = backgroundColor - - // Set icon - let iconName = iconName(for: contact) - iconImageView.image = UIImage(systemName: iconName) - - // Set display priority - displayPriority = contact.isFavorite ? .defaultHigh : .defaultLow - - // Update layout - updateLayout(selected: isSelected) - } - - // MARK: - Selection - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - if animated { - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - self.updateLayout(selected: selected) - } - } else { - updateLayout(selected: selected) - } - - // Update name label visibility since it depends on isSelected state - updateNameLabel() - - // Configure callout content when selected - if selected, let contactAnnotation = annotation as? ContactAnnotation { - configureCalloutContent(for: contactAnnotation.contact) - } - } - - private func configureCalloutContent(for contact: ContactDTO) { - let calloutContent = ContactCalloutContent( - contact: contact, - onDetail: { [weak self] in self?.onDetail?() }, - onMessage: { [weak self] in self?.onMessage?() } - ) - - let hosting = UIHostingController(rootView: calloutContent) - hosting.view.backgroundColor = .clear - - // Size the hosting view - MKMapView uses intrinsic content size for callout layout - let size = hosting.sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) - hosting.view.frame = CGRect(origin: .zero, size: size) - - detailCalloutAccessoryView = hosting.view - hostingController = hosting - } - - // MARK: - Layout - - private func updateLayout(selected: Bool) { - let circleSize: CGFloat = selected ? 44 : 36 - let iconSize: CGFloat = selected ? 20 : 16 - let triangleSize: CGFloat = 10 - - // Remove existing constraints - circleView.constraints.forEach { circleView.removeConstraint($0) } - iconImageView.constraints.forEach { iconImageView.removeConstraint($0) } - triangleImageView.constraints.forEach { triangleImageView.removeConstraint($0) } - - // Circle constraints - NSLayoutConstraint.activate([ - circleView.widthConstraint(circleSize), - circleView.heightConstraint(circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor) - ]) - - // Icon constraints - NSLayoutConstraint.activate([ - iconImageView.widthConstraint(iconSize), - iconImageView.heightConstraint(iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - // Triangle constraints - NSLayoutConstraint.activate([ - triangleImageView.widthConstraint(triangleSize), - triangleImageView.heightConstraint(triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - // Update circle corner radius - circleView.layer.cornerRadius = circleSize / 2 - - // Update border for selected state - if selected { - circleView.layer.borderWidth = 3 - circleView.layer.borderColor = UIColor.white.cgColor - } else { - circleView.layer.borderWidth = 0 - } - - // Update frame - let totalHeight = circleSize + triangleSize - 3 - frame = CGRect(x: 0, y: 0, width: circleSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - } - - // MARK: - Name Label - - private func updateNameLabel() { - if showsNameLabel && !isSelected { - if nameLabel == nil { - // Blur background matching app's material style - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.layer.masksToBounds = true - addSubview(blur) - - // Shadow container (separate from blur since blur clips) - let shadow = UIView() - shadow.translatesAutoresizingMaskIntoConstraints = false - shadow.backgroundColor = .clear - shadow.layer.shadowColor = UIColor.black.cgColor - shadow.layer.shadowOpacity = 0.3 - shadow.layer.shadowRadius = 3 - shadow.layer.shadowOffset = CGSize(width: 0, height: 1.5) - insertSubview(shadow, belowSubview: blur) - nameLabelContainer = blur - nameLabelShadow = shadow - - // Label with Dynamic Type support - let label = UILabel() - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - blur.contentView.addSubview(label) - nameLabel = label - - NSLayoutConstraint.activate([ - blur.centerXAnchor.constraint(equalTo: centerXAnchor), - blur.bottomAnchor.constraint(equalTo: topAnchor, constant: -4), - shadow.topAnchor.constraint(equalTo: blur.topAnchor), - shadow.bottomAnchor.constraint(equalTo: blur.bottomAnchor), - shadow.leadingAnchor.constraint(equalTo: blur.leadingAnchor), - shadow.trailingAnchor.constraint(equalTo: blur.trailingAnchor), - label.topAnchor.constraint(equalTo: blur.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.trailingAnchor, constant: -8) - ]) - } - - if let contactAnnotation = annotation as? ContactAnnotation { - nameLabel?.text = contactAnnotation.contact.displayName - } - nameLabelContainer?.isHidden = false - nameLabelShadow?.isHidden = false - } else { - nameLabelContainer?.isHidden = true - nameLabelShadow?.isHidden = true - } - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onDetail = nil - onMessage = nil - hostingController = nil - detailCalloutAccessoryView = nil - nameLabelContainer?.isHidden = true - nameLabelShadow?.isHidden = true - } - - override func prepareForDisplay() { - super.prepareForDisplay() - - if let contactAnnotation = annotation as? ContactAnnotation { - configure(for: contactAnnotation.contact) - } - } - - // MARK: - Helpers - - private func pinColor(for contact: ContactDTO) -> UIColor { - switch contact.type { - case .chat: - UIColor(red: 204.0 / 255.0, green: 122.0 / 255.0, blue: 92.0 / 255.0, alpha: 1) // coral #cc7a5c - case .repeater: - UIColor(red: 0, green: 170.0 / 255.0, blue: 1, alpha: 1) // MeshCore cyan #00aaff - case .room: - UIColor(red: 1, green: 136.0 / 255.0, blue: 0, alpha: 1) // orange #ff8800 (matches Nodes) - } - } - - private func iconName(for contact: ContactDTO) -> String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } -} - -// MARK: - Constraint Helpers - -private extension UIView { - func widthConstraint(_ constant: CGFloat) -> NSLayoutConstraint { - widthAnchor.constraint(equalToConstant: constant) - } - - func heightConstraint(_ constant: CGFloat) -> NSLayoutConstraint { - heightAnchor.constraint(equalToConstant: constant) - } -} diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index 55f4b08c9..4f577cd94 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -1,13 +1,20 @@ +import MapLibre import SwiftUI /// Dropdown menu for selecting map layers struct LayersMenu: View { + @Environment(\.appState) private var appState @Binding var selection: MapStyleSelection @Binding var isPresented: Bool + var viewportBounds: MLNCoordinateBounds? var body: some View { VStack(spacing: 0) { ForEach(MapStyleSelection.allCases, id: \.self) { style in + let isDisabled = !appState.offlineMapService.isNetworkAvailable + && (style.requiresNetwork + || !hasOfflineCoverage(for: style)) + Button { selection = style withAnimation { @@ -16,16 +23,18 @@ struct LayersMenu: View { } label: { HStack { Text(style.label) - .foregroundStyle(.primary) + .foregroundStyle(isDisabled ? .secondary : .primary) Spacer() if selection == style { Image(systemName: "checkmark") - .foregroundStyle(.blue) + .foregroundStyle(.tint) } } .padding(.horizontal, 16) .padding(.vertical, 12) } + .disabled(isDisabled) + .accessibilityHint(isDisabled ? disabledReason(for: style) : "") if style != MapStyleSelection.allCases.last { Divider() @@ -35,6 +44,24 @@ struct LayersMenu: View { .frame(width: 140) .liquidGlass(in: .rect(cornerRadius: 12)) .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Map.Map.Style.accessibilityLabel) + } + + private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { + if let viewportBounds { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) + } else { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) + } + } + + private func disabledReason(for style: MapStyleSelection) -> String { + if style.requiresNetwork { + L10n.Map.Map.Style.requiresNetwork + } else { + L10n.Map.Map.Style.noOfflineCoverage + } } } @@ -44,4 +71,5 @@ struct LayersMenu: View { isPresented: .constant(true) ) .padding() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift new file mode 100644 index 000000000..094c46aaa --- /dev/null +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -0,0 +1,389 @@ +import MapLibre +import UIKit + +/// Font stack available on the OpenFreeMap glyph server. +/// MapLibre's default ("Open Sans Regular") returns 404, causing silent symbol dropout. +/// Safety: immutable after initialization, only read from @MainActor coordinator methods. +private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: ["Noto Sans Regular"]) + +// MARK: - Layer and source identifiers + +enum MapLayerID { + static let clusterCircles = "cluster-circles" + static let clusterLabels = "cluster-labels" + static let unclusteredIcons = "unclustered-icons" + static let nameLabels = "name-labels" + static let badgeText = "badge-text" + static let fixedIcons = "fixed-icons" + static let fixedNameLabels = "fixed-name-labels" + static let fixedBadgeText = "fixed-badge-text" + static let lineLOS = "line-los" + static let lineTraceUntraced = "line-trace-untraced" + static let lineTraceWeak = "line-trace-weak" + static let lineTraceMedium = "line-trace-medium" + static let lineTraceGood = "line-trace-good" + static let lineTraceUntracedCasing = "line-trace-untraced-casing" + static let lineTraceWeakCasing = "line-trace-weak-casing" + static let lineTraceMediumCasing = "line-trace-medium-casing" + static let lineTraceGoodCasing = "line-trace-good-casing" + static let satelliteLayer = "satellite-layer" + static let topoLayer = "topo-layer" +} + +enum MapSourceID { + static let points = "points" + static let fixedPoints = "fixed-points" + static let lines = "lines" + static let satelliteTiles = "satellite-tiles" + static let topoTiles = "topo-tiles" +} + +extension MC1MapView.Coordinator { + + // MARK: - Update point source data + + /// Point sources and layers use deferred creation: they are created here + /// on first data arrival, not during style load. This avoids a MapLibre + /// bug where sources initialized without features ignore later `.shape` + /// updates. + func updatePointSource(mapView: MLNMapView) { + guard let style = mapView.style else { return } + + var clusterablePoints: [MapPoint] = [] + var fixedPoints: [MapPoint] = [] + for point in currentPoints { + if point.isClusterable { + clusterablePoints.append(point) + } else { + fixedPoints.append(point) + } + } + + // Clustered source — deferred creation on first data arrival + if let source = clusterSource { + source.shape = MLNShapeCollectionFeature( + shapes: clusterablePoints.map { pointFeature(for: $0) } + ) + } else if !clusterablePoints.isEmpty { + let features = clusterablePoints.map { pointFeature(for: $0) } + let source = MLNShapeSource( + identifier: MapSourceID.points, + features: features, + options: [ + .clustered: true, + .clusterRadius: 44, + .maximumZoomLevelForClustering: 14, + ] + ) + style.addSource(source) + self.clusterSource = source + addClusteredPointLayers(source: source, style: style) + } + + // Fixed source — deferred creation + if let source = fixedSource { + source.shape = MLNShapeCollectionFeature( + shapes: fixedPoints.map { pointFeature(for: $0) } + ) + } else if !fixedPoints.isEmpty { + let features = fixedPoints.map { pointFeature(for: $0) } + let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) + style.addSource(source) + self.fixedSource = source + addFixedPointLayers(source: source, style: style) + } + } + + func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { + for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { + guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } + layer.isVisible = showLabels + } + } + + // MARK: - Clustered point layers + + private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { + // Cluster circles + let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) + circleLayer.predicate = NSPredicate(format: "cluster == YES") + let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] + circleLayer.circleRadius = NSExpression( + forMLNStepping: NSExpression(forKeyPath: "point_count"), + from: NSExpression(forConstantValue: 18), + stops: NSExpression(forConstantValue: radiusStops) + ) + circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) + circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) + circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) + circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) + style.addLayer(circleLayer) + + // Cluster count labels + let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) + clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") + clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") + clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) + clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) + clusterLabelLayer.textFontNames = mapFontNames + clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + style.addLayer(clusterLabelLayer) + + // Unclustered pin icons + let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) + iconLayer.predicate = NSPredicate(format: "cluster != YES") + iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + iconLayer.text = nil + style.addLayer(iconLayer) + + // Name labels (above pins) with pill background + let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") + configureNameLabelLayer(nameLabelLayer) + style.addLayer(nameLabelLayer) + + // Stats badge text (trace path midpoints) with pill background + let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) + badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") + configureBadgeLayer(badgeLayer) + style.addLayer(badgeLayer) + } + + // MARK: - Fixed point layers + + private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { + let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) + fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + fixedIconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + fixedIconLayer.text = nil + style.addLayer(fixedIconLayer) + + let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) + fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") + configureNameLabelLayer(fixedNameLayer) + style.addLayer(fixedNameLayer) + + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) + fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") + configureBadgeLayer(fixedBadgeLayer) + style.addLayer(fixedBadgeLayer) + } + + // MARK: - Line layers + + func setupLineLayers(style: MLNStyle) { + guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } + let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) + style.addSource(source) + + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) + losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + losLayer.lineWidth = NSExpression(forConstantValue: 3) + losLayer.lineDashPattern = NSExpression(forConstantValue: [8, 4]) + losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") + style.addLayer(losLayer) + + let white = NSExpression(forConstantValue: UIColor.white) + let casingOpacity = NSExpression(forConstantValue: 0.8) + let roundJoin = NSExpression(forConstantValue: "round") + let roundCap = NSExpression(forConstantValue: "round") + + // Untraced: width 2, dash [8, 6] → casing width 5, dash scaled by 2/5 + let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) + untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedCasing.lineColor = white + untracedCasing.lineOpacity = casingOpacity + untracedCasing.lineWidth = NSExpression(forConstantValue: 5) + untracedCasing.lineDashPattern = NSExpression(forConstantValue: [1.6, 1.2]) + untracedCasing.lineJoin = roundJoin + untracedCasing.lineCap = roundCap + style.addLayer(untracedCasing) + + let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) + untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) + untracedLayer.lineWidth = NSExpression(forConstantValue: 2) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [4, 3]) + style.addLayer(untracedLayer) + + // Weak: width 3, dash [4, 4] → casing width 6, dash scaled by 3/6 + let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) + weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakCasing.lineColor = white + weakCasing.lineOpacity = casingOpacity + weakCasing.lineWidth = NSExpression(forConstantValue: 6) + weakCasing.lineDashPattern = NSExpression(forConstantValue: [1, 1]) + weakCasing.lineJoin = roundJoin + weakCasing.lineCap = roundCap + style.addLayer(weakCasing) + + let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) + weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [2, 2]) + style.addLayer(weakLayer) + + // Medium: width 3, dash [12, 4] → casing width 6, dash scaled by 3/6 + let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) + mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumCasing.lineColor = white + mediumCasing.lineOpacity = casingOpacity + mediumCasing.lineWidth = NSExpression(forConstantValue: 6) + mediumCasing.lineDashPattern = NSExpression(forConstantValue: [3, 1]) + mediumCasing.lineJoin = roundJoin + mediumCasing.lineCap = roundCap + style.addLayer(mediumCasing) + + let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) + mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [6, 2]) + style.addLayer(mediumLayer) + + // Good: width 4, solid → casing width 7 + let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) + goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodCasing.lineColor = white + goodCasing.lineOpacity = casingOpacity + goodCasing.lineWidth = NSExpression(forConstantValue: 7) + goodCasing.lineJoin = roundJoin + goodCasing.lineCap = roundCap + style.addLayer(goodCasing) + + let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) + goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGreen) + goodLayer.lineWidth = NSExpression(forConstantValue: 4) + style.addLayer(goodLayer) + } + + func updateLineSource(mapView: MLNMapView) { + guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } + + let features = currentLines.map { line -> MLNPolylineFeature in + var coords = line.coordinates + let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) + feature.attributes = [ + "lineStyle": line.style.rawValue, + "segmentOpacity": line.opacity, + ] + return feature + } + source.shape = MLNShapeCollectionFeature(shapes: features) + } + + // MARK: - Raster tile sources + + func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { + guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { + updateRasterLayerVisibility(mapView: mapView) + return + } + let satSource = MLNRasterTileSource( + identifier: MapSourceID.satelliteTiles, + tileURLTemplates: [MapTileURLs.esriWorldImagery], + options: [ + .tileSize: 256, + .maximumZoomLevel: 19, + .attributionHTMLString: "Esri", + ] + ) + style.addSource(satSource) + let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) + satLayer.isVisible = false + style.addLayer(satLayer) + + let topoSource = MLNRasterTileSource( + identifier: MapSourceID.topoTiles, + tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], + options: [ + .tileSize: 256, + .maximumZoomLevel: 17, + .attributionHTMLString: "OpenTopoMap", + ] + ) + style.addSource(topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) + topoLayer.isVisible = false + style.addLayer(topoLayer) + + updateRasterLayerVisibility(mapView: mapView) + } + + func updateRasterLayerVisibility(mapView: MLNMapView) { + guard let style = mapView.style else { return } + style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + } + + // MARK: - Shared layer configuration + + private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { + layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") + layer.iconAnchor = NSExpression(forConstantValue: "bottom") + layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -48))) // -4.8 ems × 10pt font + layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") + layer.iconAllowsOverlap = NSExpression(forConstantValue: true) + layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "badgeText") + layer.textFontSize = NSExpression(forConstantValue: 11) + layer.textFontNames = mapFontNames + layer.textColor = NSExpression(forConstantValue: UIColor.black) + layer.textAllowsOverlap = NSExpression(forConstantValue: true) + layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconImageName = NSExpression(forConstantValue: "pill-bg") + layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + } + + // MARK: - Private helpers + + private func pointFeature(for point: MapPoint) -> MLNPointFeature { + let feature = MLNPointFeature() + feature.coordinate = point.coordinate + var attributes: [String: Any] = [ + "pointId": point.id.uuidString, + "spriteName": spriteName(for: point), + ] + if let label = point.label { + attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" + } + if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } + if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } + feature.attributes = attributes + return feature + } + + private func spriteName(for point: MapPoint) -> String { + switch point.pinStyle { + case .contactChat: "pin-chat" + case .contactRepeater: "pin-repeater" + case .contactRoom: "pin-room" + case .repeater: "pin-repeater" + case .repeaterRingBlue: "pin-repeater-ring-blue" + case .repeaterRingGreen: "pin-repeater-ring-green" + case .repeaterRingWhite: + if let hop = point.hopIndex { + "pin-repeater-ring-white-hop-\(min(hop, 20))" + } else { + "pin-repeater-ring-white" + } + case .pointA: "pin-point-a" + case .pointB: "pin-point-b" + case .crosshair: "pin-crosshair" + case .badge: "pin-badge" + } + } +} diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift new file mode 100644 index 000000000..496b29bba --- /dev/null +++ b/MC1/Views/Map/MC1MapView.swift @@ -0,0 +1,504 @@ +import MapLibre +import MapKit +import ObjectiveC +import OSLog +import SwiftUI + +private let logger = Logger(subsystem: "com.mc1", category: "MapPins") + +// MARK: - MapLibre Metal scale fix + +/// Workaround for a MapLibre bug where `MLNEffectiveScaleFactorForView` +/// computes `nativeBounds.width / bounds.width` — a ratio that breaks in +/// landscape because `nativeBounds` is fixed while `bounds` rotates. +/// We intercept both `setDrawableSize:` and `setContentScaleFactor:` on +/// MapLibre's internal Metal UIView so the wrong scale is never stored. +/// Upstream issue: https://github.com/maplibre/maplibre-native/issues/3214 +private enum MetalLayerScaleFix { + + static func apply(to mapView: MLNMapView) { + guard let metalView = findMetalView(in: mapView) else { return } + + let selector = NSSelectorFromString("setDrawableSize:") + guard metalView.responds(to: selector) else { return } + + guard let originalClass: AnyClass = object_getClass(metalView) else { return } + let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" + + let fixedClass: AnyClass + if let existing = objc_getClass(name) as? AnyClass { + fixedClass = existing + } else { + guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } + addDrawableSizeOverride(to: subclass, originalClass: originalClass) + addContentScaleFactorOverride(to: subclass, originalClass: originalClass) + objc_registerClassPair(subclass) + fixedClass = subclass + } + + object_setClass(metalView, fixedClass) + } + + private static func findMetalView(in view: UIView) -> UIView? { + for subview in view.subviews where subview.layer is CAMetalLayer { + return subview + } + return nil + } + + private static func findMapView(from metalView: UIView) -> MLNMapView? { + var parent: UIView? = metalView.superview + while let v = parent, !(v is MLNMapView) { parent = v.superview } + return parent as? MLNMapView + } + + private static func addDrawableSizeOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setDrawableSize:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetDrawableSizeFn = @convention(c) (AnyObject, Selector, CGSize) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) + + let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in + guard let mapView = findMapView(from: metalView), + mapView.bounds.size.width > 0, + mapView.bounds.size.height > 0, + let screen = mapView.window?.screen else { + callOriginal(metalView, selector, proposedSize) + return + } + + let correctScale = screen.nativeScale + let correctSize = CGSize( + width: mapView.bounds.width * correctScale, + height: mapView.bounds.height * correctScale + ) + + // Avoid redundant drawable reallocation and layout loops. + if let layer = metalView.layer as? CAMetalLayer, + layer.drawableSize == correctSize { + return + } + + callOriginal(metalView, selector, correctSize) + } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } + + private static func addContentScaleFactorOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setContentScaleFactor:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetScaleFn = @convention(c) (AnyObject, Selector, CGFloat) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) + + let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in + guard let mapView = findMapView(from: metalView), + let screen = mapView.window?.screen else { + return + } + + let correctScale = screen.nativeScale + if metalView.contentScaleFactor == correctScale { + return + } + + callOriginal(metalView, selector, correctScale) + } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } +} + +/// Applies the isa-swizzle once the view is attached to a window. +private final class ScaledMLNMapView: MLNMapView { + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + MetalLayerScaleFix.apply(to: self) + } +} + +struct MC1MapView: UIViewRepresentable { + // Data + let points: [MapPoint] + let lines: [MapLine] + let mapStyle: MapStyleSelection + let isDarkMode: Bool + var isOffline: Bool = false + + // Configuration + let showLabels: Bool + let showsUserLocation: Bool + let isInteractive: Bool + let showsScale: Bool + var isNorthLocked: Bool = false + + // Camera + @Binding var cameraRegion: MKCoordinateRegion? + let cameraRegionVersion: Int + var cameraEdgePadding: UIEdgeInsets = .zero + var cameraBottomSheetFraction: CGFloat? + + // Output callbacks + let onPointTap: ((MapPoint, CGPoint) -> Void)? + let onMapTap: ((CLLocationCoordinate2D) -> Void)? + let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + + // Optional features + var isStyleLoaded: Binding = .constant(true) + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = context.coordinator.mapView + mapView.delegate = context.coordinator + + mapView.showsUserLocation = showsUserLocation + mapView.compassViewPosition = .topRight + mapView.compassViewMargins = CGPoint(x: 8, y: 8) + mapView.attributionButtonPosition = .bottomLeft + mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) + + if showsScale { + mapView.showsScale = true + } + + if !isInteractive { + mapView.isScrollEnabled = false + mapView.isZoomEnabled = false + mapView.isRotateEnabled = false + mapView.isPitchEnabled = false + mapView.compassView.isHidden = true + } + + // Disable quick-zoom (tap-then-hold-drag) gesture + mapView.gestureRecognizers? + .compactMap { $0 as? UILongPressGestureRecognizer } + .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } + .forEach { $0.isEnabled = false } + + // Tap gesture for feature queries + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.delegate = context.coordinator + mapView.addGestureRecognizer(tap) + + return mapView + } + + static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { + coordinator.pendingRegionTask?.cancel() + mapView.delegate = nil + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + let coordinator = context.coordinator + coordinator.isUpdatingFromSwiftUI = true + defer { coordinator.isUpdatingFromSwiftUI = false } + + // Refresh callbacks + coordinator.onPointTap = onPointTap + coordinator.onMapTap = onMapTap + coordinator.onCameraRegionChange = onCameraRegionChange + coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } + coordinator.currentPoints = points + coordinator.currentLines = lines + + // Style URL change — compare against our tracked value, not mapView.styleURL + // which MapLibre may transiently nil during layout/rotation. + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) + if coordinator.lastAppliedStyleURL != newStyleURL { + coordinator.lastAppliedStyleURL = newStyleURL + coordinator.isStyleLoaded = false + mapView.styleURL = newStyleURL + } + let mapStyleChanged = coordinator.currentMapStyle != mapStyle + coordinator.currentMapStyle = mapStyle + + // User location + if mapView.showsUserLocation != showsUserLocation { + mapView.showsUserLocation = showsUserLocation + } + + // North lock + if isInteractive { + mapView.isRotateEnabled = !isNorthLocked + if isNorthLocked && mapView.direction != 0 { + mapView.setDirection(0, animated: true) + } + } + + // Update data layers (only when style is loaded and not mid-gesture). + // Compare against lastApplied* so updates arriving during a gesture + // are applied once the gesture ends. + if coordinator.isStyleLoaded, !coordinator.isUserInteracting { + if mapStyleChanged { + coordinator.updateRasterLayerVisibility(mapView: mapView) + } + if coordinator.lastAppliedPoints != points { + coordinator.updatePointSource(mapView: mapView) + coordinator.lastAppliedPoints = points + } + if coordinator.lastAppliedLines != lines { + coordinator.updateLineSource(mapView: mapView) + coordinator.lastAppliedLines = lines + } + if coordinator.currentShowLabels != showLabels { + coordinator.currentShowLabels = showLabels + coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) + } + } + + // Camera region (version-number pattern) + updateCameraRegion(in: mapView, coordinator: coordinator) + } + + private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { + guard let region = cameraRegion else { return } + guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } + + let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false + let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated + coordinator.lastAppliedRegionVersion = cameraRegionVersion + + let bounds = MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - region.span.latitudeDelta / 2, + longitude: region.center.longitude - region.span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + region.span.latitudeDelta / 2, + longitude: region.center.longitude + region.span.longitudeDelta / 2 + ) + ) + var padding = cameraEdgePadding + if let sheetFraction = cameraBottomSheetFraction { + let insets = mapView.safeAreaInsets + padding.top = max(padding.top, insets.top + 20) + padding.left = max(padding.left, insets.left + 20) + if sheetFraction > 0 { + let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height + padding.bottom = max(padding.bottom, stableHeight * sheetFraction) + } + } + + if let windowSize = mapView.window?.bounds.size, + mapView.bounds.height > windowSize.height * 1.5 { + let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 + let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 + let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 + let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 + * cos(centerLat * .pi / 180) + + let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) + let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) + + let mppForLat = latSpanMeters / usableHeight + let mppForLon = lonSpanMeters / usableWidth + let requiredMPP = max(mppForLat, mppForLon) + + let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) + let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) + + let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 + let offsetDeg = pixelOffset * requiredMPP / 111_000 + let center = CLLocationCoordinate2D( + latitude: centerLat + offsetDeg, + longitude: centerLon + ) + + mapView.setCenter(center, zoomLevel: targetZoom, animated: false) + } else { + mapView.setVisibleCoordinateBounds(bounds, edgePadding: padding, animated: animated) + } + } +} + +// MARK: - Coordinator + +extension MC1MapView { + @MainActor + class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { + // Non-zero frame avoids MapLibre zero-size Metal init (issue #67). + let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + + // Callbacks + var onPointTap: ((MapPoint, CGPoint) -> Void)? + var onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + var setIsStyleLoaded: ((Bool) -> Void)? + + // State + var isUserInteracting = false + var isUpdatingFromSwiftUI = false + var isStyleLoaded = false + var lastAppliedRegionVersion = 0 + var pendingRegionTask: Task? + var currentShowLabels = true + var lastAppliedStyleURL: URL? + var currentMapStyle: MapStyleSelection? + var currentPoints: [MapPoint] = [] + var currentLines: [MapLine] = [] + var lastAppliedPoints: [MapPoint] = [] + var lastAppliedLines: [MapLine] = [] + var clusterSource: MLNShapeSource? + var fixedSource: MLNShapeSource? + + // MARK: - Style loading + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + isStyleLoaded = true + setIsStyleLoaded?(true) + + // Clear stale source/state references from the previous style. + // Reset currentShowLabels to the new layer default (visible) so + // updateUIView detects the mismatch and reapplies the user's preference. + clusterSource = nil + fixedSource = nil + lastAppliedPoints = [] + lastAppliedLines = [] + currentShowLabels = true + + PinSpriteRenderer.renderAll(into: style) + setupRasterSources(style: style, mapView: mapView) + setupLineLayers(style: style) + + updatePointSource(mapView: mapView) + updateLineSource(mapView: mapView) + } + + func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + if let style = mapView.style, + let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return image + } + logger.error("didFailToLoadImage: \(imageName)") + return nil + } + + // MARK: - Region changes + + private static let userGestureReasons: MLNCameraChangeReason = [ + .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, + .gestureRotate, .gestureTilt, .gestureOneFingerZoom + ] + + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + isUserInteracting = true + } + + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + isUserInteracting = false + guard !isUpdatingFromSwiftUI else { return } + + let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) + guard isUserGesture else { return } + + // Debounce: cancel previous pending write-back + pendingRegionTask?.cancel() + pendingRegionTask = Task { + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + let region = mapView.mlnRegion + self.onCameraRegionChange?(region) + } + } + + // MARK: - Gesture recognizer delegate + + nonisolated func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + true + } + + // MARK: - Tap handling + + @objc func handleTap(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { return } + let point = sender.location(in: mapView) + let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) + logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") + + // 1. Check cluster layers + let clusterFeatures = mapView.visibleFeatures( + in: clusterRect, + styleLayerIdentifiers: [MapLayerID.clusterCircles] + ) + if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, + let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { + let zoom = source.zoomLevel(forExpanding: cluster) + guard zoom >= 0 else { return } + mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) + return + } + + // 2. Check point and name label layers (both clustered and fixed) + let pointFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [ + MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, + MapLayerID.nameLabels, MapLayerID.fixedNameLabels + ] + ) + logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") + if let feature = pointFeatures.first, + let idString = feature.attribute(forKey: "pointId") as? String, + let id = UUID(uuidString: idString), + let mapPoint = currentPoints.first(where: { $0.id == id }) { + logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") + let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) + let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) + onPointTap?(mapPoint, calloutAnchor) + return + } + + // 3. Check badge text layers — dismiss any open callout but don't select + let badgeFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] + ) + if badgeFeatures.first != nil { + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + return + } + + // 4. Map background tap + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + } + } +} + +// MARK: - MLNMapView region helper + +extension MLNMapView { + var mlnRegion: MKCoordinateRegion { + let bounds = visibleCoordinateBounds + let center = CLLocationCoordinate2D( + latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, + longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 + ) + let span = MKCoordinateSpan( + latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, + longitudeDelta: bounds.ne.longitude - bounds.sw.longitude + ) + return MKCoordinateRegion(center: center, span: span) + } +} diff --git a/MC1/Views/Map/MKMapViewRepresentable.swift b/MC1/Views/Map/MKMapViewRepresentable.swift deleted file mode 100644 index deafcc82d..000000000 --- a/MC1/Views/Map/MKMapViewRepresentable.swift +++ /dev/null @@ -1,399 +0,0 @@ -import MapKit -import os -import SwiftUI -import MC1Services - -private let logger = Logger(subsystem: "com.mc1", category: "MapRepresentable") - -/// UIViewRepresentable wrapper for MKMapView with custom contact annotations -struct MKMapViewRepresentable: UIViewRepresentable { - let contacts: [ContactDTO] - let mapType: MKMapType - let showLabels: Bool - let showsUserLocation: Bool - - @Binding var selectedContact: ContactDTO? - @Binding var cameraRegion: MKCoordinateRegion? - - // Callbacks for callout actions - let onDetailTap: (ContactDTO) -> Void - let onMessageTap: (ContactDTO) -> Void - /// Called once with a closure that returns snapshot parameters from the actual MKMapView (bypasses async binding) - var onSnapshotParamsGetter: ((@escaping () -> (camera: MKMapCamera, size: CGSize)?) -> Void)? - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - - mapView.delegate = context.coordinator - mapView.showsUserLocation = showsUserLocation - - // Register annotation views - mapView.register( - ContactPinView.self, - forAnnotationViewWithReuseIdentifier: ContactPinView.reuseIdentifier - ) - mapView.register( - MKMarkerAnnotationView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - - // Provide closure to get snapshot params directly from MKMapView (bypasses async binding lag) - onSnapshotParamsGetter? { [weak mapView] in - guard let mapView else { return nil } - // swiftlint:disable:next force_cast - return (camera: mapView.camera.copy() as! MKMapCamera, size: mapView.bounds.size) - } - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - // Update binding setters each render cycle - coordinator.setSelectedContact = { selectedContact = $0 } - coordinator.setCameraRegion = { cameraRegion = $0 } - coordinator.onDetailTap = onDetailTap - coordinator.onMessageTap = onMessageTap - coordinator.showLabels = showLabels - - // Mark as programmatic update to prevent feedback loops - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Update map type - mapView.mapType = mapType - - // Update user location visibility - mapView.showsUserLocation = showsUserLocation - - // Update annotations - updateAnnotations(in: mapView, coordinator: coordinator) - - // Update selection state - updateSelection(in: mapView, coordinator: coordinator) - - // Update region if changed programmatically - if let region = cameraRegion { - // Check if binding has caught up with pending user gesture - if let pendingGesture = coordinator.pendingUserGestureRegion { - if region.isApproximatelyEqual(to: pendingGesture) { - // Binding now reflects user gesture, clear pending state - logger.debug("Region: binding caught up, clearing pendingUserGestureRegion") - coordinator.pendingUserGestureRegion = nil - } else { - // Binding is stale (hasn't caught up with user gesture), skip applying - logger.debug("Region: binding stale (span=\(region.span.latitudeDelta, format: .fixed(precision: 4))), pending span=\(pendingGesture.span.latitudeDelta, format: .fixed(precision: 4))), skipping") - return - } - } - - let shouldUpdate = coordinator.lastAppliedRegion == nil || - !coordinator.lastAppliedRegion!.isApproximatelyEqual(to: region) - - if shouldUpdate { - logger.debug("Region: applying via setRegion (span=\(region.span.latitudeDelta, format: .fixed(precision: 4)))") - coordinator.hasPendingProgrammaticRegion = true - coordinator.hasAppliedInitialRegion = true - mapView.setRegion(region, animated: coordinator.lastAppliedRegion != nil) - coordinator.lastAppliedRegion = region - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - // MARK: - Annotation Management - - private func updateAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? ContactAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.contact.id }) - let newIDs = Set(contacts.map { $0.id }) - - // Remove annotations that are no longer in the list - let toRemove = currentAnnotations.filter { !newIDs.contains($0.contact.id) } - mapView.removeAnnotations(toRemove) - - // Add new annotations - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.contact.id })) - let toAdd = contacts.filter { !existingIDs.contains($0.id) } - .map { ContactAnnotation(contact: $0) } - mapView.addAnnotations(toAdd) - - // Only update name labels if showLabels or selection actually changed - // Iterating and calling view(for:) on every update interferes with MKMapView clustering - let selectedID = selectedContact?.id - let labelsChanged = showLabels != coordinator.lastShowLabels - let selectionChanged = selectedID != coordinator.lastSelectedContactID - - if labelsChanged || selectionChanged { - for annotation in mapView.annotations.compactMap({ $0 as? ContactAnnotation }) { - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = showLabels && selectedID != annotation.contact.id - } - } - coordinator.lastShowLabels = showLabels - coordinator.lastSelectedContactID = selectedID - } - } - - private func updateSelection(in mapView: MKMapView, coordinator: Coordinator) { - let currentlySelectedAnnotation = mapView.selectedAnnotations.first as? ContactAnnotation - - if let selectedContact { - // Find the annotation for this contact - guard let annotation = mapView.annotations - .compactMap({ $0 as? ContactAnnotation }) - .first(where: { $0.contact.id == selectedContact.id }) else { - return - } - - // Only select if not already selected - if currentlySelectedAnnotation?.contact.id != selectedContact.id { - mapView.selectAnnotation(annotation, animated: true) - } - } else if let current = currentlySelectedAnnotation { - // Deselect all - mapView.deselectAnnotation(current, animated: true) - } - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate { - // Binding setters for deferred updates - var setSelectedContact: ((ContactDTO?) -> Void)? - var setCameraRegion: ((MKCoordinateRegion?) -> Void)? - - // Callbacks - var onDetailTap: ((ContactDTO) -> Void)? - var onMessageTap: ((ContactDTO) -> Void)? - - // Configuration - var showLabels: Bool = true - - // State management - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var hasPendingProgrammaticRegion = false - var hasAppliedInitialRegion = false - - /// Tracks pending user gesture region awaiting async binding sync. - /// When set, the binding is considered stale until it matches this value. - var pendingUserGestureRegion: MKCoordinateRegion? - - /// Timestamp of the last cluster tap handled by the gesture recognizer. - /// Used to prevent double-handling when both gesture and delegate fire. - var lastClusterTapTime: Date? - - /// Set before showAnnotations calls to ensure pendingUserGestureRegion is set - /// even if hasPendingProgrammaticRegion is true from a prior setRegion. - var hasPendingShowAnnotations = false - - // Previous state for change detection (avoid unnecessary view updates that interfere with clustering) - var lastShowLabels: Bool = true - var lastSelectedContactID: UUID? - - // Lazily created map view owned by coordinator - lazy var mapView: MKMapView = { - let map = MKMapView() - return map - }() - - // MARK: - Cluster Tap Handler - - @objc func clusterTapped(_ gesture: UITapGestureRecognizer) { - guard let clusterView = gesture.view as? MKAnnotationView, - let cluster = clusterView.annotation as? MKClusterAnnotation else { - return - } - // Mark that we handled this tap to prevent delegate double-handling - lastClusterTapTime = Date() - // Mark that we're about to call showAnnotations so regionDidChangeAnimated - // will set pendingUserGestureRegion to protect against stale binding values - hasPendingShowAnnotations = true - logger.debug("Cluster: gesture tapped, calling showAnnotations for \(cluster.memberAnnotations.count) members") - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - // Don't provide custom view for user location - if annotation is MKUserLocation { - return nil - } - - // Handle cluster annotations - if annotation is MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? MKMarkerAnnotationView ?? MKMarkerAnnotationView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.markerTintColor = .systemBlue - view.glyphImage = UIImage(systemName: "person.2.fill") - view.displayPriority = .defaultHigh - view.canShowCallout = false - - // Remove existing tap gestures to avoid duplicates on reuse - view.gestureRecognizers?.filter { $0 is UITapGestureRecognizer }.forEach { - view.removeGestureRecognizer($0) - } - - // Add tap gesture for immediate response (bypasses delegate selection delay) - let tap = UITapGestureRecognizer(target: self, action: #selector(clusterTapped(_:))) - view.addGestureRecognizer(tap) - - return view - } - - // Handle contact annotations - guard let contactAnnotation = annotation as? ContactAnnotation else { - return nil - } - - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: ContactPinView.reuseIdentifier, - for: annotation - ) as? ContactPinView ?? ContactPinView( - annotation: annotation, - reuseIdentifier: ContactPinView.reuseIdentifier - ) - - view.annotation = annotation - view.showsNameLabel = showLabels - // Must set clusteringIdentifier here before returning view, not in init/configure - // MKMapView makes clustering decisions based on this value at return time - view.clusteringIdentifier = "contact" - view.onDetail = { [weak self] in - self?.onDetailTap?(contactAnnotation.contact) - } - view.onMessage = { [weak self] in - self?.onMessageTap?(contactAnnotation.contact) - } - - return view - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - guard !isUpdatingFromSwiftUI else { return } - - // Ignore user location selection - if annotation is MKUserLocation { - return - } - - // Handle cluster selection - zoom to show members - // Skip if gesture recognizer already handled this tap (within 500ms) - if let cluster = annotation as? MKClusterAnnotation { - if let tapTime = lastClusterTapTime, Date().timeIntervalSince(tapTime) < 0.5 { - // Gesture already handled this tap, just deselect without zooming again - logger.debug("Cluster: didSelect skipped (gesture handled \(Date().timeIntervalSince(tapTime), format: .fixed(precision: 3))s ago)") - mapView.deselectAnnotation(cluster, animated: false) - return - } - logger.debug("Cluster: didSelect calling showAnnotations (fallback path)") - mapView.deselectAnnotation(cluster, animated: false) - hasPendingShowAnnotations = true - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - return - } - - guard let contactAnnotation = annotation as? ContactAnnotation else { return } - - logger.debug("Selection: didSelect for \(contactAnnotation.contact.displayName)") - - // Update name label visibility - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = false - } - - // Defer binding update to avoid SwiftUI state mutation during update - Task { @MainActor in - logger.debug("Selection: updating selectedContact binding") - self.setSelectedContact?(contactAnnotation.contact) - } - } - - func mapView(_ mapView: MKMapView, didDeselect annotation: any MKAnnotation) { - guard !isUpdatingFromSwiftUI else { return } - - // Update name label visibility - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = showLabels - } - - Task { @MainActor in - self.setSelectedContact?(nil) - } - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { - logger.debug("Region: regionDidChangeAnimated skipped (isUpdatingFromSwiftUI)") - return - } - - let newSpan = mapView.region.span.latitudeDelta - - // Handle showAnnotations region changes - must set pendingUserGestureRegion - // to protect against stale binding values, since the binding wasn't updated - if hasPendingShowAnnotations { - logger.debug("Region: regionDidChangeAnimated from showAnnotations (span=\(newSpan, format: .fixed(precision: 4)))") - hasPendingShowAnnotations = false - hasPendingProgrammaticRegion = false // Clear if also set - lastAppliedRegion = mapView.region - pendingUserGestureRegion = mapView.region - Task { @MainActor in - logger.debug("Region: updating cameraRegion binding (from showAnnotations)") - self.setCameraRegion?(mapView.region) - } - return - } - - // Don't overwrite binding during programmatic region changes from setRegion - if hasPendingProgrammaticRegion { - logger.debug("Region: regionDidChangeAnimated from programmatic change (span=\(newSpan, format: .fixed(precision: 4)))") - hasPendingProgrammaticRegion = false - lastAppliedRegion = mapView.region - return - } - - // Don't write back until we've applied at least one programmatic region - // This prevents the initial default region from overwriting the intended region - guard hasAppliedInitialRegion else { - logger.debug("Region: regionDidChangeAnimated before initial region (span=\(newSpan, format: .fixed(precision: 4)))") - lastAppliedRegion = mapView.region - return - } - - // Track user-initiated region changes - // Mark as pending so stale binding values won't revert this change - logger.debug("Region: regionDidChangeAnimated setting pendingUserGestureRegion (span=\(newSpan, format: .fixed(precision: 4)))") - lastAppliedRegion = mapView.region - pendingUserGestureRegion = mapView.region - - Task { @MainActor in - logger.debug("Region: updating cameraRegion binding") - self.setCameraRegion?(mapView.region) - } - } - } -} - -// MARK: - MKCoordinateRegion Comparison - -extension MKCoordinateRegion { - func isApproximatelyEqual(to other: MKCoordinateRegion, tolerance: Double = 0.0001) -> Bool { - abs(center.latitude - other.center.latitude) < tolerance && - abs(center.longitude - other.center.longitude) < tolerance && - abs(span.latitudeDelta - other.span.latitudeDelta) < tolerance && - abs(span.longitudeDelta - other.span.longitudeDelta) < tolerance - } -} diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift new file mode 100644 index 000000000..2608408c4 --- /dev/null +++ b/MC1/Views/Map/MapCanvasView.swift @@ -0,0 +1,136 @@ +import MapLibre +import SwiftUI +import MC1Services + +/// Canvas wrapping the map content with offline badge, floating controls, and layers menu overlay +struct MapCanvasView: View { + @Environment(\.appState) private var appState + @Bindable var viewModel: MapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onCenterOnUser: () -> Void + let onClearSelection: () -> Void + + var body: some View { + ZStack { + MapContentView( + viewModel: viewModel, + mapStyleSelection: mapStyleSelection, + showLabels: showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: onShowContactDetail, + onNavigateToChat: onNavigateToChat + ) + .ignoresSafeArea() + + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } + + // Floating controls + VStack { + Spacer() + MapCanvasControls( + isNorthLocked: $viewModel.isNorthLocked, + showingLayersMenu: $viewModel.showingLayersMenu, + showLabels: $showLabels, + contactsEmpty: viewModel.contactsWithLocation.isEmpty, + onLocationTap: { onCenterOnUser() }, + onClearSelection: onClearSelection, + onCenterAll: { viewModel.centerOnAllContacts() } + ) + } + + // Layers menu overlay + if viewModel.showingLayersMenu { + Button { + withAnimation { + viewModel.showingLayersMenu = false + } + } label: { + Color.black.opacity(0.3) + .ignoresSafeArea() + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) + + VStack { + Spacer() + HStack { + Spacer() + LayersMenu( + selection: $mapStyleSelection, + isPresented: $viewModel.showingLayersMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 72) + .padding(.bottom) + } + } + } + } + } + +} + +// MARK: - Map Controls + +private struct MapCanvasControls: View { + @Binding var isNorthLocked: Bool + @Binding var showingLayersMenu: Bool + @Binding var showLabels: Bool + let contactsEmpty: Bool + let onLocationTap: () -> Void + let onClearSelection: () -> Void + let onCenterAll: () -> Void + + var body: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: onLocationTap, + showingLayersMenu: $showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $isNorthLocked) + } + ) { + LabelsToggleButton(showLabels: $showLabels) + CenterAllButton( + isEmpty: contactsEmpty, + onClearSelection: onClearSelection, + onCenterAll: onCenterAll + ) + } + } + } +} + +// MARK: - Control Buttons + +private struct CenterAllButton: View { + let isEmpty: Bool + let onClearSelection: () -> Void + let onCenterAll: () -> Void + + var body: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + onClearSelection() + onCenterAll() + } + .font(.body.weight(.medium)) + .foregroundStyle(isEmpty ? .secondary : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .disabled(isEmpty) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift new file mode 100644 index 000000000..3d3ed1cf6 --- /dev/null +++ b/MC1/Views/Map/MapContentView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import MC1Services + +/// Map content displaying MC1MapView with contact points and popover callouts +struct MapContentView: View { + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var viewModel: MapViewModel + let mapStyleSelection: MapStyleSelection + let showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + + var body: some View { + MC1MapView( + points: viewModel.mapPoints, + lines: [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + onPointTap: { point, screenPosition in + selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } + selectedPointScreenPosition = screenPosition + }, + onMapTap: { _ in + selectedCalloutContact = nil + selectedPointScreenPosition = nil + }, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + if selectedCalloutContact != nil { + selectedCalloutContact = nil + selectedPointScreenPosition = nil + } + }, + isStyleLoaded: $isStyleLoaded + ) + .popover( + item: $selectedCalloutContact, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { contact in + ContactCalloutContent( + contact: contact, + onDetail: { onShowContactDetail(contact) }, + onMessage: { onNavigateToChat(contact) } + ) + .presentationCompactAdaptation(.popover) + } + .overlay { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { + MapLoadingOverlay() + } + } + } + +} + +// MARK: - Loading Overlay + +private struct MapLoadingOverlay: View { + var body: some View { + ZStack { + Color.black.opacity(0.1) + ProgressView() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 8)) + } + } +} diff --git a/MC1/Views/Map/MapLine.swift b/MC1/Views/Map/MapLine.swift new file mode 100644 index 000000000..59ff1ddd5 --- /dev/null +++ b/MC1/Views/Map/MapLine.swift @@ -0,0 +1,27 @@ +import CoreLocation + +struct MapLine: Identifiable, Equatable { + let id: String + let coordinates: [CLLocationCoordinate2D] + let style: LineStyle + let opacity: Double + var pathIndex: Int? + + enum LineStyle: String, Hashable { + case los + case traceUntraced + case traceWeak + case traceMedium + case traceGood + } + + static func == (lhs: MapLine, rhs: MapLine) -> Bool { + lhs.id == rhs.id + && lhs.style == rhs.style + && lhs.opacity == rhs.opacity + && lhs.coordinates.count == rhs.coordinates.count + && zip(lhs.coordinates, rhs.coordinates).allSatisfy { + $0.latitude == $1.latitude && $0.longitude == $1.longitude + } + } +} diff --git a/MC1/Views/Map/MapPoint.swift b/MC1/Views/Map/MapPoint.swift new file mode 100644 index 000000000..041f7376a --- /dev/null +++ b/MC1/Views/Map/MapPoint.swift @@ -0,0 +1,37 @@ +import CoreLocation + +struct MapPoint: Identifiable, Equatable { + let id: UUID + let coordinate: CLLocationCoordinate2D + let pinStyle: PinStyle + let label: String? + let isClusterable: Bool + + enum PinStyle: String, Hashable { + case contactChat + case contactRepeater + case contactRoom + case repeater + case repeaterRingBlue + case repeaterRingGreen + case repeaterRingWhite + case pointA + case pointB + case crosshair + case badge + } + + let hopIndex: Int? + let badgeText: String? + + static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { + lhs.id == rhs.id + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.pinStyle == rhs.pinStyle + && lhs.label == rhs.label + && lhs.isClusterable == rhs.isClusterable + && lhs.hopIndex == rhs.hopIndex + && lhs.badgeText == rhs.badgeText + } +} diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 65a1252aa..40cfdc50f 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -1,34 +1,44 @@ -import MapKit import SwiftUI /// Map style options for the Map tab enum MapStyleSelection: String, CaseIterable, Hashable { case standard case satellite - case hybrid + case topo - var mapStyle: MapStyle { + var label: String { switch self { - case .standard: .standard(elevation: .realistic) - case .satellite: .imagery - case .hybrid: .hybrid + case .standard: L10n.Map.Map.Style.standard + case .satellite: L10n.Map.Map.Style.satellite + case .topo: L10n.Map.Map.Style.topo } } - var label: String { + var requiresNetwork: Bool { switch self { - case .standard: L10n.Map.Map.Style.standard - case .satellite: L10n.Map.Map.Style.satellite - case .hybrid: L10n.Map.Map.Style.hybrid + case .standard: false + case .satellite: true + case .topo: false } } - /// MKMapType for UIKit MKMapView - var mkMapType: MKMapType { + var offlineMapLayer: OfflineMapLayer { switch self { - case .standard: .standard - case .satellite: .satellite - case .hybrid: .hybrid + case .standard: .base + case .satellite: .base + case .topo: .topo + } + } + + /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. + /// When offline, always returns Liberty — offline packs are downloaded against that style + /// and MapLibre serves cached tiles only for the exact style URL used during download. + func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { + let useDark = isDarkMode && !isOffline + let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + guard let result = URL(string: url) else { + fatalError("Invalid map tile URL constant: \(url)") } + return result } } diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift new file mode 100644 index 000000000..466a34760 --- /dev/null +++ b/MC1/Views/Map/MapTileURLs.swift @@ -0,0 +1,8 @@ +enum MapTileURLs { + static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" + static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" + static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" +} diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index c03c3ded8..73a64f4ec 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -1,251 +1,63 @@ -import os import SwiftUI import MapKit import MC1Services -private let logger = Logger(subsystem: "com.mc1", category: "MapView") - /// Map view displaying contacts with their locations struct MapView: View { - /// Estimated duration for sheet presentation animation. SwiftUI doesn't provide a completion callback, - /// so we use this delay before switching to the snapshot to hide the transition from the user. - private static let sheetPresentationDuration: Duration = .milliseconds(500) - @Environment(\.appState) private var appState + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage("mapShowLabels") private var showLabels = true @State private var viewModel = MapViewModel() + @State private var selectedCalloutContact: ContactDTO? + @State private var selectedPointScreenPosition: CGPoint? @State private var selectedContactForDetail: ContactDTO? - /// Static snapshot of the map shown while sheets are presented to prevent memory growth from SwiftUI keyboard layout cycles - @State private var mapSnapshot: UIImage? - /// Controls when snapshot is shown - delayed until after sheet presents to hide the transition - @State private var isSnapshotActive = false - /// Closure to get snapshot parameters directly from MKMapView (camera + bounds, avoids async binding lag) - @State private var getSnapshotParams: (() -> (camera: MKMapCamera, size: CGSize)?)? + @State private var isStyleLoaded = false var body: some View { NavigationStack { - mapCanvas - .toolbar { - ToolbarItem(placement: .topBarLeading) { - BLEStatusIndicatorView() - } - ToolbarItem(placement: .topBarTrailing) { - refreshButton - } - } - .task { - appState.locationService.requestPermissionIfNeeded() - appState.locationService.requestLocation() - viewModel.configure(appState: appState) - await viewModel.loadContactsWithLocation() - viewModel.centerOnAllContacts() - } - .sheet(item: $selectedContactForDetail, onDismiss: clearMapSnapshot) { contact in - ContactDetailSheet( - contact: contact, - onMessage: { navigateToChat(with: contact) } - ) - .presentationDetents([.large]) - } - .liquidGlassToolbarBackground() - } - } - - // MARK: - Map Canvas - - private var mapCanvas: some View { - ZStack { - mapContent - .ignoresSafeArea() - - // Floating controls - VStack { - Spacer() - mapControls - } - - // Layers menu overlay - if viewModel.showingLayersMenu { - Button { - withAnimation { - viewModel.showingLayersMenu = false - } - } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() - } - .buttonStyle(.plain) - - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $viewModel.mapStyleSelection, - isPresented: $viewModel.showingLayersMenu - ) - .padding(.trailing, 72) - .padding(.bottom) - } - } - } - } - } - - // MARK: - Map Content - - @ViewBuilder - private var mapContent: some View { - if viewModel.contactsWithLocation.isEmpty && !viewModel.isLoading { - emptyState - } else { - // Keep MKMapView always in tree to prevent Metal deallocation crashes - // Hide it with opacity when showing snapshot instead of removing from hierarchy - let showingSnapshot = isSnapshotActive && mapSnapshot != nil - - ZStack { - MKMapViewRepresentable( - contacts: viewModel.contactsWithLocation, - mapType: viewModel.mapStyleSelection.mkMapType, - showLabels: viewModel.showLabels, - showsUserLocation: true, - selectedContact: $viewModel.selectedContact, - cameraRegion: $viewModel.cameraRegion, - onDetailTap: { contact in - showContactDetail(contact) - }, - onMessageTap: { contact in - navigateToChat(with: contact) - }, - onSnapshotParamsGetter: { getter in - Task { @MainActor in - await Task.yield() - getSnapshotParams = getter - } - } - ) - .opacity(showingSnapshot ? 0 : 1) - - if showingSnapshot, let snapshot = mapSnapshot { - // Show static snapshot while sheet is presented to prevent memory growth - // MKMapView clustering causes unbounded memory growth during keyboard layout cycles - // Must ignore safe area to match MKMapView's positioning (UIView fills entire area) - Image(uiImage: snapshot) - .resizable() - .ignoresSafeArea() - } - } - .overlay { - if viewModel.isLoading { - loadingOverlay + MapCanvasView( + viewModel: viewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: { showContactDetail($0) }, + onNavigateToChat: { navigateToChat(with: $0) }, + onCenterOnUser: { centerOnUserLocation() }, + onClearSelection: { clearSelection() } + ) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BLEStatusIndicatorView() } - } - } - } - - // MARK: - Empty State - - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Map.Map.EmptyState.title, systemImage: "map") - } description: { - Text(L10n.Map.Map.EmptyState.description) - } actions: { - Button(L10n.Map.Map.Common.refresh) { - Task { - await viewModel.loadContactsWithLocation() + ToolbarItem(placement: .topBarTrailing) { + MapRefreshButton(viewModel: viewModel) } } - .buttonStyle(.bordered) - } - } - - // MARK: - Loading Overlay - - private var loadingOverlay: some View { - ZStack { - Color.black.opacity(0.1) - ProgressView() - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 8)) - } - } - - // MARK: - Map Controls - - private var mapControls: some View { - HStack { - Spacer() - mapControlsStack - } - } - - private var mapControlsStack: some View { - MapControlsToolbar( - onLocationTap: { centerOnUserLocation() }, - showingLayersMenu: $viewModel.showingLayersMenu - ) { - labelsToggleButton - centerAllButton - } - } - - private var labelsToggleButton: some View { - Button { - withAnimation { - viewModel.showLabels.toggle() - } - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(viewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels) - } - - private var centerAllButton: some View { - Button { - clearSelection() - viewModel.centerOnAllContacts() - } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(viewModel.contactsWithLocation.isEmpty ? .secondary : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .disabled(viewModel.contactsWithLocation.isEmpty) - .accessibilityLabel(L10n.Map.Map.Controls.centerAll) - } - - // MARK: - Refresh Button - - private var refreshButton: some View { - Button { - Task { + .task { + appState.locationService.requestPermissionIfNeeded() + appState.locationService.requestLocation() + viewModel.configure(appState: appState) await viewModel.loadContactsWithLocation() + viewModel.centerOnAllContacts() } - } label: { - if viewModel.isLoading { - ProgressView() - } else { - Image(systemName: "arrow.clockwise") + .sheet(item: $selectedContactForDetail) { contact in + ContactDetailSheet( + contact: contact, + onMessage: { navigateToChat(with: contact) } + ) + .presentationDetents([.large]) } + .liquidGlassToolbarBackground() } - .disabled(viewModel.isLoading) } // MARK: - Actions - private func selectContact(_ contact: ContactDTO) { - viewModel.centerOnContact(contact) - } - private func clearSelection() { - viewModel.clearSelection() + selectedCalloutContact = nil + selectedPointScreenPosition = nil } private func navigateToChat(with contact: ContactDTO) { @@ -254,293 +66,42 @@ struct MapView: View { } private func showContactDetail(_ contact: ContactDTO) { - // Clear selection to prevent MKSmallCalloutView constraint corruption - viewModel.selectedContact = nil - // Present sheet immediately so user sees it animating in + clearSelection() selectedContactForDetail = contact - - // Capture snapshot after sheet animation completes to hide the transition - Task { - try? await Task.sleep(for: Self.sheetPresentationDuration) - // Guard against race condition if sheet was dismissed during delay - guard selectedContactForDetail != nil else { return } - await captureMapSnapshot() - isSnapshotActive = true - } - } - - /// Captures a static snapshot of the current map view to display while sheets are presented - private func captureMapSnapshot() async { - // Get camera and bounds directly from MKMapView for pixel-perfect match - // Using camera instead of region avoids MKMapSnapshotter's automatic aspect ratio adjustment - guard let params = getSnapshotParams?() else { return } - - let options = MKMapSnapshotter.Options() - options.camera = params.camera - options.size = params.size - options.scale = UIScreen.main.scale - options.mapType = viewModel.mapStyleSelection.mkMapType - options.showsBuildings = true - - let snapshotter = MKMapSnapshotter(options: options) - do { - let snapshot = try await snapshotter.start() - mapSnapshot = snapshot.image - } catch { - logger.warning("Map snapshot capture failed: \(error.localizedDescription)") - mapSnapshot = nil - } - } - - private func clearMapSnapshot() { - isSnapshotActive = false - mapSnapshot = nil } private func centerOnUserLocation() { guard let location = appState.bestAvailableLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - viewModel.cameraRegion = MKCoordinateRegion(center: location.coordinate, span: span) + viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) } } -// MARK: - Contact Detail Sheet +// MARK: - Map Refresh Button -private struct ContactDetailSheet: View { - let contact: ContactDTO - let onMessage: () -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - /// Sheet types for repeater flows - private enum ActiveSheet: Identifiable, Hashable { - case telemetryAuth - case telemetryStatus(RemoteNodeSessionDTO) - case adminAuth - case adminSettings(RemoteNodeSessionDTO) - case roomJoin - - var id: String { - switch self { - case .telemetryAuth: "telemetryAuth" - case .telemetryStatus(let s): "telemetryStatus-\(s.id)" - case .adminAuth: "adminAuth" - case .adminSettings(let s): "adminSettings-\(s.id)" - case .roomJoin: "roomJoin" - } - } - } - - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? +private struct MapRefreshButton: View { + var viewModel: MapViewModel var body: some View { - NavigationStack { - List { - // Basic info section - Section(L10n.Map.Map.Detail.Section.contactInfo) { - LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) - - LabeledContent(L10n.Map.Map.Detail.type) { - HStack { - Image(systemName: typeIconName) - Text(typeDisplayName) - } - .foregroundStyle(typeColor) - } - - if contact.isFavorite { - LabeledContent(L10n.Map.Map.Detail.status) { - HStack { - Image(systemName: "star.fill") - Text(L10n.Map.Map.Detail.favorite) - } - .foregroundStyle(.orange) - } - } - - if contact.lastAdvertTimestamp > 0 { - LabeledContent(L10n.Map.Map.Detail.lastAdvert) { - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) - } - } - } - - // Location section - Section(L10n.Map.Map.Detail.Section.location) { - LabeledContent(L10n.Map.Map.Detail.latitude) { - Text(contact.latitude, format: .number.precision(.fractionLength(6))) - } - - LabeledContent(L10n.Map.Map.Detail.longitude) { - Text(contact.longitude, format: .number.precision(.fractionLength(6))) - } - } - - // Path info section - Section(L10n.Map.Map.Detail.Section.networkPath) { - if contact.isFloodRouted { - LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) - } else { - let hopCount = contact.pathHopCount - LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) - } - } - - // Actions section - Section { - switch contact.type { - case .repeater: - Button { - activeSheet = .telemetryAuth - } label: { - Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - - Button { - activeSheet = .adminAuth - } label: { - Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") - } - - case .room: - Button { - activeSheet = .roomJoin - } label: { - Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") - } - - case .chat: - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - } - } - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - dismiss() - } - } - } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .telemetryAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet( - contact: contact, - role: role, - customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle - ) { session in - pendingSheet = .telemetryStatus(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .telemetryStatus(let session): - RepeaterStatusView(session: session) - - case .adminAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - if session.isAdmin { - pendingSheet = .adminSettings(session) - } else { - pendingSheet = .telemetryStatus(session) - } - activeSheet = nil - } - .presentationSizing(.page) - } - - case .adminSettings(let session): - NavigationStack { - RepeaterSettingsView(session: session) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - activeSheet = nil - } - } - } - } - .presentationSizing(.page) - - case .roomJoin: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - activeSheet = nil - dismiss() - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } - } + Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { + Task { + await viewModel.loadContactsWithLocation() } } - } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } - } - - // MARK: - Computed Properties - - private var typeIconName: String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } - - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.NodeKind.chatContact - case .repeater: - L10n.Map.Map.NodeKind.repeater - case .room: - L10n.Map.Map.NodeKind.room - } - } - - private var typeColor: Color { - switch contact.type { - case .chat: - .blue - case .repeater: - .green - case .room: - .purple + .labelStyle(.iconOnly) + .disabled(viewModel.isLoading) + .opacity(viewModel.isLoading ? 0 : 1) + .overlay { + if viewModel.isLoading { + ProgressView() + } } } } // MARK: - Preview -#Preview("Map with Contacts") { - MapView() - .environment(\.appState, AppState()) -} - -#Preview("Empty Map") { +#Preview { MapView() .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index 8cd716822..60fe2ba93 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -12,23 +12,23 @@ final class MapViewModel { /// All contacts with valid locations var contactsWithLocation: [ContactDTO] = [] + /// Map points derived from contacts — stored to avoid reallocation on every body eval. + private(set) var mapPoints: [MapPoint] = [] + /// Loading state var isLoading = false /// Error message if any var errorMessage: String? - /// Selected contact for detail display - var selectedContact: ContactDTO? - - /// Camera region for map centering (MKCoordinateRegion for UIKit MKMapView) + /// Camera region for map centering var cameraRegion: MKCoordinateRegion? - /// Current map style selection - var mapStyleSelection: MapStyleSelection = .standard + /// Version counter for the camera region, incremented to signal a new camera target + private(set) var cameraRegionVersion = 0 - /// Whether to show contact name labels - var showLabels = true + /// Whether the map bearing is locked to true north + var isNorthLocked = false /// Whether the layers menu is showing var showingLayersMenu = false @@ -66,6 +66,7 @@ final class MapViewModel { do { let allContacts = try await dataStore.fetchContacts(deviceID: deviceID) contactsWithLocation = allContacts.filter(\.hasLocation) + rebuildMapPoints() } catch { errorMessage = error.localizedDescription } @@ -73,21 +74,36 @@ final class MapViewModel { isLoading = false } + // MARK: - Map Points + + private func rebuildMapPoints() { + mapPoints = contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) + } + } + // MARK: - Map Interaction + func setCameraRegion(_ region: MKCoordinateRegion?) { + cameraRegion = region + cameraRegionVersion += 1 + } + /// Center map on a specific contact func centerOnContact(_ contact: ContactDTO) { guard contact.hasLocation else { return } - let coordinate = CLLocationCoordinate2D( - latitude: contact.latitude, - longitude: contact.longitude - ) - // 5000 meters corresponds to roughly 0.045 degrees latitude span let span = MKCoordinateSpan(latitudeDelta: 0.045, longitudeDelta: 0.045) - cameraRegion = MKCoordinateRegion(center: coordinate, span: span) - selectedContact = contact + setCameraRegion(MKCoordinateRegion(center: contact.coordinate, span: span)) } /// Center map to show all contacts @@ -97,47 +113,7 @@ final class MapViewModel { return } - // Calculate bounding region - var minLat = Double.greatestFiniteMagnitude - var maxLat = -Double.greatestFiniteMagnitude - var minLon = Double.greatestFiniteMagnitude - var maxLon = -Double.greatestFiniteMagnitude - - for contact in contactsWithLocation { - let lat = contact.latitude - let lon = contact.longitude - minLat = min(minLat, lat) - maxLat = max(maxLat, lat) - minLon = min(minLon, lon) - maxLon = max(maxLon, lon) - } - - let centerLat = (minLat + maxLat) / 2 - let centerLon = (minLon + maxLon) / 2 - // Clamp spans to valid MKCoordinateSpan bounds (lat: 0-180, lon: 0-360) - let latDelta = min(180, max(0.01, (maxLat - minLat) * 1.5)) - let lonDelta = min(360, max(0.01, (maxLon - minLon) * 1.5)) - - let center = CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon) - let span = MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) - - cameraRegion = MKCoordinateRegion(center: center, span: span) - } - - /// Clear selection - func clearSelection() { - selectedContact = nil - } -} - -// MARK: - ContactDTO Location Extension - -extension ContactDTO { - /// The coordinate for MapKit - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: latitude, - longitude: longitude - ) + let coordinates = contactsWithLocation.map(\.coordinate) + setCameraRegion(coordinates.boundingRegion()) } } diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift new file mode 100644 index 000000000..f54a1d19e --- /dev/null +++ b/MC1/Views/Map/OfflineBadge.swift @@ -0,0 +1,22 @@ +import Accessibility +import SwiftUI + +// MARK: - Offline Badge + +struct OfflineBadge: View { + var body: some View { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + .accessibilityAddTraits(.isStaticText) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) + .padding(.top) + .onAppear { + AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() + } + } +} diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift new file mode 100644 index 000000000..e78022811 --- /dev/null +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -0,0 +1,347 @@ +import MapLibre +import UIKit + +@MainActor +enum PinSpriteRenderer { + /// Height of a standard pin sprite in points (circle + triangle pointer). + /// Used by the map Coordinator to position callout anchors above the pin icon. + static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) + + static let labelSpritePrefix = "label-" + + private static var cachedImages: [String: UIImage]? + + /// Registers base pin sprites into the style. Hop-ring variants are rendered + /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. + static func renderAll(into style: MLNStyle) { + var rendered: [String: UIImage] = [:] + for spec in allSpecs { + rendered[spec.name] = render(spec) + } + rendered["pin-badge"] = UIGraphicsImageRenderer( + size: CGSize(width: 1, height: 1), format: .preferred() + ).image { _ in } + rendered["pill-bg"] = renderPillBackground() + cachedImages = rendered + + for (name, image) in rendered { + style.setImage(image, forName: name) + } + } + + /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. + /// Returns the rendered image so the caller can pass it back to MapLibre as + /// the immediate fallback, avoiding a single-frame blink. + static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { + if let cached = cachedImages?[name] { + style.setImage(cached, forName: name) + return cached + } + + let image: UIImage + if name.hasPrefix("pin-repeater-ring-white-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...20).contains(hop), + let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { + return nil + } + image = render(ringWhiteSpec, hopIndex: hop) + } else if name.hasPrefix(labelSpritePrefix) { + let text = String(name.dropFirst(labelSpritePrefix.count)) + guard !text.isEmpty else { return nil } + image = renderLabelSprite(text: text) + } else { + return nil + } + + cachedImages?[name] = image + style.setImage(image, forName: name) + return image + } + + // MARK: - Sprite specifications + + private struct SpriteSpec { + let name: String + let circleColor: UIColor + let iconName: String? // SF Symbol name + let text: String? // e.g. "A", "B" for point pins + let ringColor: UIColor? // selection ring + let isCrosshair: Bool + } + + private static let allSpecs: [SpriteSpec] = [ + // Main map contacts + SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), + iconName: "person.fill", text: nil, ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), + iconName: "person.3.fill", text: nil, ringColor: nil, isCrosshair: false), + + // LOS/TracePath repeater states + SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, isCrosshair: false), + SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, isCrosshair: false), + SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, isCrosshair: false), + + // LOS point pins + SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, + iconName: nil, text: "A", ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, + iconName: nil, text: "B", ringColor: nil, isCrosshair: false), + + // LOS crosshair target + SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, + iconName: nil, text: "R", ringColor: nil, isCrosshair: true), + ] + + // MARK: - Rendering + + private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { + if spec.isCrosshair { + return renderCrosshair(spec) + } + + let circleSize: CGFloat = 36 + let iconSize: CGFloat = 16 + let triangleSize: CGFloat = 10 + let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 + let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 + let totalWidth = max(circleSize, ringSize) + let totalHeight = circleSize + triangleSize - 3 + ringPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let centerX = totalWidth / 2 + + // Selection ring + if let ringColor = spec.ringColor { + let ringRect = CGRect( + x: centerX - ringSize / 2, + y: ringPadding, + width: ringSize, + height: ringSize + ) + ringColor.setStroke() + cgContext.setLineWidth(3) + cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) + } + + // Circle shadow + cgContext.saveGState() + cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + let circleRect = CGRect( + x: centerX - circleSize / 2, + y: ringPadding, + width: circleSize, + height: circleSize + ) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + cgContext.restoreGState() + + // Circle (again without shadow for crisp edge) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + + // Icon or text + if let iconName = spec.iconName { + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) + if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let iconRect = CGRect( + x: centerX - icon.size.width / 2, + y: circleRect.midY - icon.size.height / 2, + width: icon.size.width, + height: icon.size.height + ) + icon.draw(in: iconRect) + } + } else if let text = spec.text { + let font = UIFont.systemFont(ofSize: 14, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let size = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: centerX - size.width / 2, + y: circleRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + + // Triangle pointer + let triangleTop = circleRect.maxY - 3 + let path = UIBezierPath() + path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) + path.close() + spec.circleColor.setFill() + path.fill() + + // Hop badge overlay (ring pins only) + if let hopIndex, spec.ringColor != nil { + let badgeSize: CGFloat = 18 + let badgeX = circleRect.maxX + 4 - badgeSize + let badgeY = circleRect.minY + let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) + + UIColor.systemBlue.setFill() + cgContext.fillEllipse(in: badgeRect) + + let text = "\(hopIndex)" + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + } + + // MARK: - Pill sprites + + /// Semi-transparent stretchable pill for stats badges. + /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch + /// the flat center while preserving the rounded caps. + private static func renderPillBackground() -> UIImage { + let cornerRadius: CGFloat = 4 + let size: CGFloat = 2 * cornerRadius + 2 + let shadowPadding: CGFloat = 1 + let totalSize = size + shadowPadding * 2 + let capInset = cornerRadius + shadowPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + let image = renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + // Shadow pass + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + // Light fill for readability in both light and dark mode + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + } + + return image.resizableImage( + withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), + resizingMode: .stretch + ) + } + + private static func renderLabelSprite(text: String) -> UIImage { + let font = UIFont.systemFont(ofSize: 12, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + let textSize = (text as NSString).size(withAttributes: attrs) + + let horizontalPadding: CGFloat = 6 + let verticalPadding: CGFloat = 4 + let cornerRadius: CGFloat = 4 + let shadowPadding: CGFloat = 1 + + let pillWidth = textSize.width + horizontalPadding * 2 + let pillHeight = textSize.height + verticalPadding * 2 + let totalWidth = pillWidth + shadowPadding * 2 + let totalHeight = pillHeight + shadowPadding * 2 + + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: totalWidth, height: totalHeight), + format: .preferred() + ) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + + let textRect = CGRect( + x: shadowPadding + (pillWidth - textSize.width) / 2, + y: shadowPadding + (pillHeight - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + + private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { + let size: CGFloat = 44 + let gapRadius: CGFloat = 4 + let outerRadius = size / 2 + let badgeHeight: CGFloat = 20 + let totalHeight = size + badgeHeight + 2 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: size / 2, y: size / 2) + + // Crosshair lines + cgContext.setStrokeColor(UIColor.systemPurple.cgColor) + cgContext.setLineWidth(2) + + // Vertical + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + + // Horizontal + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // "R" badge + let badgeWidth: CGFloat = 20 + let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: size + 2, width: badgeWidth, height: badgeHeight) + let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) + UIColor.systemPurple.setFill() + badgePath.fill() + + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = ("R" as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + ("R" as NSString).draw(in: textRect, withAttributes: attrs) + } + } +} diff --git a/MC1/Views/Settings/LocationPickerView.swift b/MC1/Views/Settings/LocationPickerView.swift index 0e3c2fb6b..523d0ec87 100644 --- a/MC1/Views/Settings/LocationPickerView.swift +++ b/MC1/Views/Settings/LocationPickerView.swift @@ -7,15 +7,19 @@ import MC1Services struct LocationPickerView: View { @Environment(\.dismiss) private var dismiss @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme // Configuration private let initialCoordinate: CLLocationCoordinate2D? private let onSave: (CLLocationCoordinate2D) async throws -> Void + // Stable marker identity + private let markerID = UUID() + // UI State - @State private var position: MapCameraPosition = .automatic + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 @State private var selectedCoordinate: CLLocationCoordinate2D? - @State private var visibleRegion: MKCoordinateRegion? @State private var isSaving = false @State private var showError: String? @@ -31,26 +35,21 @@ struct LocationPickerView: View { var body: some View { NavigationStack { ZStack { - MapReader { proxy in - Map(position: $position, interactionModes: [.pan, .zoom]) { - if let coord = selectedCoordinate { - Marker(L10n.Settings.LocationPicker.markerTitle, coordinate: coord) - .tint(.blue) - } - } - .onTapGesture { screenLocation in - if let coordinate = proxy.convert(screenLocation, from: .local) { - selectedCoordinate = coordinate - } - } - .onMapCameraChange { context in - visibleRegion = context.region - } - .mapControls { - MapUserLocationButton() - MapCompass() - } - } + MC1MapView( + points: markerPoints, + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: { coord in selectedCoordinate = coord }, + onCameraRegionChange: { region in cameraRegion = region } + ) // Center crosshair for precise placement Image(systemName: "plus") @@ -113,30 +112,45 @@ struct LocationPickerView: View { loadCurrentLocation() } .onChange(of: appState.locationService.currentLocation) { _, newLocation in - // Only react if we haven't set a position yet (no saved location case) + // Only react if we haven't set a camera region yet (no saved location case) guard let newLocation, initialCoordinate == nil || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), - position == .automatic else { return } + cameraRegion == nil else { return } - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: newLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 } .errorAlert($showError) } } + private var markerPoints: [MapPoint] { + guard let coord = selectedCoordinate else { return [] } + return [MapPoint( + id: markerID, + coordinate: coord, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )] + } + private func loadCurrentLocation() { // Case 1: Existing saved location if let coord = initialCoordinate, coord.latitude != 0 || coord.longitude != 0 { selectedCoordinate = coord - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 return } @@ -144,23 +158,19 @@ struct LocationPickerView: View { let locationService = appState.locationService if locationService.isAuthorized { if let userLocation = locationService.currentLocation { - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: userLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 } else if !locationService.isRequestingLocation { locationService.requestLocation() } } - - // Case 3: No saved location, no authorization - .automatic handles it } private func dropPinAtCenter() { - // Get center from tracked visible region, falling back to position.region - if let region = visibleRegion { - selectedCoordinate = region.center - } else if let region = position.region { + if let region = cameraRegion { selectedCoordinate = region.center } } @@ -200,13 +210,15 @@ private struct ButtonContent: View { Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { onClear() } - .modifier(LocationPickerGlassButtonModifier(isProminent: false)) + .liquidGlassSecondaryButtonStyle() + .controlSize(.regular) } Button(L10n.Settings.LocationPicker.dropPin) { onDropPin() } - .modifier(LocationPickerGlassButtonModifier(isProminent: true)) + .liquidGlassProminentButtonStyle() + .controlSize(.regular) } } } @@ -273,30 +285,6 @@ private struct CoordinateGlassModifier: ViewModifier { } } -private struct LocationPickerGlassButtonModifier: ViewModifier { - let isProminent: Bool - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - if isProminent { - content - .buttonStyle(.glassProminent) - .controlSize(.regular) - } else { - content - .buttonStyle(.glass) - .controlSize(.regular) - } - } else { - if isProminent { - content.buttonStyle(.borderedProminent) - } else { - content.buttonStyle(.bordered) - } - } - } -} - private struct CoordinateText: View { let label: String let value: Double diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift new file mode 100644 index 000000000..2de6e5584 --- /dev/null +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -0,0 +1,448 @@ +import MapKit +import MapLibre +import SwiftUI + +struct OfflineMapSettingsView: View { + @Environment(\.appState) private var appState + @State private var showingRegionPicker = false + @State private var showError: String? + + var body: some View { + Group { + if appState.offlineMapService.packs.isEmpty { + ContentUnavailableView { + Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") + } description: { + Text(L10n.Settings.OfflineMaps.emptyDescription) + } actions: { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { + showingRegionPicker = true + } + .buttonStyle(.bordered) + } + } else { + List { + PacksSection() + StorageSection() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true + } + } + } + } + } + .navigationTitle(L10n.Settings.OfflineMaps.title) + .sheet(isPresented: $showingRegionPicker) { + RegionPickerSheet() + } + .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in + if let newValue { + showError = newValue + appState.offlineMapService.clearLastPackError() + } + } + .errorAlert($showError) + } + +} + +// MARK: - Packs Section + +private struct PacksSection: View { + @Environment(\.appState) private var appState + + var body: some View { + Section { + ForEach(appState.offlineMapService.packs) { pack in + OfflinePackRow(pack: pack) + } + .onDelete { indexSet in + if let index = indexSet.first { + let pack = appState.offlineMapService.packs[index] + Task { await appState.offlineMapService.deletePack(pack) } + } + } + } + } +} + +// MARK: - Storage Section + +private struct StorageSection: View { + @Environment(\.appState) private var appState + + var body: some View { + Section { + LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { + Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) + } + } header: { + Text(L10n.Settings.OfflineMaps.storage) + } footer: { + Text(L10n.Settings.OfflineMaps.storageFooter) + } + } +} + +// MARK: - Offline Pack Row + +private struct OfflinePackRow: View { + @Environment(\.appState) private var appState + let pack: OfflinePack + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pack.name) + Text("— \(pack.layer.label)") + .foregroundStyle(.secondary) + } + + HStack { + if pack.isComplete { + Text(L10n.Settings.OfflineMaps.complete) + .foregroundStyle(.secondary) + } else if pack.isPaused { + Text(L10n.Settings.OfflineMaps.paused) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.OfflineMaps.downloading) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing) { + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + if let speed = pack.downloadSpeed, speed > 0 { + Text("\(speed, format: .byteCount(style: .file))/s") + } + } + .foregroundStyle(.secondary) + } + .font(.caption) + + if !pack.isComplete { + HStack { + ProgressView(value: pack.completedFraction) + + Button( + pack.isPaused + ? L10n.Settings.OfflineMaps.resume + : L10n.Settings.OfflineMaps.pause, + systemImage: pack.isPaused ? "play.fill" : "pause.fill" + ) { + if pack.isPaused { + appState.offlineMapService.resumePack(pack) + } else { + appState.offlineMapService.pausePack(pack) + } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + } + } + } +} + +// MARK: - Region Picker Sheet + +private struct RegionPickerSheet: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @State private var regionName = "" + @State private var cameraRegion: MKCoordinateRegion? + @State private var isDownloading = false + @State private var showError: String? + @State private var mapSize: CGSize = .zero + @State private var includeTopo = false + @State private var isStyleLoaded = false + @State private var debouncedRegion: MKCoordinateRegion? + @State private var debounceTask: Task? + @State private var availableBytes: Int64? + + private static let selectionPadding: CGFloat = 40 + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: [], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + isNorthLocked: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { region in + cameraRegion = region + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + debouncedRegion = region + } + }, + isStyleLoaded: $isStyleLoaded + ) + + // Selection rectangle overlay + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(Self.selectionPadding) + .allowsHitTesting(false) + } + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newValue in + mapSize = newValue + } + .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.OfflineMaps.cancel) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Settings.OfflineMaps.download) { + downloadRegion() + } + .disabled( + regionName.isEmpty || isDownloading || exceedsAvailableSpace + || !appState.offlineMapService.isNetworkAvailable + || selectionBounds == nil + ) + } + } + .safeAreaInset(edge: .bottom) { + RegionPickerBottomCard( + regionName: $regionName, + includeTopo: $includeTopo, + estimatedDownloadBytes: estimatedDownloadBytes, + exceedsAvailableSpace: exceedsAvailableSpace, + isNetworkAvailable: appState.offlineMapService.isNetworkAvailable + ) + } + .errorAlert($showError) + .onAppear { refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } + } + } + + // MARK: - Download Estimate + + private var selectedLayers: Set { + var layers: Set = [.base] + if includeTopo { layers.insert(.topo) } + return layers + } + + private var estimatedDownloadBytes: Int64? { + guard let bounds = selectionBounds else { return nil } + return selectedLayers.reduce(Int64(0)) { total, layer in + total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) + } + } + + private var exceedsAvailableSpace: Bool { + guard let estimated = estimatedDownloadBytes, + let available = availableBytes else { return false } + return estimated > available + } + + private func refreshAvailableBytes() { + let values = try? URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + + // MARK: - Bounds + + private var selectionBounds: MLNCoordinateBounds? { + guard let region = debouncedRegion, + mapSize.width > 0, mapSize.height > 0 else { return nil } + + let lonFraction = Self.selectionPadding / (mapSize.width / 2) + let latFraction = Self.selectionPadding / (mapSize.height / 2) + + let latInset = region.span.latitudeDelta * latFraction + let lonInset = region.span.longitudeDelta * lonFraction + + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) + ) + ) + } + + // MARK: - Download + + private func downloadRegion() { + guard let bounds = selectionBounds else { return } + isDownloading = true + + let layers = selectedLayers + + Task { + defer { isDownloading = false } + do { + try await appState.offlineMapService.downloadRegion( + name: regionName, + bounds: bounds, + layers: layers + ) + dismiss() + } catch { + showError = error.localizedDescription + } + } + } +} + +// MARK: - Region Picker Bottom Card + +private struct RegionPickerBottomCard: View { + @Binding var regionName: String + @Binding var includeTopo: Bool + let estimatedDownloadBytes: Int64? + let exceedsAvailableSpace: Bool + let isNetworkAvailable: Bool + + /// Warn when estimated download exceeds 500 MB. + private static let largeDownloadThreshold: Int64 = 500_000_000 + + @State private var footerMinHeight: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) + .textFieldStyle(.plain) + + Divider() + + VStack(alignment: .leading) { + Text(L10n.Settings.OfflineMaps.layers) + .font(.caption) + .foregroundStyle(.secondary) + + Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + } + .toggleStyle(.switch) + .controlSize(.mini) + + Divider() + + footerContent + .frame(minHeight: footerMinHeight, alignment: .top) + .background { + // Measure the tallest possible footer state (estimate + warning) + // to reserve stable space regardless of current state or Dynamic Type size. + tallestFooterState + .hidden() + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + footerMinHeight = height + } + } + } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 4) + } + + @ViewBuilder + private var footerContent: some View { + VStack(alignment: .leading, spacing: 12) { + if !isNetworkAvailable { + HStack { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + Text(L10n.Settings.OfflineMaps.noNetwork) + .foregroundStyle(.red) + } + .font(.caption) + } else if let bytes = estimatedDownloadBytes { + let isLarge = bytes > Self.largeDownloadThreshold + + HStack { + if exceedsAvailableSpace { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } else if isLarge { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + + Text(L10n.Settings.OfflineMaps.estimatedSize( + bytes.formatted(.byteCount(style: .file)) + )) + .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) + } + .font(.caption) + + if exceedsAvailableSpace { + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + .foregroundStyle(.red) + } else if isLarge { + Text(L10n.Settings.OfflineMaps.largeTileWarning) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + /// The tallest possible single footer state: estimate line + the longest warning. + /// Rendered hidden to measure the height needed at the current Dynamic Type size. + private var tallestFooterState: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "xmark.circle.fill") + Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) + } + .font(.caption) + + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + } + } +} + +#Preview { + NavigationStack { + OfflineMapSettingsView() + .environment(\.appState, AppState()) + } +} diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index f31c36658..174abd9ab 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -11,25 +11,23 @@ struct DiagnosticsSection: View { var body: some View { Section { - Button { - exportLogs() - } label: { - HStack { - TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "arrow.up.doc") - Spacer() - if isExporting { - ProgressView() - } + if let url = exportedFileURL { + ShareLink(item: url) { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") } - } - .disabled(isExporting) - .sheet(isPresented: Binding( - get: { exportedFileURL != nil }, - set: { if !$0 { exportedFileURL = nil } } - )) { - if let url = exportedFileURL { - ShareSheet(items: [url]) + } else { + Button { + exportLogs() + } label: { + HStack { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "arrow.up.doc") + Spacer() + if isExporting { + ProgressView() + } + } } + .disabled(isExporting) } Button(role: .destructive) { @@ -55,6 +53,7 @@ struct DiagnosticsSection: View { private func exportLogs() { let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + exportedFileURL = nil isExporting = true Task { @MainActor in @@ -77,9 +76,7 @@ struct DiagnosticsSection: View { do { try await dataStore.clearDebugLogEntries() } catch { - await MainActor.run { - showError = error.localizedDescription - } + showError = error.localizedDescription } } } diff --git a/MC1/Views/Settings/SettingsView.swift b/MC1/Views/Settings/SettingsView.swift index 78899113d..4c4a76793 100644 --- a/MC1/Views/Settings/SettingsView.swift +++ b/MC1/Views/Settings/SettingsView.swift @@ -90,6 +90,12 @@ private struct SettingsListContent: View { detail: currentLanguageDisplayName ) } + + NavigationLink { + OfflineMapSettingsView() + } label: { + TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") + } } header: { Text(L10n.Settings.AppSettings.header) } diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 58c5e5aa2..646c041cc 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -175,7 +175,7 @@ struct RxLogView: View { ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() } @State private var showClearConfirmation = false @@ -201,7 +201,7 @@ struct RxLogView: View { } label: { Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { clearLog() diff --git a/project.yml b/project.yml index 38e67d9c8..ecd044233 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,9 @@ packages: Emojibase: url: https://github.com/matrix-org/emojibase-bindings from: 1.5.0 + MapLibre: + url: https://github.com/maplibre/maplibre-gl-native-distribution + from: 6.23.0 targets: MC1: @@ -35,6 +38,7 @@ targets: dependencies: - package: MC1Services - package: Emojibase + - package: MapLibre - target: MC1Widgets preBuildScripts: - name: SwiftGen From aa45ac64b012cfca908af5b4e372cb58ef0d1bdc Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:12:14 -0700 Subject: [PATCH 37/48] feat(ble): add GAT562, M5Stack, and ThinkNode M5 device platform rules - Add GAT562 as nRF52 to fix incorrect 60ms pacing (should be 25ms) - Add M5Stack Unit C6L as ESP32 - Add ThinkNode M5 as ESP32 --- MC1Services/Sources/MC1Services/ConnectionManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/MC1Services/Sources/MC1Services/ConnectionManager.swift b/MC1Services/Sources/MC1Services/ConnectionManager.swift index 0820d741f..4ab6d671e 100644 --- a/MC1Services/Sources/MC1Services/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/ConnectionManager.swift @@ -131,11 +131,14 @@ public enum DevicePlatform: Sendable { ("Xiao C6", .esp32), // ESP32 — RAK ("RAK 3112", .esp32), + // ESP32 — M5Stack + ("Unit C6L", .esp32), // ESP32 — Other ("Station G2", .esp32), ("Meshadventurer", .esp32), ("Generic ESP32", .esp32), ("ThinkNode M2", .esp32), + ("ThinkNode M5", .esp32), // nRF52 — Heltec ("MeshPocket", .nrf52), ("Mesh Pocket", .nrf52), @@ -158,6 +161,8 @@ public enum DevicePlatform: Sendable { ("ThinkNode-M1", .nrf52), ("ThinkNode M3", .nrf52), ("ThinkNode-M6", .nrf52), + // nRF52 — GAT562 + ("GAT562", .nrf52), // nRF52 — Other ("Ikoka", .nrf52), ("ProMicro", .nrf52), From d4df4bf44feefa964bbfb4e77c296a99e7451707 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:12:18 -0700 Subject: [PATCH 38/48] fix(battery): update R1 Neo and LTO OCV curves from measured data - R1 Neo: replace synthetic linear placeholder with discharge curve from Meshtastic firmware PR #8716 - LTO: replace inaccurate flat curve with smoother discharge slope --- MC1Services/Sources/MC1Services/Models/OCVPreset.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift index 9a2f445f9..c94a41151 100644 --- a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift +++ b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift @@ -42,7 +42,7 @@ public enum OCVPreset: String, CaseIterable, Codable, Sendable { case .niMH: [1400, 1300, 1280, 1270, 1260, 1250, 1240, 1230, 1210, 1150, 1000] case .lto: - [2700, 2560, 2540, 2520, 2500, 2460, 2420, 2400, 2380, 2320, 1500] + [2770, 2650, 2540, 2420, 2300, 2180, 2060, 1940, 1800, 1680, 1550] case .trackerT1000E: [4190, 4042, 3957, 3885, 3820, 3776, 3746, 3725, 3696, 3644, 3100] case .heltecPocket5000: @@ -54,7 +54,7 @@ public enum OCVPreset: String, CaseIterable, Codable, Sendable { case .seeedSolarNode: [4200, 3986, 3922, 3812, 3734, 3645, 3527, 3420, 3281, 3087, 2786] case .r1Neo: - [4330, 4292, 4254, 4216, 4178, 4140, 4102, 4064, 4026, 3988, 3950] + [4120, 4020, 4000, 3940, 3870, 3820, 3750, 3630, 3550, 3450, 3100] case .wisMeshTag: [4240, 4112, 4029, 3970, 3906, 3846, 3824, 3802, 3776, 3650, 3072] case .custom: From 0d89e487ece5151513013bb72cf90c202187e252 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:18:29 -0700 Subject: [PATCH 39/48] ui(contacts): use consistent hop icon in contact detail view - Replace chevron.forward.2 with arrowshape.bounce.right to match the icon used in message bubble hop count footers --- MC1/Views/Contacts/ContactDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 9ebb394eb..1d7692c78 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -897,7 +897,7 @@ private struct ContactNetworkPathSection: View { .foregroundStyle(.primary) } } icon: { - Image(systemName: "chevron.forward.2") + Image(systemName: "arrowshape.bounce.right") .foregroundStyle(.secondary) } } From 6da48a652a6920930f728714e8381687f0a8aaa1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:40:36 -0700 Subject: [PATCH 40/48] feat(discover): show hop count and path in discovery rows - Added path decoding properties to DiscoveredNodeDTO (isFloodRouted, pathHopCount, pathHashSize, pathByteLength, pathNodesHex) - Each row in the Discover view now shows the incoming hop count and hex path, using the same icons and truncation as message bubbles --- MC1/Views/Contacts/DiscoveryView.swift | 29 +++++++++++++++++++ .../MC1Services/Models/DiscoveredNode.swift | 25 ++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index 3469947e1..f06bca132 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -301,6 +301,26 @@ private struct DiscoveryNodeRow: View { } .font(.caption) .foregroundStyle(.secondary) + + HStack(spacing: 4) { + Image(systemName: "arrowshape.bounce.right") + if node.isFloodRouted { + Text(L10n.Contacts.Contacts.Route.flood) + } else if node.pathHopCount == 0 { + Text(L10n.Contacts.Contacts.Route.direct) + } else { + let pathNodes = node.pathNodesHex + Text("\(node.pathHopCount)") + + if !pathNodes.isEmpty { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(formattedPath(pathNodes)) + .monospaced() + } + } + } + .font(.caption2) + .foregroundStyle(.secondary) } Spacer() @@ -350,6 +370,15 @@ private struct DiscoveryNodeRow: View { } } + private func formattedPath(_ nodes: [String]) -> String { + if nodes.count > 6 { + let first = nodes.prefix(3).joined(separator: ",") + let last = nodes.suffix(3).joined(separator: ",") + return "\(first)…\(last)" + } + return nodes.joined(separator: ",") + } + private var distanceToNode: String? { guard let userLocation = appState.bestAvailableLocation, node.hasLocation else { return nil } diff --git a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift index a3462d7b3..29b32f90b 100644 --- a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift +++ b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift @@ -90,6 +90,31 @@ public struct DiscoveredNodeDTO: Sendable, Equatable, Identifiable, RepeaterReso latitude != 0 || longitude != 0 } + public var isFloodRouted: Bool { + outPathLength == 0xFF + } + + public var pathHashSize: Int { + decodePathLen(outPathLength)?.hashSize ?? 1 + } + + public var pathHopCount: Int { + decodePathLen(outPathLength)?.hopCount ?? 0 + } + + public var pathByteLength: Int { + decodePathLen(outPathLength)?.byteLength ?? 0 + } + + public var pathNodesHex: [String] { + let size = pathHashSize + let relevantPath = outPath.prefix(pathByteLength) + return stride(from: 0, to: relevantPath.count, by: size).compactMap { start in + let end = min(start + size, relevantPath.count) + return relevantPath[start.. Date: Fri, 27 Mar 2026 19:43:53 -0700 Subject: [PATCH 41/48] feat(repeater): add discover neighbours button to status view - Adds a "Discover Neighbours" button in the Neighbours section (admin only) that sends the discover.neighbors CLI command to the repeater - Polls the binary neighbours endpoint every 3 seconds for 60 seconds, updating the list in real time as nodes respond - Button toggles to show a live countdown and stops discovery on tap - Pull-to-refresh and toolbar refresh skip the neighbour fetch during active discovery to avoid conflicts - Cleans up the polling task on view dismiss - Localised in all 9 supported languages --- MC1/Resources/Generated/L10n.swift | 6 ++ .../Localization/de.lproj/RemoteNodes.strings | 6 ++ .../Localization/en.lproj/RemoteNodes.strings | 6 ++ .../Localization/es.lproj/RemoteNodes.strings | 6 ++ .../Localization/fr.lproj/RemoteNodes.strings | 6 ++ .../Localization/nl.lproj/RemoteNodes.strings | 6 ++ .../Localization/pl.lproj/RemoteNodes.strings | 6 ++ .../Localization/ru.lproj/RemoteNodes.strings | 6 ++ .../Localization/uk.lproj/RemoteNodes.strings | 6 ++ .../zh-Hans.lproj/RemoteNodes.strings | 6 ++ .../RemoteNodes/RepeaterStatusView.swift | 42 ++++++++++--- .../RemoteNodes/RepeaterStatusViewModel.swift | 59 +++++++++++++++++++ 12 files changed, 153 insertions(+), 8 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 96cfe53ca..249a9227f 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2785,6 +2785,12 @@ public enum L10n { public static func channel(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.channel", p1, fallback: "Channel %d") } + /// Location: RepeaterStatusView.swift - Discovery in progress with countdown + public static func discoveringSeconds(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.status.discoveringSeconds", p1, fallback: "Discovering... %ds") + } + /// Location: RepeaterStatusView.swift - Discover neighbours button label + public static let discoverNeighbors = L10n.tr("RemoteNodes", "remoteNodes.status.discoverNeighbors", fallback: "Discover Neighbours") /// Location: RepeaterStatusView.swift - Guest mode badge in header public static let guestMode = L10n.tr("RemoteNodes", "remoteNodes.status.guestMode", fallback: "Guest Mode") /// Location: RepeaterStatusView.swift - Hours ago format diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 27273cdc6..aa9975bdc 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Andere von diesem Repeater entdeckte Knoten und ihre Signalqualität."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Nachbarn entdecken"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Suche... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spannungs-Prozent-Zuordnung zur Schätzung des Batteriestands."; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 523adbc7f..f1814b958 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Other nodes discovered by this repeater and their signal quality."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Discover Neighbours"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Discovering... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Voltage-to-percentage mapping used for battery level estimation."; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index c020c1c34..166aaa2b9 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Otros nodos descubiertos por este repetidor y su calidad de señal."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Descubrir vecinos"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Descubriendo... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapeo de voltaje a porcentaje para estimar el nivel de batería."; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index de4723a6b..def5f3d77 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Autres nœuds découverts par ce répéteur et leur qualité de signal."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Découvrir les voisins"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Découverte... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Correspondance tension-pourcentage pour l'estimation du niveau de batterie."; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 4c8ee5bc4..6b8142e9d 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Andere knooppunten ontdekt door deze repeater en hun signaalkwaliteit."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Buren ontdekken"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Ontdekken... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spanning-naar-percentage-toewijzing voor schatting van het batterijniveau."; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index aed514d45..cd86fa709 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -478,6 +478,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Inne węzły wykryte przez ten repeater i ich jakość sygnału."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Odkryj sąsiadów"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Odkrywanie... %ds"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapowanie napięcia na procent do szacowania poziomu baterii."; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index e33efcb5a..15abbffa5 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -478,6 +478,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Другие узлы, обнаруженные этим ретранслятором, и качество их сигнала."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Обнаружить соседей"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Обнаружение... %dс"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Соответствие напряжения и процента для оценки уровня заряда батареи."; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index c26768d45..cab7ab90e 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -478,6 +478,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "Інші вузли, виявлені цим ретранслятором, та якість їхнього сигналу."; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Виявити сусідів"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "Виявлення... %dс"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Відповідність напруги та відсотка для оцінки рівня заряду батареї."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 8b1d7de92..0c076649d 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -481,6 +481,12 @@ /* Location: RepeaterStatusView.swift - Neighbors section footer */ "remoteNodes.status.neighborsFooter" = "此节点发现的其他节点及其信号质量"; +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "发现邻居"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "发现中... %d秒"; + /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "用于估算电池电量的电压百分比"; diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index 4de5e6a27..9116533a0 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -38,7 +38,7 @@ struct RepeaterStatusView: View { .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) .radioDisabled( for: appState.connectionState, - or: viewModel.helper.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.helper.isLoadingTelemetry || viewModel.isLoadingOwnerInfo + or: viewModel.helper.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.helper.isLoadingTelemetry || viewModel.isLoadingOwnerInfo || viewModel.isDiscovering ) } @@ -82,12 +82,15 @@ struct RepeaterStatusView: View { if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } - // Refresh neighbors only if already loaded - if viewModel.neighborsLoaded { + // Refresh neighbors only if already loaded (skip during discovery polling) + if viewModel.neighborsLoaded && !viewModel.isDiscovering { await viewModel.requestNeighbors(for: session) } } } + .onDisappear { + viewModel.stopDiscovery() + } .presentationDetents([.large]) } @@ -110,7 +113,8 @@ struct RepeaterStatusView: View { viewModel: viewModel, session: session, contacts: contacts, - discoveredNodes: discoveredNodes + discoveredNodes: discoveredNodes, + connectionState: appState.connectionState ) } @@ -142,8 +146,8 @@ struct RepeaterStatusView: View { if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } - // Refresh neighbors only if already loaded - if viewModel.neighborsLoaded { + // Refresh neighbors only if already loaded (skip during discovery polling) + if viewModel.neighborsLoaded && !viewModel.isDiscovering { await viewModel.requestNeighbors(for: session) } } @@ -221,17 +225,18 @@ private struct NeighborsSection: View { let session: RemoteNodeSessionDTO let contacts: [ContactDTO] let discoveredNodes: [DiscoveredNodeDTO] + let connectionState: ConnectionState var body: some View { Section { DisclosureGroup(isExpanded: $viewModel.neighborsExpanded) { - if viewModel.isLoadingNeighbors { + if viewModel.isLoadingNeighbors && !viewModel.isDiscovering { HStack { Spacer() ProgressView() Spacer() } - } else if viewModel.neighbors.isEmpty { + } else if viewModel.neighbors.isEmpty && !viewModel.isDiscovering { Text(L10n.RemoteNodes.RemoteNodes.Status.noNeighbors) .foregroundStyle(.secondary) } else { @@ -269,6 +274,27 @@ private struct NeighborsSection: View { } } } + + if session.isAdmin { + Button { + if viewModel.isDiscovering { + viewModel.stopDiscovery() + } else { + viewModel.startDiscovery(for: session) + } + } label: { + HStack { + if viewModel.isDiscovering { + ProgressView() + .controlSize(.small) + Text(L10n.RemoteNodes.RemoteNodes.Status.discoveringSeconds(viewModel.discoverySecondsRemaining)) + } else { + Label(L10n.RemoteNodes.RemoteNodes.Status.discoverNeighbors, systemImage: "antenna.radiowaves.left.and.right") + } + } + } + .radioDisabled(for: connectionState, or: viewModel.isLoadingNeighbors && !viewModel.isDiscovering) + } } label: { HStack { Text(L10n.RemoteNodes.RemoteNodes.Status.neighbors) diff --git a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift index a87e142b2..e8da4ac2d 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -27,6 +27,15 @@ final class RepeaterStatusViewModel { /// Whether the neighbors disclosure group is expanded var neighborsExpanded = false + /// Discovery state + var isDiscovering: Bool { discoverTask != nil } + var discoverySecondsRemaining = 0 + private var discoverTask: Task? + + private static let discoveryDuration = 60 + private static let pollIntervalTicks = 5 + private static let discoverCommand = "discover.neighbors" + /// Owner info text var ownerInfo: String? @@ -146,6 +155,56 @@ final class RepeaterStatusViewModel { } } + // MARK: - Discovery + + func startDiscovery(for session: RemoteNodeSessionDTO) { + guard let repeaterAdminService, !isDiscovering else { return } + + discoverySecondsRemaining = Self.discoveryDuration + + discoverTask = Task { + do { + _ = try await repeaterAdminService.sendCommand( + sessionID: session.id, + command: Self.discoverCommand + ) + } catch { + helper.errorMessage = error.localizedDescription + discoverySecondsRemaining = 0 + discoverTask = nil + return + } + + let startTime = Date.now + var tickCount = 0 + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + + let elapsed = Int(Date.now.timeIntervalSince(startTime)) + let remaining = max(0, Self.discoveryDuration - elapsed) + discoverySecondsRemaining = remaining + + tickCount += 1 + if tickCount.isMultiple(of: Self.pollIntervalTicks) { + await requestNeighbors(for: session) + } + + if remaining <= 0 { break } + } + + discoverySecondsRemaining = 0 + discoverTask = nil + } + } + + func stopDiscovery() { + discoverTask?.cancel() + discoverTask = nil + discoverySecondsRemaining = 0 + } + // MARK: - Telemetry func requestTelemetry(for session: RemoteNodeSessionDTO) async { From a7061070069ca0908b094384855878b1b8e1c3e7 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:25:09 -0700 Subject: [PATCH 42/48] feat(los): show obstruction markers on map - Render red X markers on map at peak of each obstruction region - White-cased stroke style to match the LOS path lines - Build map lines from elevation profile coords for accurate marker alignment - Data-driven icon anchor so X centers on coordinate - Group obstruction points into contiguous regions, one marker per region - Align terrain profile red bars with 60% Fresnel threshold to match markers - Fix invisible red bars for single-sample obstructions (zero-width rect) - Replace isCrosshair/isObstruction bools with RenderStyle enum - Extract worstObstructionPoint on PathAnalysisResult --- MC1/Calculations/RFCalculator.swift | 40 +++++++ .../LineOfSight/FresnelZoneRenderer.swift | 4 +- .../LineOfSight/LineOfSightViewModel.swift | 43 +++++-- .../LineOfSight/TerrainProfileCanvas.swift | 25 ++-- MC1/Views/Map/MC1MapView+Layers.swift | 47 ++++++-- MC1/Views/Map/MapPoint.swift | 1 + MC1/Views/Map/PinSpriteRenderer.swift | 107 ++++++++++++++---- 7 files changed, 216 insertions(+), 51 deletions(-) diff --git a/MC1/Calculations/RFCalculator.swift b/MC1/Calculations/RFCalculator.swift index 520becace..e192aac70 100644 --- a/MC1/Calculations/RFCalculator.swift +++ b/MC1/Calculations/RFCalculator.swift @@ -39,6 +39,46 @@ struct PathAnalysisResult: Equatable { let refractionK: Double var distanceKm: Double { distanceMeters / 1000 } + + var worstObstructionPoint: ObstructionPoint? { + obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent }) + } + + /// Returns the worst obstruction point per contiguous obstructed region. + /// Groups adjacent obstruction points by sample spacing, then picks the + /// lowest clearance point from each group — one per red bar in the terrain profile. + var peakObstructionPerRegion: [ObstructionPoint] { + guard obstructionPoints.count >= 2 else { return obstructionPoints } + + // Find the smallest gap between consecutive points (= one sample step) + var minGap = Double.infinity + for i in 1.. 0 && gap < minGap { minGap = gap } + } + guard minGap.isFinite else { return [obstructionPoints[0]] } + + // A gap > 2x the sample step means a non-obstructed sample separates two regions + let gapThreshold = minGap * 2.5 + + var regions: [ObstructionPoint] = [] + var regionWorst = obstructionPoints[0] + + for i in 1.. gapThreshold { + regions.append(regionWorst) + regionWorst = point + } else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent { + regionWorst = point + } + } + regions.append(regionWorst) + + return regions + } } /// Elevation sample along the path diff --git a/MC1/Views/LineOfSight/FresnelZoneRenderer.swift b/MC1/Views/LineOfSight/FresnelZoneRenderer.swift index 9b1753d89..6a3afd097 100644 --- a/MC1/Views/LineOfSight/FresnelZoneRenderer.swift +++ b/MC1/Views/LineOfSight/FresnelZoneRenderer.swift @@ -105,8 +105,8 @@ struct ProfileSample { min(max(yTerrain, yBottom60), yTop60) } - /// Whether terrain intrudes into the Fresnel zone at this point - var isObstructed: Bool { yTerrain > yBottom } + /// Whether terrain intrudes past the 60% Fresnel clearance threshold at this point + var isObstructed: Bool { yTerrain > yBottom60 } /// Visible bottom of Fresnel zone (clamped to avoid path inversion) var yVisibleBottom: Double { diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 32ff98e78..96a613c02 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -250,7 +250,12 @@ final class LineOfSightViewModel { // MARK: - Analysis State - private(set) var analysisStatus: AnalysisStatus = .idle + private(set) var analysisStatus: AnalysisStatus = .idle { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } private(set) var isAnalyzing = false private(set) var elevationProfile: [ElevationSample] = [] @@ -380,6 +385,25 @@ final class LineOfSightViewModel { )) } + if repeaterPoint == nil, + case .result(let result) = analysisStatus, + result.clearanceStatus != .clear { + for obstruction in result.peakObstructionPerRegion { + let pathFraction = obstruction.distanceFromAMeters / result.distanceMeters + if let coordinate = coordinateAt(pathFraction: pathFraction) { + points.append(MapPoint( + id: obstruction.id, + coordinate: coordinate, + pinStyle: .obstruction, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + } + } + mapPoints = points } @@ -394,17 +418,20 @@ final class LineOfSightViewModel { let dimOpacity = 0.3 if let r = repeaterPoint?.coordinate { + let arCoords = elevationProfileAR.isEmpty ? [a, r] : elevationProfileAR.map(\.coordinate) + let rbCoords = elevationProfileRB.isEmpty ? [r, b] : elevationProfileRB.map(\.coordinate) let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity mapLines = [ - MapLine(id: "los-ar", coordinates: [a, r], style: .los, - opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), - MapLine(id: "los-rb", coordinates: [r, b], style: .los, - opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) + MapLine(id: "los-ar", coordinates: arCoords, + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: rbCoords, + style: .los, opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) ] } else { + let coords = elevationProfile.isEmpty ? [a, b] : elevationProfile.map(\.coordinate) let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity - mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + mapLines = [MapLine(id: "los-ab", coordinates: coords, style: .los, opacity: opacity)] } } @@ -650,12 +677,10 @@ final class LineOfSightViewModel { /// Adds repeater at the worst obstruction point func addRepeater() { guard case .result(let result) = analysisStatus, - !result.obstructionPoints.isEmpty, - let worstPoint = result.obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent }) else { + let worstPoint = result.worstObstructionPoint else { return } - // Convert distance to path fraction let pathFraction = worstPoint.distanceFromAMeters / result.distanceMeters // Get coordinate and elevation from cached profile diff --git a/MC1/Views/LineOfSight/TerrainProfileCanvas.swift b/MC1/Views/LineOfSight/TerrainProfileCanvas.swift index a7d7434b2..b292e1634 100644 --- a/MC1/Views/LineOfSight/TerrainProfileCanvas.swift +++ b/MC1/Views/LineOfSight/TerrainProfileCanvas.swift @@ -500,17 +500,24 @@ extension TerrainProfileCanvas { let regionSamples = Array(samples[startIndex...endIndex]) guard let first = regionSamples.first, let last = regionSamples.last else { return } - // Draw a rectangle spanning the full vertical height of the chart - let topLeft = coords.point(x: first.x, y: yRange.upperBound) - let topRight = coords.point(x: last.x, y: yRange.upperBound) - let bottomRight = coords.point(x: last.x, y: yRange.lowerBound) - let bottomLeft = coords.point(x: first.x, y: yRange.lowerBound) + // Ensure a minimum pixel width so single-sample obstructions are visible + let minWidth: CGFloat = 4 + var leftX = coords.xPixel(first.x) + var rightX = coords.xPixel(last.x) + if rightX - leftX < minWidth { + let center = (leftX + rightX) / 2 + leftX = center - minWidth / 2 + rightX = center + minWidth / 2 + } + + let topY = coords.yPixel(yRange.upperBound) + let bottomY = coords.yPixel(yRange.lowerBound) var path = Path() - path.move(to: topLeft) - path.addLine(to: topRight) - path.addLine(to: bottomRight) - path.addLine(to: bottomLeft) + path.move(to: CGPoint(x: leftX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: topY)) + path.addLine(to: CGPoint(x: rightX, y: bottomY)) + path.addLine(to: CGPoint(x: leftX, y: bottomY)) path.closeSubpath() context.fill(path, with: .color(fresnelObstructed)) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 094c46aaa..69366378e 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -18,6 +18,7 @@ enum MapLayerID { static let fixedNameLabels = "fixed-name-labels" static let fixedBadgeText = "fixed-badge-text" static let lineLOS = "line-los" + static let lineLOSCasing = "line-los-casing" static let lineTraceUntraced = "line-trace-untraced" static let lineTraceWeak = "line-trace-weak" static let lineTraceMedium = "line-trace-medium" @@ -158,7 +159,7 @@ extension MC1MapView.Coordinator { private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") - fixedIconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + fixedIconLayer.iconAnchor = NSExpression(forKeyPath: "anchorType") fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) fixedIconLayer.text = nil @@ -182,11 +183,23 @@ extension MC1MapView.Coordinator { let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) style.addSource(source) + let losCasing = MLNLineStyleLayer(identifier: MapLayerID.lineLOSCasing, source: source) + losCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losCasing.lineColor = NSExpression(forConstantValue: UIColor.white) + losCasing.lineOpacity = NSExpression(forConstantValue: 0.8) + losCasing.lineWidth = NSExpression(forConstantValue: 6) + losCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) + losCasing.lineJoin = NSExpression(forConstantValue: "round") + losCasing.lineCap = NSExpression(forConstantValue: "round") + style.addLayer(losCasing) + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) losLayer.lineWidth = NSExpression(forConstantValue: 3) - losLayer.lineDashPattern = NSExpression(forConstantValue: [8, 4]) + losLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + losLayer.lineJoin = NSExpression(forConstantValue: "round") + losLayer.lineCap = NSExpression(forConstantValue: "round") losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") style.addLayer(losLayer) @@ -195,13 +208,12 @@ extension MC1MapView.Coordinator { let roundJoin = NSExpression(forConstantValue: "round") let roundCap = NSExpression(forConstantValue: "round") - // Untraced: width 2, dash [8, 6] → casing width 5, dash scaled by 2/5 let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) untracedCasing.lineColor = white untracedCasing.lineOpacity = casingOpacity untracedCasing.lineWidth = NSExpression(forConstantValue: 5) - untracedCasing.lineDashPattern = NSExpression(forConstantValue: [1.6, 1.2]) + untracedCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) untracedCasing.lineJoin = roundJoin untracedCasing.lineCap = roundCap style.addLayer(untracedCasing) @@ -210,16 +222,17 @@ extension MC1MapView.Coordinator { untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) untracedLayer.lineWidth = NSExpression(forConstantValue: 2) - untracedLayer.lineDashPattern = NSExpression(forConstantValue: [4, 3]) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [1.75, 3.25]) + untracedLayer.lineJoin = roundJoin + untracedLayer.lineCap = roundCap style.addLayer(untracedLayer) - // Weak: width 3, dash [4, 4] → casing width 6, dash scaled by 3/6 let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) weakCasing.lineColor = white weakCasing.lineOpacity = casingOpacity weakCasing.lineWidth = NSExpression(forConstantValue: 6) - weakCasing.lineDashPattern = NSExpression(forConstantValue: [1, 1]) + weakCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) weakCasing.lineJoin = roundJoin weakCasing.lineCap = roundCap style.addLayer(weakCasing) @@ -228,16 +241,17 @@ extension MC1MapView.Coordinator { weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) weakLayer.lineWidth = NSExpression(forConstantValue: 3) - weakLayer.lineDashPattern = NSExpression(forConstantValue: [2, 2]) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + weakLayer.lineJoin = roundJoin + weakLayer.lineCap = roundCap style.addLayer(weakLayer) - // Medium: width 3, dash [12, 4] → casing width 6, dash scaled by 3/6 let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) mediumCasing.lineColor = white mediumCasing.lineOpacity = casingOpacity mediumCasing.lineWidth = NSExpression(forConstantValue: 6) - mediumCasing.lineDashPattern = NSExpression(forConstantValue: [3, 1]) + mediumCasing.lineDashPattern = NSExpression(forConstantValue: [0.7, 1.3]) mediumCasing.lineJoin = roundJoin mediumCasing.lineCap = roundCap style.addLayer(mediumCasing) @@ -246,7 +260,9 @@ extension MC1MapView.Coordinator { mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) mediumLayer.lineWidth = NSExpression(forConstantValue: 3) - mediumLayer.lineDashPattern = NSExpression(forConstantValue: [6, 2]) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + mediumLayer.lineJoin = roundJoin + mediumLayer.lineCap = roundCap style.addLayer(mediumLayer) // Good: width 4, solid → casing width 7 @@ -356,6 +372,7 @@ extension MC1MapView.Coordinator { var attributes: [String: Any] = [ "pointId": point.id.uuidString, "spriteName": spriteName(for: point), + "anchorType": iconAnchor(for: point), ] if let label = point.label { attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" @@ -366,6 +383,13 @@ extension MC1MapView.Coordinator { return feature } + private func iconAnchor(for point: MapPoint) -> String { + switch point.pinStyle { + case .crosshair, .obstruction: "center" + default: "bottom" + } + } + private func spriteName(for point: MapPoint) -> String { switch point.pinStyle { case .contactChat: "pin-chat" @@ -383,6 +407,7 @@ extension MC1MapView.Coordinator { case .pointA: "pin-point-a" case .pointB: "pin-point-b" case .crosshair: "pin-crosshair" + case .obstruction: "pin-obstruction" case .badge: "pin-badge" } } diff --git a/MC1/Views/Map/MapPoint.swift b/MC1/Views/Map/MapPoint.swift index 041f7376a..5023f604c 100644 --- a/MC1/Views/Map/MapPoint.swift +++ b/MC1/Views/Map/MapPoint.swift @@ -18,6 +18,7 @@ struct MapPoint: Identifiable, Equatable { case pointA case pointB case crosshair + case obstruction case badge } diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index e78022811..ed25fd5a6 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -62,48 +62,60 @@ enum PinSpriteRenderer { // MARK: - Sprite specifications + private enum RenderStyle { + case standard + case crosshair + case obstruction + } + private struct SpriteSpec { let name: String let circleColor: UIColor let iconName: String? // SF Symbol name let text: String? // e.g. "A", "B" for point pins let ringColor: UIColor? // selection ring - let isCrosshair: Bool + let renderStyle: RenderStyle } private static let allSpecs: [SpriteSpec] = [ // Main map contacts SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), - iconName: "person.fill", text: nil, ringColor: nil, isCrosshair: false), + iconName: "person.fill", text: nil, ringColor: nil, renderStyle: .standard), SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, isCrosshair: false), + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, renderStyle: .standard), SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), - iconName: "person.3.fill", text: nil, ringColor: nil, isCrosshair: false), + iconName: "person.3.fill", text: nil, ringColor: nil, renderStyle: .standard), // LOS/TracePath repeater states SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, isCrosshair: false), + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, renderStyle: .standard), SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, isCrosshair: false), + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, renderStyle: .standard), SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, - iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, isCrosshair: false), + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, renderStyle: .standard), // LOS point pins SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, - iconName: nil, text: "A", ringColor: nil, isCrosshair: false), + iconName: nil, text: "A", ringColor: nil, renderStyle: .standard), SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, - iconName: nil, text: "B", ringColor: nil, isCrosshair: false), + iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), // LOS crosshair target SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, - iconName: nil, text: "R", ringColor: nil, isCrosshair: true), + iconName: nil, text: "R", ringColor: nil, renderStyle: .crosshair), + + // LOS obstruction marker + SpriteSpec(name: "pin-obstruction", circleColor: .systemRed, + iconName: nil, text: nil, ringColor: nil, renderStyle: .obstruction), ] // MARK: - Rendering private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { - if spec.isCrosshair { - return renderCrosshair(spec) + switch spec.renderStyle { + case .crosshair: return renderCrosshair(spec) + case .obstruction: return renderObstruction() + case .standard: break } let circleSize: CGFloat = 36 @@ -296,29 +308,84 @@ enum PinSpriteRenderer { } } + private static func renderObstruction() -> UIImage { + let size: CGFloat = 20 + let padding: CGFloat = 3 + let totalSize = size + padding * 2 + let armLength: CGFloat = size / 2 - 1 + let casingWidth: CGFloat = 6 + let strokeWidth: CGFloat = 2.5 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: totalSize / 2, y: totalSize / 2) + + // Draw white casing (thick white stroke behind the red X) + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(casingWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + + // Draw red X on top + cgContext.setStrokeColor(UIColor.systemRed.cgColor) + cgContext.setLineWidth(strokeWidth) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x - armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x + armLength, y: center.y + armLength)) + cgContext.move(to: CGPoint(x: center.x + armLength, y: center.y - armLength)) + cgContext.addLine(to: CGPoint(x: center.x - armLength, y: center.y + armLength)) + cgContext.strokePath() + } + } + private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { - let size: CGFloat = 44 + let casingWidth: CGFloat = 6 + let capInset = casingWidth / 2 + let size: CGFloat = 44 + capInset * 2 let gapRadius: CGFloat = 4 - let outerRadius = size / 2 + let outerRadius: CGFloat = 22 let badgeHeight: CGFloat = 20 - let totalHeight = size + badgeHeight + 2 + let badgeGap: CGFloat = 2 + // Top padding so the crosshair center sits at the image's vertical midpoint + let topPadding = badgeHeight + badgeGap + let totalHeight = topPadding + size + badgeGap + badgeHeight let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) return renderer.image { ctx in let cgContext = ctx.cgContext - let center = CGPoint(x: size / 2, y: size / 2) + let center = CGPoint(x: size / 2, y: topPadding + size / 2) + + // White casing behind crosshair lines + cgContext.setStrokeColor(UIColor.white.cgColor) + cgContext.setLineWidth(6) + cgContext.setLineCap(.round) + + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() // Crosshair lines cgContext.setStrokeColor(UIColor.systemPurple.cgColor) cgContext.setLineWidth(2) + cgContext.setLineCap(.round) - // Vertical cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - - // Horizontal cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) @@ -327,7 +394,7 @@ enum PinSpriteRenderer { // "R" badge let badgeWidth: CGFloat = 20 - let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: size + 2, width: badgeWidth, height: badgeHeight) + let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: topPadding + size + badgeGap, width: badgeWidth, height: badgeHeight) let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) UIColor.systemPurple.setFill() badgePath.fill() From 8a1fa003234022166a3961831b7e5ce420015d53 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:24:06 -0700 Subject: [PATCH 43/48] fix(signal): sync uiColor with updated SNR tier colors - uiColor still had the old 4-color mapping after 007c4d2e changed color - Map layers now use SNRQuality.uiColor instead of hardcoded UIColors --- MC1/Extensions/SNRQuality+Color.swift | 5 ++--- MC1/Views/Map/MC1MapView+Layers.swift | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MC1/Extensions/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 2826f04e7..4a26f8bd2 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -16,9 +16,8 @@ extension SNRQuality { /// UIKit color for MapKit renderers. var uiColor: UIColor { switch self { - case .excellent: .systemGreen - case .good: .systemYellow - case .fair: .systemOrange + case .excellent, .good: .systemGreen + case .fair: .systemYellow case .poor: .systemRed case .unknown: .systemGray } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 69366378e..a33db9163 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -1,4 +1,5 @@ import MapLibre +import MC1Services import UIKit /// Font stack available on the OpenFreeMap glyph server. @@ -239,7 +240,7 @@ extension MC1MapView.Coordinator { let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) - weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) + weakLayer.lineColor = NSExpression(forConstantValue: SNRQuality.poor.uiColor) weakLayer.lineWidth = NSExpression(forConstantValue: 3) weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) weakLayer.lineJoin = roundJoin @@ -258,7 +259,7 @@ extension MC1MapView.Coordinator { let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) - mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) + mediumLayer.lineColor = NSExpression(forConstantValue: SNRQuality.fair.uiColor) mediumLayer.lineWidth = NSExpression(forConstantValue: 3) mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) mediumLayer.lineJoin = roundJoin @@ -277,7 +278,7 @@ extension MC1MapView.Coordinator { let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) - goodLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGreen) + goodLayer.lineColor = NSExpression(forConstantValue: SNRQuality.good.uiColor) goodLayer.lineWidth = NSExpression(forConstantValue: 4) style.addLayer(goodLayer) } From 39b1cc9aa11ef7860c4fad54221d7d126a70617e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:29:52 -0700 Subject: [PATCH 44/48] ui(trace): remove empty state overlay from map view - Map was hidden behind a ContentUnavailableView when no repeaters had location data, now it's always visible - Remove orphaned localization keys across all 9 languages --- MC1/Resources/Generated/L10n.swift | 6 ----- .../Localization/de.lproj/Contacts.strings | 5 ----- .../Localization/en.lproj/Contacts.strings | 5 ----- .../Localization/es.lproj/Contacts.strings | 5 ----- .../Localization/fr.lproj/Contacts.strings | 5 ----- .../Localization/nl.lproj/Contacts.strings | 5 ----- .../Localization/pl.lproj/Contacts.strings | 5 ----- .../Localization/ru.lproj/Contacts.strings | 5 ----- .../Localization/uk.lproj/Contacts.strings | 5 ----- .../zh-Hans.lproj/Contacts.strings | 5 ----- .../TracePathMap/TracePathMapView.swift | 22 ------------------- 11 files changed, 73 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 249a9227f..dddd53c85 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1816,12 +1816,6 @@ public enum L10n { return L10n.tr("Contacts", "contacts.trace.map.cluster.label", p1, fallback: "%d repeaters") } } - public enum Empty { - /// Location: TracePathMapView.swift - Purpose: Empty state description - public static let description = L10n.tr("Contacts", "contacts.trace.map.empty.description", fallback: "Use List view to build paths with repeaters that don't have location data.") - /// Location: TracePathMapView.swift - Purpose: Empty state title - public static let title = L10n.tr("Contacts", "contacts.trace.map.empty.title", fallback: "No Repeaters with Location") - } public enum Pin { /// Location: TracePathRepeaterPinView.swift - Accessibility hint for adding repeater to path public static let addHint = L10n.tr("Contacts", "contacts.trace.map.pin.addHint", fallback: "Double tap to add to path") diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index c01b76aa1..5c83e8c26 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d Sprünge"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Keine Repeater mit Standort"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Verwende die Listenansicht, um Pfade mit Repeatern ohne Standortdaten zu erstellen."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Löschen"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index a2b9ca893..15e7a3989 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d hops"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "No Repeaters with Location"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Use List view to build paths with repeaters that don't have location data."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Clear"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 4defd58b7..ee58c3c00 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d saltos"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Sin repetidores con ubicación"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Usa la vista de Lista para construir rutas con repetidores que no tienen datos de ubicación."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Limpiar"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 987ff347b..aa553023e 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d sauts"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Aucun répéteur avec position"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Utilisez la vue Liste pour construire des chemins avec des répéteurs qui n'ont pas de données de position."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Effacer"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index de4875284..5d8391b35 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d sprongen"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Geen repeaters met locatie"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Gebruik Lijstweergave om paden te bouwen met repeaters zonder locatiegegevens."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Wissen"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 03bd9459e..9e2ec8992 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -868,11 +868,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d skoków"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Brak przekaźników z lokalizacją"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Użyj widoku listy do budowania ścieżek z przekaźnikami bez danych o lokalizacji."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Wyczyść"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 7fcac86b1..6fa75bf20 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -868,11 +868,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d переходов"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Нет ретрансляторов с местоположением"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Используй режим «Список» для построения путей с ретрансляторами без данных о местоположении."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Очистить"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 17559d38c..2f38e316d 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -868,11 +868,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d переходів"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "Немає ретрансляторів з місцезнаходженням"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "Використовуй режим Список для побудови шляхів з ретрансляторами без даних про місцезнаходження."; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "Очистити"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 3e50f4c62..e73dc46d7 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -881,11 +881,6 @@ /* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ "contacts.trace.map.hops" = "%d 跳"; -/* Location: TracePathMapView.swift - Purpose: Empty state title */ -"contacts.trace.map.empty.title" = "无位置信息的转发节点"; - -/* Location: TracePathMapView.swift - Purpose: Empty state description */ -"contacts.trace.map.empty.description" = "使用列表视图以构建没有位置数据的转发路径"; /* Location: TracePathMapView.swift - Purpose: Clear button */ "contacts.trace.map.clear" = "清除"; diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index d81da5783..8d1c521b5 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -37,11 +37,6 @@ struct TracePathMapView: View { ) } - // Empty state - if mapViewModel.repeatersWithLocation.isEmpty { - TracePathEmptyState() - } - // Floating buttons TracePathFloatingButtonsView( mapViewModel: mapViewModel, @@ -184,20 +179,3 @@ private struct TracePathResultsBanner: View { } } -// MARK: - Empty State - -private struct TracePathEmptyState: View { - var body: some View { - VStack { - Spacer() - ContentUnavailableView( - L10n.Contacts.Contacts.Trace.Map.Empty.title, - systemImage: "map", - description: Text(L10n.Contacts.Contacts.Trace.Map.Empty.description) - ) - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 16)) - .padding() - } - } -} From c8a08fe4660d952459b17c2b89e02844f22e42b2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:09:41 -0700 Subject: [PATCH 45/48] fix(block): discard channel messages from blocked senders at ingestion Instead of storing blocked sender messages and filtering at display time, drop them before saving to the database. Update block confirmation alert text across all 9 locales to reflect that channel messages are now discarded rather than hidden, and cannot be recovered on unblock. --- MC1/Resources/Localization/de.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/en.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/es.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/fr.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/nl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/pl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/ru.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/uk.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings | 2 +- .../MC1Services/SyncCoordinator+MessageHandlers.swift | 5 +++++ 10 files changed, 14 insertions(+), 9 deletions(-) diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 5c83e8c26..1fa3771fc 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Kontakt blockieren"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Du wirst keine Nachrichten von %@ erhalten. Unterhaltungen von diesem Benutzer werden aus deiner Chatliste ausgeblendet und Kanalnachrichten werden nicht angezeigt. Das Entsperren macht diese Aktionen rückgängig und zeigt alle gesendeten Nachrichten an."; +"contacts.detail.alert.block.message" = "Du wirst keine Nachrichten von %@ erhalten. Unterhaltungen werden aus deiner Chatliste ausgeblendet und neue Kanalnachrichten werden verworfen. Nach dem Entsperren werden neue Nachrichten wieder angezeigt, aber verworfene Nachrichten können nicht wiederhergestellt werden."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "%@ löschen"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 15e7a3989..aec8d0e76 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Block Contact"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "You won't receive messages from %@. Conversations from this user will be hidden from your Chats list, and their channel messages will not appear. Unblocking will reverse these actions and make visible any messages they have sent."; +"contacts.detail.alert.block.message" = "You won't receive messages from %@. Their conversations will be hidden from your Chats list and new channel messages will be discarded. Unblocking will allow new messages, but discarded messages cannot be recovered."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Delete %@"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index ee58c3c00..74b75ff7f 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Bloquear contacto"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "No recibirás mensajes de %@. Las conversaciones de este usuario se ocultarán de tu lista de Chats, y sus mensajes de canal no aparecerán. Desbloquear revertirá estas acciones y hará visibles los mensajes que hayan enviado."; +"contacts.detail.alert.block.message" = "No recibirás mensajes de %@. Sus conversaciones se ocultarán de tu lista de Chats y los nuevos mensajes de canal serán descartados. Al desbloquear se permitirán nuevos mensajes, pero los mensajes descartados no se pueden recuperar."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Eliminar %@"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index aa553023e..3890f913c 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Bloquer le contact"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Vous ne recevrez plus de messages de %@. Les conversations de cet utilisateur seront masquées de votre liste de Messages, et leurs messages de canal n'apparaîtront pas. Le déblocage inversera ces actions et rendra visibles tous les messages qu'ils ont envoyés."; +"contacts.detail.alert.block.message" = "Vous ne recevrez plus de messages de %@. Leurs conversations seront masquées de votre liste de Messages et les nouveaux messages de canal seront supprimés. Le déblocage permettra de nouveaux messages, mais les messages supprimés ne pourront pas être récupérés."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Supprimer %@"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 5d8391b35..3ff13a60d 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Contact blokkeren"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Je ontvangt geen berichten meer van %@. Gesprekken van deze gebruiker worden verborgen in je Chats-lijst en hun kanaalberichten verschijnen niet. Deblokkeren maakt dit ongedaan en toont alle berichten die ze hebben gestuurd."; +"contacts.detail.alert.block.message" = "Je ontvangt geen berichten meer van %@. Hun gesprekken worden verborgen in je Chats-lijst en nieuwe kanaalberichten worden verwijderd. Deblokkeren staat nieuwe berichten toe, maar verwijderde berichten kunnen niet worden hersteld."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "%@ verwijderen"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 9e2ec8992..1b14d489d 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Zablokuj kontakt"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Nie będziesz otrzymywać wiadomości od %@. Rozmowy od tego użytkownika będą ukryte na liście czatów, a ich wiadomości na kanałach nie będą wyświetlane. Odblokowanie odwróci te działania i uczyni widocznymi wszystkie wysłane wiadomości."; +"contacts.detail.alert.block.message" = "Nie będziesz otrzymywać wiadomości od %@. Rozmowy będą ukryte na liście czatów, a nowe wiadomości na kanałach zostaną odrzucone. Odblokowanie pozwoli na nowe wiadomości, ale odrzuconych wiadomości nie można odzyskać."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Usuń %@"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index 6fa75bf20..bd2d5e3a0 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Заблокировать контакт"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Ты больше не будешь получать сообщения от %@. Переписки с этим пользователем будут скрыты из списка чатов, а их сообщения в каналах не будут отображаться. Разблокировка отменит эти действия и покажет все полученные сообщения."; +"contacts.detail.alert.block.message" = "Ты больше не будешь получать сообщения от %@. Переписки будут скрыты из списка чатов, а новые сообщения в каналах будут отклонены. Разблокировка позволит получать новые сообщения, но отклонённые сообщения восстановить невозможно."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Удалить %@"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 2f38e316d..84616face 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "Заблокувати контакт"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Ти не отримуватимеш повідомлення від %@. Розмови з цим користувачем будуть приховані зі списку чатів, а їхні повідомлення в каналах не відображатимуться. Розблокування скасує ці дії та зробить видимими всі надіслані ними повідомлення."; +"contacts.detail.alert.block.message" = "Ти не отримуватимеш повідомлення від %@. Розмови будуть приховані зі списку чатів, а нові повідомлення в каналах будуть відхилені. Розблокування дозволить отримувати нові повідомлення, але відхилені повідомлення відновити неможливо."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Видалити %@"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index e73dc46d7..2834d605f 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -354,7 +354,7 @@ "contacts.detail.alert.block.title" = "屏蔽联系人"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "屏蔽后,你将不再接收来自 %@ 的消息。与他的对话会被隐藏,其频道消息也不会显示。取消屏蔽即可恢复。"; +"contacts.detail.alert.block.message" = "屏蔽后,你将不再接收来自 %@ 的消息。对话会被隐藏,新的频道消息将被丢弃。取消屏蔽后可接收新消息,但已丢弃的消息无法恢复。"; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "删除%@"; diff --git a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift index 778f38455..354487445 100644 --- a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift @@ -287,6 +287,11 @@ extension SyncCoordinator { self.logger.warning("Dedup check failed, proceeding with save: \(error)") } + // Discard messages from blocked senders + if await self.isBlockedSender(senderNodeName) { + return + } + // Check if this is a reaction if await self.handleChannelReaction( text: messageText, From c1fa204827ac6f8052cf591054002a853eee982d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:15:04 -0700 Subject: [PATCH 46/48] ui(contacts): rename "Ping" to "Zero-Hop Ping" in contact details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated contacts.detail.ping across all 9 locales - Matches Zero-Hop terminology used in the radio menu - zh-Hans uses 零跳, consistent with sendZeroHopAdvert translation --- MC1/Resources/Generated/L10n.swift | 4 ++-- MC1/Resources/Localization/de.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/en.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/es.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/fr.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/nl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/pl.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/ru.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/uk.lproj/Contacts.strings | 2 +- MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index dddd53c85..ae07e3875 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1084,7 +1084,7 @@ public enum L10n { /// Location: ContactDetailView.swift - Purpose: Footer for path routing public static let pathFooter = L10n.tr("Contacts", "contacts.detail.pathFooter", fallback: "Messages route through the path shown. Reset Path to use flood routing instead.") /// Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes - public static let ping = L10n.tr("Contacts", "contacts.detail.ping", fallback: "Ping") + public static let ping = L10n.tr("Contacts", "contacts.detail.ping", fallback: "Zero-Hop Ping") /// Location: ContactDetailView.swift - Purpose: Ping failure VoiceOver announcement public static let pingFailureAnnouncement = L10n.tr("Contacts", "contacts.detail.pingFailureAnnouncement", fallback: "Ping failed") /// Location: ContactDetailView.swift - Purpose: Ping failure accessibility label @@ -1151,7 +1151,7 @@ public enum L10n { public enum Block { /// Location: ContactDetailView.swift - Purpose: Block contact alert message public static func message(_ p1: Any) -> String { - return L10n.tr("Contacts", "contacts.detail.alert.block.message", String(describing: p1), fallback: "You won't receive messages from %@. Conversations from this user will be hidden from your Chats list, and their channel messages will not appear. Unblocking will reverse these actions and make visible any messages they have sent.") + return L10n.tr("Contacts", "contacts.detail.alert.block.message", String(describing: p1), fallback: "You won't receive messages from %@. Their conversations will be hidden from your Chats list and new channel messages will be discarded. Unblocking will allow new messages, but discarded messages cannot be recovered.") } /// Location: ContactDetailView.swift - Purpose: Block contact alert title public static let title = L10n.tr("Contacts", "contacts.detail.alert.block.title", fallback: "Block Contact") diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 1fa3771fc..83e472a78 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Repeater anpingen"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Zero-Hop-Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Keine Antwort"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index aec8d0e76..2113e78ae 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Ping Repeater"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Zero-Hop Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "No response"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 74b75ff7f..ff5309c87 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Ping al repetidor"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Ping Zero-Hop"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Sin respuesta"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 3890f913c..f066d2d01 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Ping du répéteur"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Ping Zero-Hop"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Pas de réponse"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 3ff13a60d..6b7983a36 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Ping repeater"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Zero-Hop Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Geen reactie"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 1b14d489d..3bb273cc4 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Pinguj przekaźnik"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Ping Zero-Hop"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Brak odpowiedzi"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index bd2d5e3a0..2ad939c00 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Пинг ретранслятора"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Zero-Hop Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Нет ответа"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 84616face..93dbd92b4 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Пінг ретранслятора"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "Zero-Hop Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Немає відповіді"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 2834d605f..36509f5a8 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -234,7 +234,7 @@ "contacts.detail.pingRepeater" = "Ping 转发节点"; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ -"contacts.detail.ping" = "Ping"; +"contacts.detail.ping" = "零跳 Ping"; /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "无响应"; From 765ba859678c42d760396de65d044aa3594e930a Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:49:52 -0700 Subject: [PATCH 47/48] fix(app): survive background launch before first unlock - Use in-memory container when the on-disk store is inaccessible (device rebooted, not yet unlocked), then swap to the real store once data protection kicks in - Race the protectedDataDidBecomeAvailable notification against a 1s poll so a missed notification while suspended doesn't hang forever - Keep the existing retry + fatalError for real container failures when the disk is actually reachable --- MC1/MC1App.swift | 59 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/MC1/MC1App.swift b/MC1/MC1App.swift index 294ae005e..a6422e174 100644 --- a/MC1/MC1App.swift +++ b/MC1/MC1App.swift @@ -9,6 +9,7 @@ private let logger = Logger(subsystem: "com.mc1", category: "MC1App") @main struct MC1App: App { @State private var appState: AppState + @State private var awaitingDataProtection = false @Environment(\.scenePhase) private var scenePhase #if DEBUG @@ -23,13 +24,32 @@ struct MC1App: App { do { container = try PersistenceStore.createContainer() } catch { - logger.fault("Container creation failed, retrying: \(error)") + logger.error("Container creation failed: \(error)") + + if UIApplication.shared.isProtectedDataAvailable { + // Data is accessible — this is a genuine failure, not BFU. + // Retry once for transient file system issues. + logger.info("Retrying container creation") + do { + container = try PersistenceStore.createContainer() + } catch { + logger.fault("Container creation failed after retry: \(error)") + fatalError("ModelContainer creation failed after retry while data is available") + } + _appState = State(initialValue: AppState(modelContainer: container)) + return + } + + // Before first unlock: the encrypted store is inaccessible. Create a throwaway + // in-memory container so the struct can initialize. The .task body will wait for + // data protection and replace this with the real store before doing any work. + logger.warning("Protected data unavailable (before first unlock), deferring initialization") do { - container = try PersistenceStore.createContainer() + container = try PersistenceStore.createContainer(inMemory: true) } catch { - logger.fault("Container creation failed after retry: \(error)") - fatalError("Unrecoverable: ModelContainer creation failed after retry") + fatalError("In-memory ModelContainer creation failed: \(error)") } + _awaitingDataProtection = State(initialValue: true) } _appState = State(initialValue: AppState(modelContainer: container)) } @@ -39,6 +59,18 @@ struct MC1App: App { ContentView() .environment(\.appState, appState) .task { + if awaitingDataProtection { + await waitForProtectedData() + do { + let container = try PersistenceStore.createContainer() + appState = AppState(modelContainer: container) + awaitingDataProtection = false + } catch { + logger.fault("Container creation failed after unlock: \(error)") + fatalError("ModelContainer creation failed after protected data became available") + } + } + try? Tips.configure([ .displayFrequency(.immediate) ]) @@ -90,6 +122,25 @@ struct MC1App: App { } #endif + private func waitForProtectedData() async { + guard !UIApplication.shared.isProtectedDataAvailable else { return } + let notification = UIApplication.protectedDataDidBecomeAvailableNotification + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in NotificationCenter.default.notifications(named: notification) { + return + } + } + group.addTask { + while await !UIApplication.shared.isProtectedDataAvailable { + try? await Task.sleep(for: .seconds(1)) + } + } + await group.next() + group.cancelAll() + } + } + private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { switch newPhase { case .active: From ca28805734076d1d223e8d369949188caa801035 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:48:39 -0700 Subject: [PATCH 48/48] fix(repeater): fix contact info backspace and checkmark centering - Switch to @State + onChange so backspace works on loaded text - Center the apply button checkmark to match other save buttons --- MC1/Views/RemoteNodes/SharedNodeViews.swift | 29 ++++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/MC1/Views/RemoteNodes/SharedNodeViews.swift b/MC1/Views/RemoteNodes/SharedNodeViews.swift index 5733e0619..306f80f66 100644 --- a/MC1/Views/RemoteNodes/SharedNodeViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeViews.swift @@ -555,6 +555,7 @@ struct RemoteNodeIdentitySection: View { struct NodeContactInfoSection: View { @Bindable var settings: NodeSettingsHelper var focusedField: FocusState.Binding + @State private var contactText = "" var body: some View { ExpandableSettingsSection( @@ -567,10 +568,7 @@ struct NodeContactInfoSection: View { onLoad: { await settings.fetchContactInfo() }, footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter ) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: Binding( - get: { settings.ownerInfo ?? "" }, - set: { settings.ownerInfo = $0 } - ), axis: .vertical) + TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: $contactText, axis: .vertical) .lineLimit(3...6) .focused(focusedField, equals: .contactInfo) .overlay(alignment: .bottomTrailing) { @@ -579,17 +577,28 @@ struct NodeContactInfoSection: View { .foregroundStyle(settings.ownerInfoCharCount > 119 ? .red : .secondary) .padding(4) } + .onChange(of: settings.ownerInfo, initial: true) { _, newValue in + contactText = newValue ?? "" + } + .onChange(of: contactText) { _, newValue in + settings.ownerInfo = newValue + } Button { Task { await settings.applyContactInfoSettings() } } label: { - if settings.contactInfoApplySuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + HStack { + Spacer() + if settings.contactInfoApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + } + Spacer() } + .animation(.default, value: settings.contactInfoApplySuccess) } .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.ownerInfoCharCount > 119) }