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/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/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/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/SNRQuality+Color.swift b/MC1/Extensions/SNRQuality+Color.swift index 2fb432baf..4a26f8bd2 100644 --- a/MC1/Extensions/SNRQuality+Color.swift +++ b/MC1/Extensions/SNRQuality+Color.swift @@ -1,14 +1,36 @@ import MC1Services import SwiftUI +import UIKit extension SNRQuality { /// SwiftUI color for signal quality indicators. var color: Color { switch self { - case .excellent: .green - case .good: .yellow - case .fair, .poor, .veryPoor: .red + case .excellent, .good: .green + case .fair: .yellow + case .poor: .red case .unknown: .secondary } } + + /// UIKit color for MapKit renderers. + var uiColor: UIColor { + switch self { + case .excellent, .good: .systemGreen + case .fair: .systemYellow + 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/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/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: diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f11c96b64..ae07e3875 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 @@ -905,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 @@ -926,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)") @@ -985,8 +1035,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 @@ -1021,6 +1069,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 @@ -1033,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: "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 @@ -1067,6 +1119,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: "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") @@ -1097,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") @@ -1457,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 { @@ -1600,7 +1660,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") } @@ -1718,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 @@ -1752,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") @@ -1967,10 +2025,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 @@ -1981,8 +2039,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 @@ -2012,10 +2076,12 @@ 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 public static let sendMessage = L10n.tr("Map", "map.detail.action.sendMessage", fallback: "Send Message") /// Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry @@ -2032,12 +2098,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") @@ -2046,13 +2106,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") } } } @@ -2255,8 +2325,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 @@ -2269,6 +2337,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 @@ -2319,20 +2389,36 @@ 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 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 + 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 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 @@ -2391,6 +2477,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 @@ -2403,6 +2491,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 @@ -2420,6 +2510,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)") @@ -2482,7 +2610,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 @@ -2513,8 +2641,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 @@ -2551,6 +2679,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 @@ -2599,6 +2731,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 @@ -2611,6 +2779,14 @@ 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 public static func hoursAgo(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.status.hoursAgo", p1, fallback: "%dh ago") @@ -2631,6 +2807,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 @@ -2643,12 +2821,16 @@ 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 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 @@ -3539,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/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/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 0d68767e3..83e472a78 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"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetrie"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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"; @@ -230,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" = "Zero-Hop-Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Keine Antwort"; @@ -348,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"; @@ -426,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 */ @@ -866,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"; @@ -881,6 +891,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"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 5e7866079..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 3fd5ff080..aa9975bdc 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"; @@ -301,23 +301,91 @@ /* 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"; +// 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 */ "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"; @@ -413,9 +481,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Aktualisieren"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -572,6 +670,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/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/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/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index fee159706..2113e78ae 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"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetry"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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"; @@ -230,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" = "Zero-Hop Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "No response"; @@ -348,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 %@"; @@ -426,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 */ @@ -866,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"; @@ -881,6 +891,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"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 e43db984a..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index e0407fe3d..f1814b958 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"; @@ -301,23 +301,91 @@ /* 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"; +// 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 */ "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"; @@ -413,9 +481,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Refresh"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -572,6 +670,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/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/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/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 7c6036416..ff5309c87 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"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetría"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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"; @@ -230,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 Zero-Hop"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Sin respuesta"; @@ -348,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 %@"; @@ -426,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 */ @@ -866,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"; @@ -881,6 +891,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"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 14ebc09f5..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 47923cc47..166aaa2b9 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"; @@ -301,23 +301,91 @@ /* 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"; +// 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 */ "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"; @@ -413,9 +481,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualizar"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -572,6 +670,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/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/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/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index c97bd6fa5..f066d2d01 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"; @@ -206,11 +206,14 @@ /* 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" = "Telemetry History"; + /* 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"; @@ -230,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 Zero-Hop"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Pas de réponse"; @@ -348,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 %@"; @@ -426,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 */ @@ -866,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"; @@ -881,6 +891,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"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 636f96fd4..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 739c0f0ee..def5f3d77 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"; @@ -301,23 +301,91 @@ /* 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"; +// 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 */ "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"; @@ -413,9 +481,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualiser"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -569,6 +667,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/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/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/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 85c4c89cd..6b7983a36 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"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetrie"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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"; @@ -230,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" = "Zero-Hop Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Geen reactie"; @@ -348,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"; @@ -426,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 */ @@ -866,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"; @@ -881,6 +891,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"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 5f91efc8c..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 0a6858431..6b8142e9d 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"; @@ -301,23 +301,91 @@ /* 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"; +// 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 */ "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"; @@ -413,9 +481,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Vernieuwen"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -572,6 +670,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/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/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/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 10dd1ec11..3bb273cc4 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"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Telemetria"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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ść"; @@ -230,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 Zero-Hop"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Brak odpowiedzi"; @@ -348,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ń %@"; @@ -426,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 */ @@ -853,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ść"; @@ -868,6 +878,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ę"; @@ -892,6 +905,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 */ @@ -921,6 +937,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 f228d19f2..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 @@ -102,11 +110,14 @@ /* 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"; -/* 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"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index d725c0651..cd86fa709 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"; @@ -298,23 +298,91 @@ /* 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"; +// 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 */ "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"; @@ -410,9 +478,18 @@ /* 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."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Odśwież"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -469,6 +546,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"; @@ -499,6 +579,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -569,6 +667,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/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/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/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index a8e11fe32..2ad939c00 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" = "Имя"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Телеметрия"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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" = "Отправить сообщение"; @@ -230,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" = "Zero-Hop Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Нет ответа"; @@ -348,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" = "Удалить %@"; @@ -426,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 */ @@ -853,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" = "Очистить"; @@ -868,6 +878,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" = "Сохранить путь"; @@ -892,6 +905,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 */ @@ -921,6 +937,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 f600210f0..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 @@ -102,11 +110,14 @@ /* 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" = "Доступ к телеметрии"; -/* 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" = "Присоединиться к комнате"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 5a9084ea7..15abbffa5 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" = "Отмена"; @@ -298,23 +298,91 @@ /* 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 переходов"; +// 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 */ "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" = "Статус"; @@ -410,9 +478,18 @@ /* 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" = "Соответствие напряжения и процента для оценки уровня заряда батареи."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Обновить"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -469,6 +546,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" = "Активен"; @@ -499,6 +579,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -569,6 +667,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/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/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/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 6e9c03d72..93dbd92b4 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" = "Ім'я"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "Телеметрія"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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" = "Надіслати повідомлення"; @@ -230,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" = "Zero-Hop Ping"; + /* Location: ContactDetailView.swift - Purpose: Ping no response message */ "contacts.detail.pingNoResponse" = "Немає відповіді"; @@ -348,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" = "Видалити %@"; @@ -426,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 */ @@ -853,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" = "Очистити"; @@ -868,6 +878,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" = "Зберегти шлях"; @@ -892,6 +905,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 */ @@ -921,6 +937,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 0cefe9f55..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 @@ -102,11 +110,14 @@ /* 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" = "Доступ до телеметрії"; -/* 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" = "Приєднатися до кімнати"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 87b3428e9..cab7ab90e 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" = "Скасувати"; @@ -298,23 +298,91 @@ /* 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 переходів"; +// 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 */ "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" = "Статус"; @@ -410,9 +478,18 @@ /* 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" = "Відповідність напруги та відсотка для оцінки рівня заряду батареї."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Оновити"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -469,6 +546,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" = "Активний"; @@ -499,6 +579,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -569,6 +667,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/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/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/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index bdacd5170..36509f5a8 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" = "名称"; @@ -206,11 +206,14 @@ /* Location: ContactDetailView.swift - Purpose: Telemetry button */ "contacts.detail.telemetry" = "遥测数据"; +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Telemetry History"; + /* 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" = "发送消息"; @@ -230,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" = "无响应"; @@ -348,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" = "删除%@"; @@ -426,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 */ @@ -866,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" = "清除"; @@ -881,6 +891,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" = "保存路径"; @@ -905,6 +918,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 */ @@ -934,6 +950,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 d075bbf8b..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 @@ -102,11 +110,14 @@ /* 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" = "遥测数据访问权限"; -/* 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" = "加入房间"; @@ -152,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/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index b9a5e52b5..0c076649d 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" = "取消"; @@ -301,23 +301,91 @@ /* 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 跳"; +// 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 */ "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" = "状态"; @@ -413,9 +481,18 @@ /* 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" = "用于估算电池电量的电压百分比"; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "刷新"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -472,6 +549,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" = "活跃"; @@ -502,6 +582,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 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."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Telemetry History"; + // MARK: - Room Conversation /* Location: RoomConversationView.swift - Disconnected status */ @@ -572,6 +670,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/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/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/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 dccc78799..5baac3427 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,22 @@ 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 { + 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 @@ -628,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/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/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/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/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/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 964413ff2..abb6b7a5c 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -47,15 +47,6 @@ 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 { @@ -66,7 +57,31 @@ struct ChatConversationMessagesContent: View { } else if viewModel.messages.isEmpty { emptyState } else { - messagesTable + 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 + ) } } } @@ -87,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 { @@ -168,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/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/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index 438b7f33b..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 @@ -94,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) @@ -180,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 { 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/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/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/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..03bc49aba 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 ) } } @@ -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" ) @@ -452,29 +452,15 @@ 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))" } - 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/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/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/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index 3acdbcc84..b21d6df20 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -236,6 +236,18 @@ private struct ConnectedMenu: View { .foregroundStyle(.secondary) } } + + Button { + onChangeDevice() + } label: { + Label(L10n.Settings.BleStatus.changeDevice, systemImage: "flipphone") + } + + Button(role: .destructive) { + onDisconnect() + } label: { + Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") + } } } @@ -263,23 +275,12 @@ private struct ConnectedMenu: View { } label: { Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } - - Button { - onChangeDevice() - } label: { - Label(L10n.Settings.BleStatus.changeDevice, systemImage: "antenna.radiowaves.left.and.right") - } - - Button(role: .destructive) { - onDisconnect() - } label: { - Label(L10n.Settings.BleStatus.disconnect, systemImage: "eject") - } } } 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) diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index cdd7bf352..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) @@ -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: { @@ -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/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/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/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/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()) 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 1b74f6955..1d7692c78 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,31 +245,38 @@ 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: { // 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 if session.isRoom { + activeSheet = .roomStatus(session) + } else { + activeSheet = .repeaterStatus(session) + } } }) { if let role = RemoteNodeRole(contactType: currentContact.type) { @@ -292,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) + } } } } @@ -540,35 +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) - - // Admin Access - navigates to settings view after auth - Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.adminAccess, systemImage: "gearshape.2") - } - .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 + ) - // 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) - - 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 @@ -612,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 @@ -683,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()) @@ -717,7 +787,7 @@ private struct ContactLocationSection: View { } .listRowBackground( UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemGroupedBackground)) ) // Open in Maps @@ -732,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() } @@ -827,7 +897,7 @@ private struct ContactNetworkPathSection: View { .foregroundStyle(.primary) } } icon: { - Image(systemName: "chevron.forward.2") + Image(systemName: "arrowshape.bounce.right") .foregroundStyle(.secondary) } } 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..f06bca132 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 ) } @@ -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,8 +370,17 @@ 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.locationService.currentLocation, + guard let userLocation = appState.bestAvailableLocation, node.hasLocation else { return nil } let nodeLocation = CLLocation( @@ -390,7 +419,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 +442,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/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/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 b414ff6c1..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift +++ /dev/null @@ -1,89 +0,0 @@ -import MapKit - -/// 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 - - /// 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: SignalQuality = .untraced, - 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: SignalQuality, 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 9c233e7aa..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift +++ /dev/null @@ -1,39 +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 } - - switch pathOverlay.signalQuality { - case .untraced: - strokeColor = UIColor.systemGray - lineWidth = 2 - lineDashPattern = [8, 6] - - case .good: - strokeColor = UIColor.systemGreen - lineWidth = 4 // Thicker for accessibility (color-blind users) - lineDashPattern = nil - - case .medium: - strokeColor = UIColor.systemYellow - lineWidth = 3 - lineDashPattern = [12, 4] // Different pattern for accessibility - - case .weak: - strokeColor = UIColor.systemRed - lineWidth = 3 - lineDashPattern = [4, 4] // Different pattern for accessibility - } - } -} 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 0b8dabc68..8d1c521b5 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,32 +31,46 @@ struct TracePathMapView: View { // Results banner at top if let result = mapViewModel.result, result.success { - resultsBanner(result: result) - } - - // Empty state - if mapViewModel.repeatersWithLocation.isEmpty { - emptyState + TracePathResultsBanner( + result: result, + totalPathDistance: traceViewModel.totalPathDistance + ) } // 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.locationService.currentLocation + userLocation: appState.bestAvailableLocation ) + mapViewModel.showLabels = showLabels mapViewModel.rebuildOverlays() mapViewModel.performInitialCentering() } - .onChange(of: appState.locationService.currentLocation) { _, newLocation in - mapViewModel.updateUserLocation(newLocation) + .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() } @@ -97,39 +114,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 struct TracePathResultsBanner: View { + let result: TraceResult + let totalPathDistance: Double? - private func resultsBanner(result: TraceResult) -> some View { + 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)) @@ -139,155 +173,9 @@ 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 - - private var emptyState: some View { - VStack { - Spacer() - ContentUnavailableView( - L10n.Contacts.Contacts.Trace.Map.Empty.title, - 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.locationService.currentLocation { - 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)) - } - } - .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 459e5f3a1..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 = PathLineOverlay.SignalQuality(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/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/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 b0ce8bbfd..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,391 +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 signalLevel: Double { - TraceHop.signalLevel(for: displaySNR) - } - - private var signalColor: Color { - TraceHop.signalColor(for: displaySNR) - } - - 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/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/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..96a613c02 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 { @@ -216,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] = [] @@ -242,6 +281,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 +300,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 +321,142 @@ 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 + )) + } + + 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 + } + + 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 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: 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: coords, 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 +553,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointAElevationTask = Task { @MainActor in + pointAElevationTask = Task { await fetchElevationForPointA() } } @@ -454,7 +581,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointBElevationTask = Task { @MainActor in + pointBElevationTask = Task { await fetchElevationForPointB() } } @@ -487,7 +614,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 { @@ -550,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 @@ -689,9 +814,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 +822,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 +955,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 +977,6 @@ final class LineOfSightViewModel { if Task.isCancelled { return } - // Update state on MainActor elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( from: profile, @@ -934,7 +1052,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/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/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..a33db9163 --- /dev/null +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -0,0 +1,415 @@ +import MapLibre +import MC1Services +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 lineLOSCasing = "line-los-casing" + 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(forKeyPath: "anchorType") + 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 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: [1.4, 2.6]) + losLayer.lineJoin = NSExpression(forConstantValue: "round") + losLayer.lineCap = NSExpression(forConstantValue: "round") + 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") + + 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: [0.7, 1.3]) + 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: [1.75, 3.25]) + untracedLayer.lineJoin = roundJoin + untracedLayer.lineCap = roundCap + style.addLayer(untracedLayer) + + 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: [0.7, 1.3]) + 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: SNRQuality.poor.uiColor) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + weakLayer.lineJoin = roundJoin + weakLayer.lineCap = roundCap + style.addLayer(weakLayer) + + 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: [0.7, 1.3]) + 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: SNRQuality.fair.uiColor) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [1.4, 2.6]) + mediumLayer.lineJoin = roundJoin + mediumLayer.lineCap = roundCap + 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: SNRQuality.good.uiColor) + 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), + "anchorType": iconAnchor(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 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" + 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 .obstruction: "pin-obstruction" + 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..5023f604c --- /dev/null +++ b/MC1/Views/Map/MapPoint.swift @@ -0,0 +1,38 @@ +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 obstruction + 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 e1f3e823d..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,289 +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.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) + 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.adminAccess, 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 - 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) - } - } + 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..ed25fd5a6 --- /dev/null +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -0,0 +1,414 @@ +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 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 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, renderStyle: .standard), + SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, + 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, renderStyle: .standard), + + // LOS/TracePath repeater states + SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, + 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, renderStyle: .standard), + SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, + 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, renderStyle: .standard), + SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, + iconName: nil, text: "B", ringColor: nil, renderStyle: .standard), + + // LOS crosshair target + SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, + 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 { + switch spec.renderStyle { + case .crosshair: return renderCrosshair(spec) + case .obstruction: return renderObstruction() + case .standard: break + } + + 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 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 casingWidth: CGFloat = 6 + let capInset = casingWidth / 2 + let size: CGFloat = 44 + capInset * 2 + let gapRadius: CGFloat = 4 + let outerRadius: CGFloat = 22 + let badgeHeight: CGFloat = 20 + 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: 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) + + 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() + + // "R" badge + let badgeWidth: CGFloat = 20 + 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() + + 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/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) diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 42c6802a5..de12a701c 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) { @@ -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 a2a6667bb..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,39 +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: 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 3f97d2245..65462d725 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift @@ -2,38 +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() + makeBehaviorSection() + makeRegionsSection() makeIdentitySection() makeContactInfoSection() - makeBehaviorSection() makeSecuritySection() makeDeviceInfoSection() makeActionsSection() @@ -56,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 ) @@ -79,442 +65,53 @@ 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 { BehaviorSection(viewModel: viewModel, focusedField: $focusedField) } + private func makeRegionsSection() -> some View { + RegionsSection(viewModel: viewModel) + } + 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( @@ -523,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 ) { @@ -555,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) } @@ -582,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) } @@ -609,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) } @@ -626,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") @@ -641,69 +238,136 @@ private struct BehaviorSection: View { } .animation(.default, value: viewModel.behaviorApplySuccess) } - .disabled(viewModel.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) + .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) } } } -// MARK: - Security Section +// MARK: - Regions Section -private struct SecuritySection: View { +private struct RegionsSection: 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) + /// 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 } } -} - -// MARK: - Actions Section -private struct ActionsSection: View { - let viewModel: RepeaterSettingsViewModel - @Binding var showRebootConfirmation: Bool + /// 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 { - Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) { - Task { await viewModel.forceAdvert() } + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.regions, + icon: "globe", + isExpanded: $viewModel.isRegionsExpanded, + isLoaded: { viewModel.regionsLoaded }, + isLoading: $viewModel.isLoadingRegions, + hasError: $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) } - Button(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) { - Task { await viewModel.syncTime() } + // 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) } - .disabled(viewModel.isApplying) - Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { - showRebootConfirmation = true + // 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.helper.isApplying) } - .disabled(viewModel.isRebooting) - .confirmationDialog(L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle, isPresented: $showRebootConfirmation) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { - Task { await viewModel.reboot() } + .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) } } - 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) + // Add region button + Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { + viewModel.isAddingRegion = true + } + .disabled(viewModel.helper.isApplying) + + // Save to device button + if viewModel.regionsLoaded { + Button { + Task { await viewModel.saveRegions() } + } label: { + HStack { + Spacer() + if viewModel.helper.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.helper.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 = "" } } } diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift index bc594b4a0..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 { firmwareVersion != nil || 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 { originalName != nil || 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,47 +21,15 @@ 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? - // Password change (no query available) - var newPassword: String = "" - var confirmPassword: String = "" - - // Expansion state for DisclosureGroups - var isDeviceInfoExpanded = false - var isRadioExpanded = false - var isIdentityExpanded = false - var isContactInfoExpanded = false - var isBehaviorExpanded = 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) || @@ -145,6 +37,24 @@ final class RepeaterSettingsViewModel { (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 = false + var regionsLoaded: Bool { originalRegions != nil } + var hasUnsavedRegionChanges = false + var isAddingRegion = false + var newRegionName = "" + var regionsSaveSuccess = false + + // MARK: - Expansion State (repeater-only sections) + + var isBehaviorExpanded = false + var isRegionsExpanded = false + // MARK: - Dependencies private var repeaterAdminService: RepeaterAdminService? @@ -152,489 +62,224 @@ 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) - /// - 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 { - 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) - 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 - await MainActor.run { - self?.handleLateResponse(message.text) - } - } - } + guard let repeaterAdminService else { return } - /// 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 - } + 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) } + ) - 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 - } - } - } + helper.name = session.name - // 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 - } - } + helper.onPreFetchNodeInfo = { [weak self] in + await self?.fetchNodeInfo() + } - 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 - } + // Register CLI handler for late responses + await repeaterAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) } } - // 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 - } - } + await fetchNodeInfo() + } - 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 - } - } + private var isLoadingNodeInfo = false - 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 fetchNodeInfo() async { + 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) + helper.setNodeInfo( + firmwareVersion: response.firmwareVersion, + name: response.nodeName, + ownerInfo: response.ownerInfo + ) + } catch { + logger.warning("Failed to fetch node info via binary: \(error)") } + } + + // MARK: - Late Response Handling + + 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 } + self.behaviorError = false + 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") + // Regions + if !isLoadingRegions && regionsError { + if originalRegions == nil { + let parsed = Self.parseRegionTree(response) + if !parsed.isEmpty { + self.regions = parsed + self.originalRegions = parsed + self.regionsError = false + logger.info("Late response: received region tree (\(parsed.count) regions)") return } } } } - // MARK: - Fetch Methods (Pull-to-Load) - - /// Fetch device info (firmware version and time) - 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)") - } - - // 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 { hadTimeout = true } - 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) - 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)") - } - - // 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)") - } - - // Show error if any request timed out (even if some succeeded) - 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 { - 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 + func applyBehaviorSettings() async { + let validation = NodeSettingsHelper.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops - /// 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 - } + if validation.hasErrors { return } - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.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 + if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { + let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalRepeaterEnabled = repeaterEnabled + } else { + allSucceeded = false + } } - } 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 let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") if case .ok = CLIResponse.parse(response) { - originalName = name + originalAdvertIntervalMinutes = advertIntervalMinutes } else { allSucceeded = false } } - if let latitude, latitude != originalLatitude { - let response = try await sendAndWait("set lat \(latitude)") + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") if case .ok = CLIResponse.parse(response) { - originalLatitude = latitude + originalFloodAdvertIntervalHours = floodAdvertIntervalHours } else { allSucceeded = false } } - if let longitude, longitude != originalLongitude { - let response = try await sendAndWait("set lon \(longitude)") + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") if case .ok = CLIResponse.parse(response) { - originalLongitude = longitude + originalFloodMaxHops = floodMaxHops } else { allSucceeded = false } @@ -642,258 +287,213 @@ final class RepeaterSettingsViewModel { if allSucceeded { withAnimation { - isApplying = false - identityApplySuccess = true + helper.isApplying = false + behaviorApplySuccess = true } try? await Task.sleep(for: .seconds(1.5)) - withAnimation { identityApplySuccess = false } + withAnimation { behaviorApplySuccess = false } return } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Apply contact info (owner.info) - func applyContactInfoSettings() async { - isApplying = true - errorMessage = nil + // MARK: - Region Methods + + func fetchRegions() async { + isLoadingRegions = true + regionsError = false 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 - } + let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let parsed = Self.parseRegionTree(treeResponse) + self.regions = parsed + self.originalRegions = parsed } catch { - errorMessage = error.localizedDescription + if case RemoteNodeError.timeout = error { + regionsError = true + } + logger.warning("Failed to fetch regions: \(error)") } - isApplying = false + isLoadingRegions = 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 - } - } + 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) + text = String(text.drop(while: { $0 == " " })) + guard !text.isEmpty else { continue } - // Validate flood interval: accepts 3-48 - if let interval = floodAdvertIntervalHours { - if interval < 3 || interval > 48 { - floodAdvertIntervalError = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation + let floodAllowed: Bool + if text.hasSuffix(" F") { + floodAllowed = true + text = String(text.dropLast(2)) + } else { + floodAllowed = false } - } - // Validate flood max hops: accepts 0-64 - if let hops = floodMaxHops { - if hops < 0 || hops > 64 { - floodMaxHopsError = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation + let isHome: Bool + if text.hasSuffix("^") { + isHome = true + text = String(text.dropLast(1)) + } else { + isHome = false } - } - // Don't proceed if validation failed - if advertIntervalError != nil || floodAdvertIntervalError != nil || floodMaxHopsError != nil { - return + guard !text.isEmpty else { continue } + + entries.append(RepeaterRegionEntry( + name: text, + floodAllowed: floodAllowed, + isHome: isHome + )) } - isApplying = true - errorMessage = nil + return entries + } - do { - var allSucceeded = true + 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)" - if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { - let response = try await sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") - if case .ok = CLIResponse.parse(response) { - originalRepeaterEnabled = repeaterEnabled - } else { - allSucceeded = false - } - } + helper.isApplying = true + helper.errorMessage = nil - if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await sendAndWait("set advert.interval \(advertIntervalMinutes)") - if case .ok = CLIResponse.parse(response) { - originalAdvertIntervalMinutes = advertIntervalMinutes - } else { - allSucceeded = false - } + do { + let response = try await helper.sendAndWait(command) + if case .ok = CLIResponse.parse(response) { + regions[index].floodAllowed = !currentlyAllowed + hasUnsavedRegionChanges = true + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } + } catch { + helper.errorMessage = error.localizedDescription + } - if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") - if case .ok = CLIResponse.parse(response) { - originalFloodAdvertIntervalHours = floodAdvertIntervalHours - } else { - allSucceeded = false - } - } + helper.isApplying = false + } - if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await sendAndWait("set flood.max \(floodMaxHops)") - if case .ok = CLIResponse.parse(response) { - originalFloodMaxHops = floodMaxHops - } else { - allSucceeded = false - } - } + func setHomeRegion(name: String) async { + let command = "region home \(name)" - if allSucceeded { - withAnimation { - isApplying = false - behaviorApplySuccess = true + helper.isApplying = true + helper.errorMessage = nil + + do { + let response = try await helper.sendAndWait(command, rawMatching: true) + if response.contains("home is now") { + for i in regions.indices { + regions[i].isHome = (regions[i].name == name) } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { behaviorApplySuccess = false } - return + hasUnsavedRegionChanges = true } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } } catch { - errorMessage = error.localizedDescription + helper.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 + helper.isApplying = false } - /// 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 + 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: + 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("password \(newPassword)") + let response = try await helper.sendAndWait("region put \(trimmed)") if case .ok = CLIResponse.parse(response) { - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangedSuccess - showSuccessAlert = true - newPassword = "" - confirmPassword = "" + regions.append(RepeaterRegionEntry( + name: trimmed, + floodAllowed: false, + isHome: false + )) + hasUnsavedRegionChanges = true + newRegionName = "" } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - // MARK: - Device Actions - - /// Reboot the repeater - func reboot() async { - guard let session, let service = repeaterAdminService else { return } - - isRebooting = true - errorMessage = nil + func removeRegion(name: String) async { + helper.isApplying = true + helper.errorMessage = nil do { - _ = try await service.sendCommand(sessionID: session.id, command: "reboot") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent - showSuccessAlert = true + 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") { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isRebooting = false + helper.isApplying = false } - /// Force advertisement - func forceAdvert() async { - guard let session, let service = repeaterAdminService else { return } + func saveRegions() async { + helper.isApplying = true + helper.errorMessage = nil 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 + let response = try await helper.sendAndWait("region save") + if case .ok = CLIResponse.parse(response) { + hasUnsavedRegionChanges = false + withAnimation { + helper.isApplying = false + regionsSaveSuccess = true } - - default: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) - + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { regionsSaveSuccess = false } + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } } -// MARK: - Error Types - -enum RepeaterSettingsError: LocalizedError { - case notConnected - case timeout - case noService +// MARK: - Region Entry - 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 - } - } +struct RepeaterRegionEntry: Identifiable, Equatable { + var id: String { name } + let name: String + var floodAllowed: Bool + var isHome: Bool + var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } } diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index 9e3238c21..9116533a0 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -9,11 +9,13 @@ 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 { List { makeHeaderSection() + makeOwnerInfoSection() makeStatusSection() makeTelemetrySection() makeNeighborsSection() @@ -33,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 + or: viewModel.helper.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.helper.isLoadingTelemetry || viewModel.isLoadingOwnerInfo || viewModel.isDiscovering ) } @@ -55,54 +58,69 @@ 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)) ?? [] } } } .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 { + 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]) } // MARK: - Subviews private func makeHeaderSection() -> some View { - HeaderSection(publicKey: session.publicKey, name: session.name) + NodeStatusHeaderSection(session: session) + } + + private func makeOwnerInfoSection() -> some View { + OwnerInfoSection(viewModel: viewModel, session: session) } private func makeStatusSection() -> some View { - StatusSection(viewModel: viewModel, session: session) + StatusSection(viewModel: viewModel) } private func makeNeighborsSection() -> some View { NeighborsSection( viewModel: viewModel, session: session, - contacts: contacts + contacts: contacts, + discoveredNodes: discoveredNodes, + connectionState: appState.connectionState ) } private func makeBatteryCurveSection() -> some View { - BatteryCurveDisclosureSection( - viewModel: viewModel, + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, session: session, connectionState: appState.connectionState, connectedDeviceID: appState.connectedDevice?.id @@ -110,7 +128,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 @@ -118,37 +138,56 @@ 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 { + 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) } } } } -// MARK: - Header Section +// MARK: - Owner Info Section -private struct HeaderSection: View { - let publicKey: Data - let name: String +private struct OwnerInfoSection: View { + @Bindable var viewModel: RepeaterStatusViewModel + let session: RemoteNodeSessionDTO var body: some View { Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: .repeater, size: 60) - - Text(name) - .font(.headline) + 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) + } } - Spacer() } - .listRowBackground(Color.clear) } } } @@ -157,34 +196,10 @@ private struct HeaderSection: 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) } } } @@ -195,38 +210,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) @@ -240,51 +224,77 @@ private struct NeighborsSection: View { @Bindable var viewModel: RepeaterStatusViewModel 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 { 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 + 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 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 ) } } } + + 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) @@ -308,133 +318,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 { @@ -571,6 +454,7 @@ private struct NeighborRow: View { private struct DisappearedNeighborRow: View { let neighbor: NeighborSnapshotEntry let contact: ContactDTO? + var discoveredName: String? var body: some View { HStack { @@ -588,37 +472,11 @@ private struct DisappearedNeighborRow: View { private var displayName: String { contact?.displayName + ?? discoveredName ?? Data(neighbor.publicKeyPrefix.prefix(4)).hexString() } } -// 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 8edca8c44..e8da4ac2d 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,71 +27,46 @@ 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 - - /// Error message if any - var errorMessage: String? + /// Discovery state + var isDiscovering: Bool { discoverTask != nil } + var discoverySecondsRemaining = 0 + private var discoverTask: Task? - // MARK: - OCV Curve Properties + private static let discoveryDuration = 60 + private static let pollIntervalTicks = 5 + private static let discoverCommand = "discover.neighbors" - /// Whether the battery curve disclosure group is expanded - var isBatteryCurveExpanded = false + /// Owner info text + var ownerInfo: String? - /// 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? + /// Owner info loading/state + var isLoadingOwnerInfo = false + var ownerInfoLoaded: Bool { ownerInfo != nil } + var ownerInfoExpanded = false + var ownerInfoError: String? // 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 @@ -111,452 +74,186 @@ 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 + } + } + + 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) } } - /// Request neighbors from the repeater + // 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 - ) - 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 + // MARK: - Discovery - /// Request telemetry from the repeater - func requestTelemetry(for session: RemoteNodeSessionDTO) async { - guard let repeaterAdminService else { return } + func startDiscovery(for session: RemoteNodeSessionDTO) { + guard let repeaterAdminService, !isDiscovering else { return } - self.session = session - isLoadingTelemetry = true - errorMessage = nil - - do { - let response = try await performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in - return try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) - } - handleTelemetryResponse(response) - } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isLoadingTelemetry = false - } catch { - errorMessage = error.localizedDescription - isLoadingTelemetry = false - } - } + discoverySecondsRemaining = Self.discoveryDuration - /// 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 + discoverTask = Task { + do { + _ = try await repeaterAdminService.sendCommand( + sessionID: session.id, + command: Self.discoverCommand + ) + } catch { + helper.errorMessage = error.localizedDescription + discoverySecondsRemaining = 0 + discoverTask = nil + return } - } - } - - // 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 + let startTime = Date.now + var tickCount = 0 - /// Em-dash for missing data (cleaner than "Unavailable") - private static let emDash = "—" + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } - private static let secondsPerMinute: UInt32 = 60 - private static let secondsPerHour: UInt32 = 3_600 - private static let secondsPerDay: UInt32 = 86_400 + let elapsed = Int(Date.now.timeIntervalSince(startTime)) + let remaining = max(0, Self.discoveryDuration - elapsed) + discoverySecondsRemaining = remaining - 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) + tickCount += 1 + if tickCount.isMultiple(of: Self.pollIntervalTicks) { + await requestNeighbors(for: session) + } - if days > 0 { - if days == 1 { - return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) + if remaining <= 0 { break } } - } 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() - } - var receiveErrorsDisplay: String? { - guard let count = 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())) + discoverySecondsRemaining = 0 + discoverTask = nil } } - /// 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) + func stopDiscovery() { + discoverTask?.cancel() + discoverTask = nil + discoverySecondsRemaining = 0 } - /// 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: - Telemetry - // MARK: - OCV Settings + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } - /// Load OCV settings for a contact by public key - func loadOCVSettings(publicKey: Data, deviceID: UUID) async { - guard let contactService else { return } + if helper.session == nil { helper.session = session } + helper.isLoadingTelemetry = true + helper.errorMessage = nil 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 + let response = try await helper.performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) } + helper.handleTelemetryResponse(response) + } catch RemoteNodeError.timeout { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingTelemetry = false } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed + helper.errorMessage = error.localizedDescription + helper.isLoadingTelemetry = false } } - /// 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 - } + // MARK: - Owner Info - ocvError = nil + func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { + guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } + + ownerInfoError = nil + isLoadingOwnerInfo = true 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 - ) + let response = try await helper.performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) } - - // Update local state - selectedOCVPreset = preset - ocvValues = values + ownerInfo = response.ownerInfo + } catch RemoteNodeError.timeout { + ownerInfoError = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.localizedDescription) + ownerInfoError = error.localizedDescription } + isLoadingOwnerInfo = false + } + + // MARK: - Repeater-Only Display + + var receiveErrorsDisplay: String? { + guard let count = helper.status?.receiveErrors, count > 0 else { return nil } + return count.formatted() } } 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..306f80f66 --- /dev/null +++ b/MC1/Views/RemoteNodes/SharedNodeViews.swift @@ -0,0 +1,671 @@ +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 + @State private var contactText = "" + + 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: $contactText, 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) + } + .onChange(of: settings.ownerInfo, initial: true) { _, newValue in + contactText = newValue ?? "" + } + .onChange(of: contactText) { _, newValue in + settings.ownerInfo = newValue + } + + Button { + Task { await settings.applyContactInfoSettings() } + } label: { + 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) + } + } +} + +// 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 new file mode 100644 index 000000000..83f6c4c66 --- /dev/null +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -0,0 +1,280 @@ +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 || + $0.receiveErrors != nil || + $0.postedCount != nil || $0.postPushCount != 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)) } + } + ) + + 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)) } + } + ) + + 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)) } + } + ) + } + } + } + } + + // 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() + let resolvedName = viewModel.resolveNeighborName(prefix: neighbor.publicKeyPrefix) ?? hexName + charts[neighbor.publicKeyPrefix] = NeighborChart( + prefix: neighbor.publicKeyPrefix, + name: resolvedName, + 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] +} diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift new file mode 100644 index 000000000..dc6bf99ba --- /dev/null +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewViewModel.swift @@ -0,0 +1,118 @@ +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 + private(set) var contacts: [ContactDTO] = [] + private(set) var discoveredNodes: [DiscoveredNodeDTO] = [] + 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 + } + + 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 + } +} + +// 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/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/MC1Services/Sources/MC1Services/ConnectionManager.swift b/MC1Services/Sources/MC1Services/ConnectionManager.swift index ab8744f2a..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), @@ -794,7 +799,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/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index bd6f78955..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 @@ -194,6 +197,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 +255,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/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..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 @@ -115,6 +116,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 +157,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 +198,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 +240,7 @@ public final class Device { ocvPreset = dto.ocvPreset customOCVArrayString = dto.customOCVArrayString connectionMethods = dto.connectionMethods + knownRegions = dto.knownRegions } } @@ -269,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? @@ -287,6 +302,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 +398,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 +439,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 +481,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/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.. 10 dB - case good // SNR > 5 dB - case fair // SNR > 0 dB - case poor // SNR > -10 dB - case veryPoor // SNR <= -10 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?) { @@ -16,11 +13,10 @@ 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 } - else if snr > -10 { self = .poor } - else { self = .veryPoor } + if snr > 6 { self = .excellent } + else if snr > 0 { self = .good } + else if snr > -6 { self = .fair } + 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" } } diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index e4177213d..3ae67b019 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -410,7 +410,10 @@ public protocol PersistenceStoreProtocol: Actor { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: 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/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/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/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index a82e512a5..d30506371 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -24,7 +24,10 @@ public actor NodeSnapshotService { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) async -> UUID? { do { if let latest = try await dataStore.fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), @@ -42,7 +45,10 @@ public actor NodeSnapshotService { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) logger.info("Saved status snapshot for node") return id 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/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 38ef9fc89..b2831a05c 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -376,7 +376,10 @@ extension PersistenceStore { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { try saveNodeStatusSnapshot( timestamp: .now, @@ -388,11 +391,15 @@ extension PersistenceStore { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) } - /// 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 +410,10 @@ extension PersistenceStore { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { let snapshot = NodeStatusSnapshot( timestamp: timestamp, @@ -415,7 +425,10 @@ extension PersistenceStore { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) modelContext.insert(snapshot) try modelContext.save() diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 87e5b70ee..94ade4599 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 @@ -814,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/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..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, @@ -410,7 +415,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 17860d200..da4a1de6f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1425,7 +1425,10 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, - packetsReceived: UInt32? + packetsReceived: UInt32?, + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? ) async throws -> UUID { let dto = NodeStatusSnapshotDTO( nodePublicKey: nodePublicKey, @@ -1436,7 +1439,10 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { uptimeSeconds: uptimeSeconds, rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, - packetsReceived: packetsReceived + packetsReceived: packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) nodeStatusSnapshots.append(dto) return dto.id @@ -1477,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 ) @@ -1498,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/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/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/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) + } } 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() { diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index b787c0e95..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?) 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 96ecbbda7..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?) 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/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( diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 02c39bdf2..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?) 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") } } diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift new file mode 100644 index 000000000..3fc320728 --- /dev/null +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -0,0 +1,294 @@ +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, 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, receiveErrors: nil + ) + + 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, receiveErrors: 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, receiveErrors: 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, receiveErrors: 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, receiveErrors: nil + ) + + await viewModel.loadData( + dataStore: store, publicKey: testPublicKey, deviceID: testDeviceID + ) + #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, receiveErrors: 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, receiveErrors: 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, receiveErrors: 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)") + } +} 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/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/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/Events/MeshEvent.swift b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift index 9515d04cf..7f8bb2b1c 100644 --- a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift +++ b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift @@ -501,13 +501,38 @@ 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 /// 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 +571,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 +597,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 +621,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/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..d63b4fd2a 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 @@ -780,6 +789,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). @@ -790,7 +828,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 +902,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 +919,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 +933,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 +948,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/PacketCodes.swift b/MeshCore/Sources/MeshCore/Protocol/PacketCodes.swift index ad995b07d..ed56086ac 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. @@ -216,6 +218,18 @@ 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. +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. 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..be73eed82 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.. 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, @@ -624,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( + 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 + )) - 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 - )) + 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. @@ -677,7 +748,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 || @@ -701,33 +776,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 + ) + } } } @@ -1324,7 +1434,19 @@ public enum Parsers { let timestamp = data.readUInt32LE(at: 0) let pathLen = data[4] - let byteLen = decodePathLen(pathLen)?.byteLength ?? 0 + guard let decoded = decodePathLen(pathLen) else { + return .parseFailure( + data: data, + reason: "AdvertPathResponse uses reserved path length encoding: 0x\(String(format: "%02X", pathLen))" + ) + } + let byteLen = decoded.byteLength + guard data.count >= 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( @@ -1607,3 +1729,33 @@ 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 { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != "*" } + } +} diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 1523bc83d..477ec0f34 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 - } - - 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 + try await sendAndMatch(data, timeout: timeout) { event in + if let result = predicate(event) { + return .success(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,22 @@ 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? { + try requireFullPublicKey(publicKey, operation: "getContact") 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,28 +878,62 @@ 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. /// + /// 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. + /// ``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 - try await binaryRequestSerializer.withSerialization { [self] in - try await performStatusRequest(from: publicKey) + try requireFullPublicKey(publicKey, operation: "requestStatus") + return try await binaryRequestSerializer.withSerialization { [self] in + 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() @@ -916,6 +969,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): @@ -924,7 +978,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { guard let response = Parsers.StatusResponse.parseFromBinaryResponse( responseData, - publicKeyPrefix: publicKeyPrefix + publicKeyPrefix: publicKeyPrefix, + layout: layout ) else { return nil } @@ -934,6 +989,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 @@ -977,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 @@ -998,6 +1059,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) @@ -1082,10 +1144,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 +1272,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 +1286,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 +1334,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 } } @@ -1311,12 +1373,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 } } @@ -1327,14 +1396,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 @@ -1386,6 +1459,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 +1468,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 +1480,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 +1490,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 +1898,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)) } @@ -1828,10 +1910,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,10 +1950,12 @@ 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 { + 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) } } @@ -1916,6 +2000,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 +2017,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 @@ -1979,6 +2065,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. @@ -1989,8 +2173,10 @@ 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 requireFullPublicKey(publicKey, operation: "requestMMA") + return try await binaryRequestSerializer.withSerialization { [self] in try await performMMARequest(from: publicKey, start: start, end: end) } } @@ -2030,6 +2216,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,8 +2259,10 @@ 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 requireFullPublicKey(publicKey, operation: "requestACL") + return try await binaryRequestSerializer.withSerialization { [self] in try await performACLRequest(from: publicKey) } } @@ -2106,6 +2295,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 +2343,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 +2405,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): @@ -2253,6 +2445,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") + + 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() + + 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 @@ -2425,10 +2757,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. @@ -2459,6 +2802,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) { @@ -2493,9 +2844,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 } @@ -2508,7 +2863,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { ) return .telemetryResponse(response) - case .keepAlive: + case .keepAlive, .ownerInfo: return nil } } @@ -2581,6 +2936,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/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/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/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/Protocol/RegionTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift new file mode 100644 index 000000000..8a295a975 --- /dev/null +++ b/MeshCore/Tests/MeshCoreTests/Protocol/RegionTests.swift @@ -0,0 +1,447 @@ +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) + } + } + + @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 + +@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() + } +} + 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/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift new file mode 100644 index 000000000..c0ba4a973 --- /dev/null +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -0,0 +1,650 @@ +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("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() + 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("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() + 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 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) + 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 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)) + 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 +} 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..db8309ae0 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 @@ -238,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+) @@ -348,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") 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 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