diff --git a/.gitignore b/.gitignore index dd2ad8bae..78e46200e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ playground.xcworkspace build/ .build/ +.spm-cache/ # CocoaPods # diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift new file mode 100644 index 000000000..41d8f9955 --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -0,0 +1,46 @@ +import CoreLocation +import MapKit +import MapLibre + +extension Array where Element == CLLocationCoordinate2D { + /// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding. + func boundingRegion(paddingMultiplier: Double = 1.5) -> MKCoordinateRegion? { + guard let first else { return nil } + + var minLat = first.latitude, maxLat = first.latitude + var minLon = first.longitude, maxLon = first.longitude + + for coord in dropFirst() { + minLat = Swift.min(minLat, coord.latitude) + maxLat = Swift.max(maxLat, coord.latitude) + minLon = Swift.min(minLon, coord.longitude) + maxLon = Swift.max(maxLon, coord.longitude) + } + + return MKCoordinateRegion( + center: CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ), + span: MKCoordinateSpan( + latitudeDelta: Swift.min(180, Swift.max(0.01, (maxLat - minLat) * paddingMultiplier)), + longitudeDelta: Swift.min(360, Swift.max(0.01, (maxLon - minLon) * paddingMultiplier)) + ) + ) + } +} + +extension MKCoordinateRegion { + func toMLNCoordinateBounds() -> MLNCoordinateBounds { + MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: center.latitude - span.latitudeDelta / 2, + longitude: center.longitude - span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: center.latitude + span.latitudeDelta / 2, + longitude: center.longitude + span.longitudeDelta / 2 + ) + ) + } +} diff --git a/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift new file mode 100644 index 000000000..08bd97305 --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+Formatting.swift @@ -0,0 +1,7 @@ +import CoreLocation + +extension CLLocationCoordinate2D { + var formattedString: String { + "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" + } +} diff --git a/MC1/Extensions/ContactDTO+Coordinate.swift b/MC1/Extensions/ContactDTO+Coordinate.swift new file mode 100644 index 000000000..8f268014a --- /dev/null +++ b/MC1/Extensions/ContactDTO+Coordinate.swift @@ -0,0 +1,8 @@ +import CoreLocation +import MC1Services + +extension ContactDTO { + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} diff --git a/MC1/Extensions/ContactType+Display.swift b/MC1/Extensions/ContactType+Display.swift new file mode 100644 index 000000000..99966bd60 --- /dev/null +++ b/MC1/Extensions/ContactType+Display.swift @@ -0,0 +1,28 @@ +import MeshCore +import SwiftUI + +extension ContactType { + var iconSystemName: String { + switch self { + case .chat: "person.fill" + case .repeater: "antenna.radiowaves.left.and.right" + case .room: "person.3.fill" + } + } + + var displayColor: Color { + switch self { + case .chat: .blue + case .repeater: .green + case .room: .purple + } + } + + var pinStyle: MapPoint.PinStyle { + switch self { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } +} diff --git a/MC1/Extensions/View+LiquidGlass.swift b/MC1/Extensions/View+LiquidGlass.swift index cd5d4b829..a7ad6ba59 100644 --- a/MC1/Extensions/View+LiquidGlass.swift +++ b/MC1/Extensions/View+LiquidGlass.swift @@ -21,6 +21,16 @@ extension View { } } + /// Applies glass button style on iOS 26+, falls back to bordered (secondary weight) on earlier versions + @ViewBuilder + func liquidGlassSecondaryButtonStyle() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + /// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions @ViewBuilder func liquidGlassProminentButtonStyle() -> some View { diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index a028aec5e..96cfe53ca 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1511,6 +1511,12 @@ public enum L10n { public static func viewRuns(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.results.viewRuns", p1, fallback: "View %d runs") } + public enum Comparison { + /// Decreased + public static let decreased = L10n.tr("Contacts", "contacts.results.comparison.decreased", fallback: "Decreased") + /// Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction + public static let increased = L10n.tr("Contacts", "contacts.results.comparison.increased", fallback: "Increased") + } public enum Hop { /// Location: TraceResultsSheet.swift - Purpose: Average SNR display public static func avgSNR(_ p1: Any, _ p2: Any, _ p3: Any) -> String { @@ -1772,8 +1778,12 @@ public enum L10n { } } public enum Map { + /// Location: TracePathMapView.swift - Purpose: Center on path accessibility + public static let centerOnPath = L10n.tr("Contacts", "contacts.trace.map.centerOnPath", fallback: "Center on path") /// Location: TracePathMapView.swift - Purpose: Clear button public static let clear = L10n.tr("Contacts", "contacts.trace.map.clear", fallback: "Clear") + /// Location: TracePathMapViewModel.swift - Purpose: Default path name fallback + public static let defaultPathName = L10n.tr("Contacts", "contacts.trace.map.defaultPathName", fallback: "Path") /// Location: TracePathMapView.swift - Purpose: Hide labels accessibility public static let hideLabels = L10n.tr("Contacts", "contacts.trace.map.hideLabels", fallback: "Hide labels") /// Location: TracePathMapView.swift - Purpose: Hops count in results banner @@ -2021,10 +2031,10 @@ public enum L10n { } } public enum Common { + /// Dismiss + public static let dismissOverlay = L10n.tr("Map", "map.common.dismissOverlay", fallback: "Dismiss") /// Location: MapView.swift - Purpose: Done button for sheets public static let done = L10n.tr("Map", "map.common.done", fallback: "Done") - /// Location: MapView.swift - Purpose: Refresh button label - public static let refresh = L10n.tr("Map", "map.common.refresh", fallback: "Refresh") } public enum Controls { /// Location: MapView.swift - Purpose: Accessibility label for center on all contacts button @@ -2035,8 +2045,14 @@ public enum L10n { public static let hideLabels = L10n.tr("Map", "map.controls.hideLabels", fallback: "Hide labels") /// Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button public static let layers = L10n.tr("Map", "map.controls.layers", fallback: "Map layers") + /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) + public static let lockNorth = L10n.tr("Map", "map.controls.lockNorth", fallback: "Lock to north") + /// Location: MapView.swift - Purpose: Accessibility label for refresh button + public static let refresh = L10n.tr("Map", "map.controls.refresh", fallback: "Refresh contacts") /// Location: MapView.swift - Purpose: Accessibility label when labels are hidden public static let showLabels = L10n.tr("Map", "map.controls.showLabels", fallback: "Show labels") + /// Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) + public static let unlockNorth = L10n.tr("Map", "map.controls.unlockNorth", fallback: "Unlock rotation") } public enum Detail { /// Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited @@ -2088,12 +2104,6 @@ public enum L10n { public static let networkPath = L10n.tr("Map", "map.detail.section.networkPath", fallback: "Network Path") } } - public enum EmptyState { - /// Location: MapView.swift - Purpose: Empty state description - public static let description = L10n.tr("Map", "map.emptyState.description", fallback: "Contacts with location data will appear here once discovered on the mesh network.") - /// Location: MapView.swift - Purpose: Empty state title when no contacts have location - public static let title = L10n.tr("Map", "map.emptyState.title", fallback: "No Contacts on Map") - } public enum NodeKind { /// Location: MapView.swift ContactDetailSheet - Purpose: Display name for chat contact type public static let chatContact = L10n.tr("Map", "map.nodeKind.chatContact", fallback: "Chat Contact") @@ -2102,13 +2112,23 @@ public enum L10n { /// Location: MapView.swift ContactDetailSheet - Purpose: Display name for room type public static let room = L10n.tr("Map", "map.nodeKind.room", fallback: "Room") } + public enum OfflineBadge { + /// Label shown on map when device has no internet connection + public static let label = L10n.tr("Map", "map.offlineBadge.label", fallback: "Offline") + } public enum Style { - /// Location: MapStyleSelection.swift - Purpose: Hybrid map style option - public static let hybrid = L10n.tr("Map", "map.style.hybrid", fallback: "Hybrid") + /// Location: LayersMenu.swift - Purpose: Accessibility label for map style menu + public static let accessibilityLabel = L10n.tr("Map", "map.style.accessibilityLabel", fallback: "Map style") + /// Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport + public static let noOfflineCoverage = L10n.tr("Map", "map.style.noOfflineCoverage", fallback: "No offline map covers this area") + /// Location: LayersMenu.swift - Purpose: Hint when style requires network + public static let requiresNetwork = L10n.tr("Map", "map.style.requiresNetwork", fallback: "Requires network connection") /// Location: MapStyleSelection.swift - Purpose: Satellite map style option public static let satellite = L10n.tr("Map", "map.style.satellite", fallback: "Satellite") /// Location: MapStyleSelection.swift - Purpose: Standard map style option public static let standard = L10n.tr("Map", "map.style.standard", fallback: "Standard") + /// Location: MapStyleSelection.swift - Purpose: Topo map style option + public static let topo = L10n.tr("Map", "map.style.topo", fallback: "Topography") } } } @@ -3701,6 +3721,76 @@ public enum L10n { /// Toggle label for room messages notifications public static let roomMessages = L10n.tr("Settings", "notifications.roomMessages", fallback: "Room Messages") } + public enum OfflineMaps { + /// Cancel button + public static let cancel = L10n.tr("Settings", "offlineMaps.cancel", fallback: "Cancel") + /// Status when pack download is complete + public static let complete = L10n.tr("Settings", "offlineMaps.complete", fallback: "Downloaded") + /// Delete button + public static let delete = L10n.tr("Settings", "offlineMaps.delete", fallback: "Delete") + /// Delete confirmation message + public static let deleteMessage = L10n.tr("Settings", "offlineMaps.deleteMessage", fallback: "The downloaded map data will be removed.") + /// Delete confirmation title + public static let deleteTitle = L10n.tr("Settings", "offlineMaps.deleteTitle", fallback: "Delete Offline Map?") + /// Button to start download + public static let download = L10n.tr("Settings", "offlineMaps.download", fallback: "Download") + /// Hint shown before estimate is available + public static let downloadHint = L10n.tr("Settings", "offlineMaps.downloadHint", fallback: "Enter a name and select an area to download.") + /// Status when pack is downloading + public static let downloading = L10n.tr("Settings", "offlineMaps.downloading", fallback: "Downloading…") + /// Button to download a new offline region + public static let downloadRegion = L10n.tr("Settings", "offlineMaps.downloadRegion", fallback: "Download Region") + /// Description for empty state + public static let emptyDescription = L10n.tr("Settings", "offlineMaps.emptyDescription", fallback: "Download map regions for use without internet.") + /// Title for empty state when no offline packs exist + public static let emptyTitle = L10n.tr("Settings", "offlineMaps.emptyTitle", fallback: "No Offline Maps") + /// Estimated download size + public static func estimatedSize(_ p1: Any) -> String { + return L10n.tr("Settings", "offlineMaps.estimatedSize", String(describing: p1), fallback: "Estimated size: ~%@") + } + /// Download exceeds available storage + public static let exceedsStorage = L10n.tr("Settings", "offlineMaps.exceedsStorage", fallback: "Not enough storage on this device. Zoom in to select a smaller area.") + /// Include layers prompt + public static let includeLayers = L10n.tr("Settings", "offlineMaps.includeLayers", fallback: "Include additional layers for offline use.") + /// Large tile download warning + public static let largeTileWarning = L10n.tr("Settings", "offlineMaps.largeTileWarning", fallback: "Large download area. This may take a while and use significant storage.") + /// Layers section header + public static let layers = L10n.tr("Settings", "offlineMaps.layers", fallback: "Layers") + /// No network available + public static let noNetwork = L10n.tr("Settings", "offlineMaps.noNetwork", fallback: "An internet connection is required to download maps.") + /// Pause download button + public static let pause = L10n.tr("Settings", "offlineMaps.pause", fallback: "Pause") + /// Paused status label + public static let paused = L10n.tr("Settings", "offlineMaps.paused", fallback: "Paused") + /// Navigation title for region picker sheet + public static let pickRegion = L10n.tr("Settings", "offlineMaps.pickRegion", fallback: "Select Region") + /// Placeholder for region name text field + public static let regionName = L10n.tr("Settings", "offlineMaps.regionName", fallback: "Region Name") + /// Resume download button + public static let resume = L10n.tr("Settings", "offlineMaps.resume", fallback: "Resume") + /// Section header for storage info + public static let storage = L10n.tr("Settings", "offlineMaps.storage", fallback: "Storage") + /// Storage section footer + public static let storageFooter = L10n.tr("Settings", "offlineMaps.storageFooter", fallback: "Includes map data and internal indexes. Total may be larger than the sum of individual downloads.") + /// Label for total storage used + public static let storageUsed = L10n.tr("Settings", "offlineMaps.storageUsed", fallback: "Storage Used") + /// Navigation title for offline maps settings + public static let title = L10n.tr("Settings", "offlineMaps.title", fallback: "Offline Maps") + /// Fallback name for unknown region + public static let unknownRegion = L10n.tr("Settings", "offlineMaps.unknownRegion", fallback: "Unknown Region") + public enum Error { + /// Error: insufficient disk space + public static let insufficientDiskSpace = L10n.tr("Settings", "offlineMaps.error.insufficientDiskSpace", fallback: "Not enough storage space. At least 100 MB is required.") + /// Error: tile limit reached + public static let tileLimitReached = L10n.tr("Settings", "offlineMaps.error.tileLimitReached", fallback: "The download tile limit has been reached.") + } + public enum Layer { + /// Layer type labels + public static let base = L10n.tr("Settings", "offlineMaps.layer.base", fallback: "Base Map") + /// Topography + public static let topo = L10n.tr("Settings", "offlineMaps.layer.topo", fallback: "Topography") + } + } public enum PathHashMode { /// Footer explaining path hash mode tradeoff public static let footer = L10n.tr("Settings", "pathHashMode.footer", fallback: "Larger hashes reduce routing collisions but limit the maximum number of hops per path.") diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 98d0214fd..c01b76aa1 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Beschriftungen einblenden"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Auf Pfad zentrieren"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Pfad speichern"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Ergebnisse"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Pfad"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms am %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Gestiegen"; +"contacts.results.comparison.decreased" = "Gesunken"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "%d Durchläufe anzeigen"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index b50ba436e..999085b82 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Fertig"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Aktualisieren"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Keine Kontakte auf der Karte"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Kontakte mit Standortdaten erscheinen hier, sobald sie im Mesh-Netzwerk entdeckt werden."; +"map.common.dismissOverlay" = "Schließen"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Kartenebenen"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Nach Norden ausrichten"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Drehung freigeben"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Kontakte aktualisieren"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellit"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrid"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Raum"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 02ed3d364..1f7792716 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline-Karten"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Keine Offline-Karten"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Kartenregionen für die Nutzung ohne Internet herunterladen."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Region herunterladen"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Speicher"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Speicher belegt"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Unbekannte Region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Heruntergeladen"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Wird heruntergeladen…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Region auswählen"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Regionsname"; + +/* Button to start download */ +"offlineMaps.download" = "Herunterladen"; + +/* Cancel button */ +"offlineMaps.cancel" = "Abbrechen"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Offline-Karte löschen?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Die heruntergeladenen Kartendaten werden entfernt."; + +/* Delete button */ +"offlineMaps.delete" = "Löschen"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Nicht genügend Speicherplatz. Mindestens 100 MB erforderlich."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Das Kachel-Limit für diesen Download wurde erreicht."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Fortsetzen"; + +/* Paused status label */ +"offlineMaps.paused" = "Pausiert"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Geschätzte Größe: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Großes Downloadgebiet. Dies kann eine Weile dauern und erheblichen Speicherplatz beanspruchen."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Geben Sie einen Namen ein und wählen Sie einen Bereich zum Herunterladen."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Nicht genügend Speicherplatz auf diesem Gerät. Vergrößern Sie die Ansicht, um einen kleineren Bereich auszuwählen."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Basiskarte"; +"offlineMaps.layer.topo" = "Topografie"; + +/* Layers section header */ +"offlineMaps.layers" = "Kartenebenen"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Zusätzliche Kartenebenen für die Offline-Nutzung einschließen."; + +/* No network available */ +"offlineMaps.noNetwork" = "Zum Herunterladen von Karten ist eine Internetverbindung erforderlich."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Umfasst Kartendaten und interne Indizes. Der Gesamtspeicher kann größer sein als die Summe der einzelnen Downloads."; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 5e7dc7704..a2b9ca893 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Show labels"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Center on path"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Save Path"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Results"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Path"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms on %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Increased"; +"contacts.results.comparison.decreased" = "Decreased"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "View %d runs"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index b4575e2a6..443e3454f 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Done"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Refresh"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "No Contacts on Map"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Contacts with location data will appear here once discovered on the mesh network."; +"map.common.dismissOverlay" = "Dismiss"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Map layers"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Lock to north"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Unlock rotation"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Refresh contacts"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrid"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topography"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Room"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index f24e15eee..9c4545327 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1269,3 +1269,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline Maps"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "No Offline Maps"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Download map regions for use without internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Download Region"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Storage"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Storage Used"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Unknown Region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Downloaded"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Downloading…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Select Region"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Region Name"; + +/* Button to start download */ +"offlineMaps.download" = "Download"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancel"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Delete Offline Map?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "The downloaded map data will be removed."; + +/* Delete button */ +"offlineMaps.delete" = "Delete"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Not enough storage space. At least 100 MB is required."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "The download tile limit has been reached."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Resume"; + +/* Paused status label */ +"offlineMaps.paused" = "Paused"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Estimated size: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Large download area. This may take a while and use significant storage."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Enter a name and select an area to download."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Not enough storage on this device. Zoom in to select a smaller area."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.topo" = "Topography"; + +/* Layers section header */ +"offlineMaps.layers" = "Layers"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Include additional layers for offline use."; + +/* No network available */ +"offlineMaps.noNetwork" = "An internet connection is required to download maps."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 26764c831..4defd58b7 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Mostrar etiquetas"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrar en la ruta"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Guardar ruta"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Resultados"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Ruta"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms en %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Aumentado"; +"contacts.results.comparison.decreased" = "Disminuido"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Ver %d ejecuciones"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 608148012..cc3e6934c 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Listo"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Actualizar"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Sin contactos en el mapa"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Los contactos con datos de ubicación aparecerán aquí una vez descubiertos en la red mesh."; +"map.common.dismissOverlay" = "Cerrar"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Capas del mapa"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Fijar al norte"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Desbloquear rotación"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Actualizar contactos"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satélite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Híbrido"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografía"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Sala"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Sin conexión"; diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index f7ade27bc..e070cac3d 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapas sin conexión"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Sin mapas sin conexión"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Descarga regiones del mapa para usar sin internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Descargar región"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Almacenamiento"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Almacenamiento usado"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Región desconocida"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Descargado"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Descargando…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Seleccionar región"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nombre de la región"; + +/* Button to start download */ +"offlineMaps.download" = "Descargar"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancelar"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "¿Eliminar mapa sin conexión?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Se eliminarán los datos del mapa descargado."; + +/* Delete button */ +"offlineMaps.delete" = "Eliminar"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "No hay suficiente espacio de almacenamiento. Se requieren al menos 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Se ha alcanzado el límite de mosaicos de descarga."; + +/* Pause download button */ +"offlineMaps.pause" = "Pausar"; + +/* Resume download button */ +"offlineMaps.resume" = "Reanudar"; + +/* Paused status label */ +"offlineMaps.paused" = "En pausa"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Tamaño estimado: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Área de descarga grande. Esto puede tardar un tiempo y usar bastante almacenamiento."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Ingrese un nombre y seleccione un área para descargar."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "No hay suficiente espacio en este dispositivo. Acerque el zoom para seleccionar un área más pequeña."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa base"; +"offlineMaps.layer.topo" = "Topografía"; + +/* Layers section header */ +"offlineMaps.layers" = "Capas"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Incluir capas adicionales para uso sin conexión."; + +/* No network available */ +"offlineMaps.noNetwork" = "Se requiere conexión a internet para descargar mapas."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Incluye datos del mapa e índices internos. El total puede ser mayor que la suma de las descargas individuales."; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index bbbbfc454..987ff347b 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Afficher les étiquettes"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrer sur le chemin"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Enregistrer le chemin"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Résultats"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Chemin"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs %d ms le %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Augmenté"; +"contacts.results.comparison.decreased" = "Diminué"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Voir %d exécutions"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index e7ac361ef..07a145d6a 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Terminé"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Actualiser"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Aucun contact sur la carte"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Les contacts avec des données de position apparaîtront ici une fois découverts sur le réseau mesh."; +"map.common.dismissOverlay" = "Fermer"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Couches de la carte"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Verrouiller au nord"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Déverrouiller la rotation"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Actualiser les contacts"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satellite"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybride"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topographie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Salon"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Hors ligne"; diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 01c31541f..74a4f5400 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Cartes hors ligne"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Aucune carte hors ligne"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Téléchargez des régions pour une utilisation sans internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Télécharger une région"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Stockage"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Stockage utilisé"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Région inconnue"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Téléchargé"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Téléchargement…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Sélectionner une région"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nom de la région"; + +/* Button to start download */ +"offlineMaps.download" = "Télécharger"; + +/* Cancel button */ +"offlineMaps.cancel" = "Annuler"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Supprimer la carte hors ligne ?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Les données cartographiques téléchargées seront supprimées."; + +/* Delete button */ +"offlineMaps.delete" = "Supprimer"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Espace de stockage insuffisant. Au moins 100 Mo sont requis."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "La limite de tuiles pour ce téléchargement a été atteinte."; + +/* Pause download button */ +"offlineMaps.pause" = "Pause"; + +/* Resume download button */ +"offlineMaps.resume" = "Reprendre"; + +/* Paused status label */ +"offlineMaps.paused" = "En pause"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Taille estimée : ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Zone de téléchargement étendue. Cela peut prendre du temps et utiliser un espace de stockage important."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Entrez un nom et sélectionnez une zone à télécharger."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Pas assez d'espace sur cet appareil. Zoomez pour sélectionner une zone plus petite."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Carte de base"; +"offlineMaps.layer.topo" = "Topographie"; + +/* Layers section header */ +"offlineMaps.layers" = "Couches"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Inclure des couches supplémentaires pour une utilisation hors ligne."; + +/* No network available */ +"offlineMaps.noNetwork" = "Une connexion internet est nécessaire pour télécharger les cartes."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Comprend les données cartographiques et les index internes. Le total peut être supérieur à la somme des téléchargements individuels."; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 827b2281f..de4875284 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Labels tonen"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centreren op pad"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Pad opslaan"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Resultaten"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Pad"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms op %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Gestegen"; +"contacts.results.comparison.decreased" = "Gedaald"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Bekijk %d uitvoeringen"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index c14cbe175..f7857235b 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Klaar"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Vernieuwen"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Geen contacten op de kaart"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Contacten met locatiegegevens verschijnen hier zodra ze worden ontdekt op het mesh-netwerk."; +"map.common.dismissOverlay" = "Sluiten"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Kaartlagen"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Vergrendel op noorden"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Rotatie ontgrendelen"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Contacten vernieuwen"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satelliet"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybride"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografie"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Kamer"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index b98af98e5..372e43f17 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Offline kaarten"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Geen offline kaarten"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Download kaartregio's voor gebruik zonder internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Regio downloaden"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Opslag"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Opslag gebruikt"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Onbekende regio"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Gedownload"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Downloaden…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Selecteer regio"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Regionaam"; + +/* Button to start download */ +"offlineMaps.download" = "Downloaden"; + +/* Cancel button */ +"offlineMaps.cancel" = "Annuleren"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Offline kaart verwijderen?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "De gedownloade kaartgegevens worden verwijderd."; + +/* Delete button */ +"offlineMaps.delete" = "Verwijderen"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Niet genoeg opslagruimte. Er is minimaal 100 MB vereist."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "De tegellimiet voor deze download is bereikt."; + +/* Pause download button */ +"offlineMaps.pause" = "Pauzeren"; + +/* Resume download button */ +"offlineMaps.resume" = "Hervatten"; + +/* Paused status label */ +"offlineMaps.paused" = "Gepauzeerd"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Geschatte grootte: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Groot downloadgebied. Dit kan even duren en aanzienlijke opslagruimte gebruiken."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Voer een naam in en selecteer een gebied om te downloaden."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Niet genoeg opslagruimte op dit apparaat. Zoom in om een kleiner gebied te selecteren."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Basiskaart"; +"offlineMaps.layer.topo" = "Topografie"; + +/* Layers section header */ +"offlineMaps.layers" = "Kaartlagen"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Extra kaartlagen opnemen voor offline gebruik."; + +/* No network available */ +"offlineMaps.noNetwork" = "Een internetverbinding is vereist om kaarten te downloaden."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Omvat kaartgegevens en interne indexen. Het totaal kan groter zijn dan de som van de afzonderlijke downloads."; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index e88734cdb..03bd9459e 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Pokaż etykiety"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Wyśrodkuj na ścieżce"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Zapisz ścieżkę"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Wyniki"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Ścieżka"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "vs. %d ms w %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Wzrosło"; +"contacts.results.comparison.decreased" = "Spadło"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Zobacz %d uruchomień"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 8424914f3..a5e5b553d 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Gotowe"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Odśwież"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Brak kontaktów na mapie"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Kontakty z danymi lokalizacji pojawią się tutaj, gdy zostaną odkryte w sieci mesh."; +"map.common.dismissOverlay" = "Zamknij"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Warstwy mapy"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Zablokuj na północ"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Odblokuj obrót"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Odśwież kontakty"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Satelitarna"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Hybrydowa"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografia"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Pokój"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 0f97b96fd..2139f0129 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapy offline"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Brak map offline"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Pobierz regiony mapy do użytku bez internetu."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Pobierz region"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Pamięć"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Zajęta pamięć"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Nieznany region"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Pobrano"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Pobieranie…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Wybierz region"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nazwa regionu"; + +/* Button to start download */ +"offlineMaps.download" = "Pobierz"; + +/* Cancel button */ +"offlineMaps.cancel" = "Anuluj"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Usunąć mapę offline?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Pobrane dane mapy zostaną usunięte."; + +/* Delete button */ +"offlineMaps.delete" = "Usuń"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Za mało miejsca na dysku. Wymagane jest co najmniej 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Osiągnięto limit kafelków dla tego pobierania."; + +/* Pause download button */ +"offlineMaps.pause" = "Wstrzymaj"; + +/* Resume download button */ +"offlineMaps.resume" = "Wznów"; + +/* Paused status label */ +"offlineMaps.paused" = "Wstrzymano"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Szacowany rozmiar: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Duży obszar pobierania. Może to trochę potrwać i zużyć dużo miejsca."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Wpisz nazwę i wybierz obszar do pobrania."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Za mało miejsca na tym urządzeniu. Przybliż, aby wybrać mniejszy obszar."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa bazowa"; +"offlineMaps.layer.topo" = "Topografia"; + +/* Layers section header */ +"offlineMaps.layers" = "Warstwy"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Dołącz dodatkowe warstwy do użytku offline."; + +/* No network available */ +"offlineMaps.noNetwork" = "Do pobrania map wymagane jest połączenie z internetem."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Obejmuje dane mapy i wewnętrzne indeksy. Łączny rozmiar może być większy niż suma poszczególnych pobrań."; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index eed626315..7fcac86b1 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Показать метки"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Центрировать на маршруте"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Сохранить путь"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Результаты"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Путь"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "по сравнению с %d мс %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Увеличено"; +"contacts.results.comparison.decreased" = "Уменьшено"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Просмотреть %d запусков"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 725c4dc60..be54431dd 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Обновить"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Нет контактов на карте"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Контакты с данными о местоположении появятся здесь при обнаружении в сети mesh."; +"map.common.dismissOverlay" = "Закрыть"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Слои карты"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Зафиксировать на север"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Разблокировать вращение"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Обновить контакты"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Спутниковая"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Гибридная"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Топография"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Комната"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Офлайн"; diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 314ae6f6f..00dc1f5a1 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Офлайн-карты"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Нет офлайн-карт"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Скачайте регионы карты для использования без интернета."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Скачать регион"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Хранилище"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Занято"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Неизвестный регион"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Загружено"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Загрузка…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Выберите регион"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Название региона"; + +/* Button to start download */ +"offlineMaps.download" = "Скачать"; + +/* Cancel button */ +"offlineMaps.cancel" = "Отмена"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Удалить офлайн-карту?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Скачанные данные карты будут удалены."; + +/* Delete button */ +"offlineMaps.delete" = "Удалить"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Недостаточно места. Требуется не менее 100 МБ."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Достигнут лимит тайлов для этой загрузки."; + +/* Pause download button */ +"offlineMaps.pause" = "Пауза"; + +/* Resume download button */ +"offlineMaps.resume" = "Продолжить"; + +/* Paused status label */ +"offlineMaps.paused" = "Приостановлено"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Примерный размер: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Большая область загрузки. Это может занять время и потребовать значительного объёма памяти."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Введите название и выберите область для загрузки."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Недостаточно места на устройстве. Увеличьте масштаб, чтобы выбрать меньшую область."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Базовая карта"; +"offlineMaps.layer.topo" = "Топография"; + +/* Layers section header */ +"offlineMaps.layers" = "Слои"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Включить дополнительные слои для использования офлайн."; + +/* No network available */ +"offlineMaps.noNetwork" = "Для загрузки карт требуется подключение к интернету."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Включает данные карты и внутренние индексы. Общий объём может превышать сумму отдельных загрузок."; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 1ac2da435..17559d38c 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -883,6 +883,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "Показати підписи"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Центрувати на маршруті"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "Зберегти шлях"; @@ -907,6 +910,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Результати"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Шлях"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -936,6 +942,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "проти %d мс на %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Збільшено"; +"contacts.results.comparison.decreased" = "Зменшено"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "Переглянути %d запусків"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 4741c2db9..cdf012004 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "Оновити"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "Немає контактів на карті"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "Контакти з даними про місцезнаходження з'являться тут при виявленні в mesh-мережі."; +"map.common.dismissOverlay" = "Закрити"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "Шари карти"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "Зафіксувати на північ"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "Розблокувати обертання"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Оновити контакти"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "Супутникова"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "Гібридна"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Топографія"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "Кімната"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Офлайн"; diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 8c7a2c78d..d3cf44935 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1264,3 +1264,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Офлайн-карти"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Немає офлайн-карт"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Завантажте регіони карти для використання без інтернету."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Завантажити регіон"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "Сховище"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Використано"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Невідомий регіон"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Завантажено"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "Завантаження…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Оберіть регіон"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Назва регіону"; + +/* Button to start download */ +"offlineMaps.download" = "Завантажити"; + +/* Cancel button */ +"offlineMaps.cancel" = "Скасувати"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "Видалити офлайн-карту?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "Завантажені дані карти буде видалено."; + +/* Delete button */ +"offlineMaps.delete" = "Видалити"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Недостатньо місця. Потрібно щонайменше 100 МБ."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Досягнуто ліміт тайлів для цього завантаження."; + +/* Pause download button */ +"offlineMaps.pause" = "Пауза"; + +/* Resume download button */ +"offlineMaps.resume" = "Продовжити"; + +/* Paused status label */ +"offlineMaps.paused" = "Призупинено"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Орієнтовний розмір: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Велика область завантаження. Це може зайняти час і потребувати значного обсягу пам'яті."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Введіть назву та виберіть область для завантаження."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Недостатньо місця на пристрої. Збільште масштаб, щоб вибрати меншу область."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Базова карта"; +"offlineMaps.layer.topo" = "Топографія"; + +/* Layers section header */ +"offlineMaps.layers" = "Шари"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "Додати додаткові шари для використання офлайн."; + +/* No network available */ +"offlineMaps.noNetwork" = "Для завантаження карт потрібне з'єднання з інтернетом."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Включає дані карти та внутрішні індекси. Загальний обсяг може перевищувати суму окремих завантажень."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index b7d327e70..3e50f4c62 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -896,6 +896,9 @@ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ "contacts.trace.map.showLabels" = "显示标签"; +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "以路径为中心"; + /* Location: TracePathMapView.swift - Purpose: Save path alert title */ "contacts.trace.map.saveTitle" = "保存路径"; @@ -920,6 +923,9 @@ /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "结果"; +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "路径"; + // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ @@ -949,6 +955,10 @@ /* Location: TraceResultsSheet.swift - Purpose: Comparison text */ "contacts.results.comparison" = "对比 %@ 毫秒在 %@"; +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "增加"; +"contacts.results.comparison.decreased" = "减少"; + /* Location: TraceResultsSheet.swift - Purpose: View runs link */ "contacts.results.viewRuns" = "查看 %d 次运行"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index a2129c897..3bcfa42e0 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -10,17 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "完成"; - -/* Location: MapView.swift - Purpose: Refresh button label */ -"map.common.refresh" = "刷新"; - -// MARK: - Map View Empty State - -/* Location: MapView.swift - Purpose: Empty state title when no contacts have location */ -"map.emptyState.title" = "地图上无联系人"; - -/* Location: MapView.swift - Purpose: Empty state description */ -"map.emptyState.description" = "带有位置数据的联系人将在Mesh网络上发现后显示在此处"; +"map.common.dismissOverlay" = "关闭"; // MARK: - Map Controls @@ -39,6 +29,15 @@ /* Location: MapControlsToolbar.swift - Purpose: Accessibility label for layers button */ "map.controls.layers" = "地图图层"; +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (lock) */ +"map.controls.lockNorth" = "锁定朝北"; + +/* Location: MapCanvasView.swift - Purpose: Accessibility label for north lock button (unlock) */ +"map.controls.unlockNorth" = "解锁旋转"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "刷新联系人"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -47,8 +46,17 @@ /* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ "map.style.satellite" = "卫星"; -/* Location: MapStyleSelection.swift - Purpose: Hybrid map style option */ -"map.style.hybrid" = "混合"; +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "地形"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Map style"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requires network connection"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "No offline map covers this area"; // MARK: - Contact Detail Sheet @@ -155,3 +163,8 @@ /* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ "map.annotation.room" = "房间"; + +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "离线"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index 3803e72ac..c19945ed7 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1237,3 +1237,97 @@ // Reply with Quote "replyWithQuote.toggle" = "Reply with Quote"; "replyWithQuote.footer" = "Replying includes a preview of the original message."; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "离线地图"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "无离线地图"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "下载地图区域以便在无网络时使用。"; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "下载区域"; + + +/* Section header for storage info */ +"offlineMaps.storage" = "存储空间"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "已使用"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "未知区域"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "已下载"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "下载中…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "选择区域"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "区域名称"; + +/* Button to start download */ +"offlineMaps.download" = "下载"; + +/* Cancel button */ +"offlineMaps.cancel" = "取消"; + +/* Delete confirmation title */ +"offlineMaps.deleteTitle" = "删除离线地图?"; + +/* Delete confirmation message */ +"offlineMaps.deleteMessage" = "已下载的地图数据将被删除。"; + +/* Delete button */ +"offlineMaps.delete" = "删除"; + +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "存储空间不足,至少需要 100 MB。"; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "已达到此下载的瓦片数量限制。"; + +/* Pause download button */ +"offlineMaps.pause" = "暂停"; + +/* Resume download button */ +"offlineMaps.resume" = "继续"; + +/* Paused status label */ +"offlineMaps.paused" = "已暂停"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "预计大小:~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "下载区域较大,可能需要较长时间并占用大量存储空间。"; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "输入名称并选择要下载的区域。"; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "设备存储空间不足。请放大地图以选择更小的区域。"; + +/* Layer type labels */ +"offlineMaps.layer.base" = "基础地图"; +"offlineMaps.layer.topo" = "地形"; + +/* Layers section header */ +"offlineMaps.layers" = "图层"; + +/* Include layers prompt */ +"offlineMaps.includeLayers" = "包含额外图层以供离线使用。"; + +/* No network available */ +"offlineMaps.noNetwork" = "下载地图需要互联网连接。"; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "包括地图数据和内部索引。总大小可能超过各项下载的总和。"; diff --git a/MC1/Resources/Styles/topo-offline.json b/MC1/Resources/Styles/topo-offline.json new file mode 100644 index 000000000..de137f080 --- /dev/null +++ b/MC1/Resources/Styles/topo-offline.json @@ -0,0 +1,22 @@ +{ + "version": 8, + "name": "Topo Offline", + "sources": { + "topo": { + "type": "raster", + "tiles": [ + "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + ], + "tileSize": 256, + "maxzoom": 17, + "attribution": "OpenTopoMap" + } + }, + "layers": [ + { + "id": "topo", + "type": "raster", + "source": "topo" + } + ] +} diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift new file mode 100644 index 000000000..11db500d7 --- /dev/null +++ b/MC1/Services/OfflineMapService.swift @@ -0,0 +1,425 @@ +import Foundation +import MapLibre +import Network +import os + +enum OfflineMapLayer: String, Codable { + case base + case topo + + var label: String { + switch self { + case .base: L10n.Settings.OfflineMaps.Layer.base + case .topo: L10n.Settings.OfflineMaps.Layer.topo + } + } + + var maxDownloadZoom: Double { + switch self { + case .base: 14 + case .topo: 17 + } + } + + var styleURL: URL? { + switch self { + case .base: + URL(string: MapTileURLs.openFreeMapLiberty) + case .topo: + Bundle.main.url(forResource: "topo-offline", withExtension: "json") + } + } +} + +struct OfflinePackMetadata: Codable { + let name: String + let createdAt: Date + var layer: OfflineMapLayer + + init(name: String, createdAt: Date, layer: OfflineMapLayer = .base) { + self.name = name + self.createdAt = createdAt + self.layer = layer + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + createdAt = try container.decode(Date.self, forKey: .createdAt) + layer = try container.decodeIfPresent(OfflineMapLayer.self, forKey: .layer) ?? .base + } +} + +enum OfflineMapError: LocalizedError { + case insufficientDiskSpace + case missingStyleResource(OfflineMapLayer) + + var errorDescription: String? { + switch self { + case .insufficientDiskSpace: + L10n.Settings.OfflineMaps.Error.insufficientDiskSpace + case .missingStyleResource(let layer): + "Missing style resource for layer: \(layer.rawValue)" + } + } +} + +@MainActor @Observable +final class OfflineMapService { + private static let logger = Logger(subsystem: "com.mc1", category: "OfflineMapService") + + private static let minimumDiskSpaceBytes: Int64 = 100_000_000 + + private(set) var packs: [OfflinePack] = [] + private(set) var databaseSize: Int64 = 0 + private(set) var isNetworkAvailable = true + private(set) var lastPackError: String? + + private let monitor = NWPathMonitor() + private var observationTasks: [Task] = [] + private var pendingLoadTask: Task? + private var highWaterMarks: [ObjectIdentifier: Double] = [:] + private var byteSnapshots: [ObjectIdentifier: (bytes: UInt64, time: ContinuousClock.Instant)] = [:] + private var downloadSpeeds: [ObjectIdentifier: Int64] = [:] + private var metadataCache: [ObjectIdentifier: OfflinePackMetadata?] = [:] + private var deletingPackIDs: Set = [] + private var userPausedPackIDs: Set = [] + + init() { + let monitor = self.monitor + let networkStream = AsyncStream { continuation in + continuation.onTermination = { _ in monitor.cancel() } + monitor.pathUpdateHandler = { continuation.yield($0) } + // NWPathMonitor requires a DispatchQueue; no Swift concurrency alternative exists. + monitor.start(queue: .global(qos: .utility)) + } + observationTasks.append(Task { [weak self] in + for await path in networkStream { + self?.isNetworkAvailable = path.status == .satisfied + } + }) + + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { + self?.scheduleLoadPacks() + } + }) + observationTasks.append(Task { [weak self] in + for await notification in NotificationCenter.default.notifications(named: .MLNOfflinePackError) { + if let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError { + Self.logger.warning("Offline pack error: \(error.localizedDescription)") + self?.lastPackError = error.localizedDescription + } + } + }) + observationTasks.append(Task { [weak self] in + for await _ in NotificationCenter.default.notifications( + named: .MLNOfflinePackMaximumMapboxTilesReached + ) { + Self.logger.warning("Offline pack tile limit reached") + self?.lastPackError = L10n.Settings.OfflineMaps.Error.tileLimitReached + } + }) + + excludeDatabaseFromBackup() + loadPacks() + updateDatabaseSize() + + // MLNOfflineStorage.shared.packs may be nil until async DB load completes. + // Retry once after a delay to catch late initialization. + if MLNOfflineStorage.shared.packs == nil { + observationTasks.append(Task { [weak self] in + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + self?.loadPacks() + }) + } + } + + isolated deinit { + monitor.cancel() + pendingLoadTask?.cancel() + for task in observationTasks { + task.cancel() + } + } + + func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { + packs.contains { $0.layer == layer && $0.isComplete } + } + + func hasCompletedPack(for layer: OfflineMapLayer, overlapping viewport: MLNCoordinateBounds) -> Bool { + packs.contains { pack in + pack.layer == layer && pack.isComplete && pack.bounds.map { $0.overlaps(viewport) } ?? false + } + } + + func loadPacks() { + let mlnPacks = MLNOfflineStorage.shared.packs ?? [] + let currentIDs = Set(mlnPacks.map { ObjectIdentifier($0) }) + let now = ContinuousClock.now + + // Remove tracking data for deleted packs + highWaterMarks = highWaterMarks.filter { currentIDs.contains($0.key) } + byteSnapshots = byteSnapshots.filter { currentIDs.contains($0.key) } + downloadSpeeds = downloadSpeeds.filter { currentIDs.contains($0.key) } + metadataCache = metadataCache.filter { currentIDs.contains($0.key) } + + packs = mlnPacks.map { mlnPack in + let packID = ObjectIdentifier(mlnPack) + let previousFraction = highWaterMarks[packID] ?? 0 + let currentBytes = mlnPack.progress.countOfBytesCompleted + + if let previous = byteSnapshots[packID] { + let elapsed = now - previous.time + let seconds = elapsed / .seconds(1) + if seconds > 0.5, currentBytes > previous.bytes { + downloadSpeeds[packID] = Int64(Double(currentBytes - previous.bytes) / seconds) + } else if currentBytes == previous.bytes { + downloadSpeeds[packID] = 0 + } + } + byteSnapshots[packID] = (bytes: currentBytes, time: now) + + let speed = downloadSpeeds[packID] + if metadataCache[packID] == nil { + metadataCache[packID] = try? JSONDecoder().decode(OfflinePackMetadata.self, from: mlnPack.context) + } + let pack = OfflinePack(pack: mlnPack, metadata: metadataCache[packID] ?? nil, previousFraction: previousFraction, downloadSpeed: speed) + highWaterMarks[packID] = pack.completedFraction + return pack + } + + for mlnPack in mlnPacks where mlnPack.state == .unknown { + mlnPack.requestProgress() + } + } + + private func updateDatabaseSize() { + let url = MLNOfflineStorage.shared.databaseURL + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + databaseSize = Int64(size) + } + + /// Coalesces rapid progress notifications into a single `loadPacks()` call. + private func scheduleLoadPacks() { + pendingLoadTask?.cancel() + pendingLoadTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + loadPacks() + } + } + + // Note: active offline downloads share MapLibre's internal FIFO request queue + // with the interactive map renderer. Large downloads may degrade live map tile + // loading. Consider suspending packs during active map interaction if needed. + func downloadRegion( + name: String, + bounds: MLNCoordinateBounds, + layers: Set, + minZoom: Double = 10 + ) async throws { + let values = try URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + if let available = values.volumeAvailableCapacityForImportantUsage, + available < Self.minimumDiskSpaceBytes { + throw OfflineMapError.insufficientDiskSpace + } + + let encoder = JSONEncoder() + let now = Date.now + + var pendingPacks: [(region: MLNTilePyramidOfflineRegion, context: Data)] = [] + for layer in layers { + guard let styleURL = layer.styleURL else { + throw OfflineMapError.missingStyleResource(layer) + } + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: layer.maxDownloadZoom + ) + let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) + let context = try encoder.encode(metadata) + pendingPacks.append((region, context)) + } + + for (region, context) in pendingPacks { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in + if let error { + continuation.resume(throwing: error) + } else { + pack?.resume() + continuation.resume() + } + } + } + } + loadPacks() + updateDatabaseSize() + } + + func deletePack(_ pack: OfflinePack) async { + guard deletingPackIDs.insert(pack.id).inserted else { return } + defer { deletingPackIDs.remove(pack.id) } + + await withCheckedContinuation { continuation in + MLNOfflineStorage.shared.removePack(pack.mlnPack) { error in + if let error { + Self.logger.error("Failed to delete offline pack: \(error.localizedDescription)") + } + continuation.resume() + } + } + highWaterMarks.removeValue(forKey: pack.id) + byteSnapshots.removeValue(forKey: pack.id) + downloadSpeeds.removeValue(forKey: pack.id) + loadPacks() + updateDatabaseSize() + } + + func pausePack(_ pack: OfflinePack) { + userPausedPackIDs.insert(pack.id) + pack.mlnPack.suspend() + loadPacks() + } + + func resumePack(_ pack: OfflinePack) { + userPausedPackIDs.remove(pack.id) + pack.mlnPack.resume() + loadPacks() + } + + func resumeAllPacks() { + for pack in MLNOfflineStorage.shared.packs ?? [] { + let packID = ObjectIdentifier(pack) + if pack.state == .inactive, !userPausedPackIDs.contains(packID) { + pack.resume() + } + } + loadPacks() + } + + func clearLastPackError() { + lastPackError = nil + } + + /// Estimated download size using per-zoom average byte sizes. + nonisolated static func estimatedDownloadSize( + bounds: MLNCoordinateBounds, + minZoom: Int, + maxZoom: Int, + layer: OfflineMapLayer = .base + ) -> Int64 { + let bytesPerTile: [Int: Int64] + switch layer { + case .base: + // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). + // Populated land regions average 30-150 KB per tile at these zooms. + bytesPerTile = [ + 10: 15_000, 11: 25_000, 12: 45_000, + 13: 70_000, 14: 100_000, + ] + case .topo: + // OpenTopoMap PNG raster tiles (256px, max z17). + bytesPerTile = [ + 10: 15_000, 11: 18_000, 12: 22_000, + 13: 25_000, 14: 30_000, 15: 35_000, + 16: 40_000, 17: 45_000, + ] + } + + // Non-tile resources: style JSON, TileJSON manifests, sprites, glyph PBFs + let overhead: Int64 = 500_000 + + var total: Int64 = 0 + for z in minZoom...maxZoom { + let n = Double(1 << z) + let xMin = Int(floor((bounds.sw.longitude + 180) / 360 * n)) + let xMax = Int(floor((bounds.ne.longitude + 180) / 360 * n)) + + let latRadNE = bounds.ne.latitude * .pi / 180 + let latRadSW = bounds.sw.latitude * .pi / 180 + let yMin = Int(floor((1 - log(tan(latRadNE) + 1 / cos(latRadNE)) / .pi) / 2 * n)) + let yMax = Int(floor((1 - log(tan(latRadSW) + 1 / cos(latRadSW)) / .pi) / 2 * n)) + + let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) + total += Int64(tileCount) * (bytesPerTile[z] ?? 10_000) + } + return total + overhead + } + + private func excludeDatabaseFromBackup() { + var url = MLNOfflineStorage.shared.databaseURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + Self.logger.error("Failed to exclude offline database from backup: \(error.localizedDescription)") + } + } +} + +struct OfflinePack: Identifiable { + let id: ObjectIdentifier + fileprivate let mlnPack: MLNOfflinePack + let name: String + let createdAt: Date? + let layer: OfflineMapLayer + let completedFraction: Double + let downloadSpeed: Int64? + let bounds: MLNCoordinateBounds? + + private let progress: MLNOfflinePackProgress + private let state: MLNOfflinePackState + + var completedBytes: UInt64 { progress.countOfBytesCompleted } + var isComplete: Bool { state == .complete } + var isPaused: Bool { state == .inactive } + + init(pack: MLNOfflinePack, metadata: OfflinePackMetadata?, previousFraction: Double = 0, downloadSpeed: Int64? = nil) { + self.id = ObjectIdentifier(pack) + self.mlnPack = pack + self.progress = pack.progress + self.state = pack.state + self.bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds + + let rawFraction: Double + if state == .complete { + rawFraction = 1 + } else if progress.countOfResourcesExpected > 0 { + rawFraction = Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) + } else { + rawFraction = 0 + } + self.completedFraction = max(rawFraction, previousFraction) + self.downloadSpeed = state == .active ? downloadSpeed : nil + + if let metadata { + self.name = metadata.name + self.createdAt = metadata.createdAt + self.layer = metadata.layer + } else { + self.name = L10n.Settings.OfflineMaps.unknownRegion + self.createdAt = nil + self.layer = .base + } + } +} + +extension MLNCoordinateBounds { + func overlaps(_ other: MLNCoordinateBounds) -> Bool { + sw.latitude <= other.ne.latitude + && ne.latitude >= other.sw.latitude + && sw.longitude <= other.ne.longitude + && ne.longitude >= other.sw.longitude + } +} diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index 726e09212..5baac3427 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -24,6 +24,11 @@ public final class AppState { /// App-wide location service for permission management public let locationService = LocationService() + // MARK: - Offline Maps + + /// Offline map pack management and network monitoring + let offlineMapService = OfflineMapService() + /// Best available location for proximity-based disambiguation. public var bestAvailableLocation: CLLocation? { if let phoneLocation = locationService.currentLocation { @@ -640,6 +645,8 @@ public final class AppState { await batteryMonitor.checkMissedBatteryThreshold(device: connectedDevice, services: services) batteryMonitor.startRefreshLoop(services: services, device: connectedDevice) } + + offlineMapService.resumeAllPacks() } // MARK: - Onboarding diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 31d1e92cd..abb6b7a5c 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -47,29 +47,42 @@ struct ChatConversationMessagesContent: View { let onScrollToMention: () -> Void let onRetryMessage: (MessageDTO) -> Void - // MARK: - Private State - - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - @State private var hasDismissedDividerFAB = false - - private var showDividerFAB: Bool { - newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB - } - // MARK: - Body var body: some View { - if !viewModel.hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - messagesTable - .overlay { - if viewModel.messages.isEmpty { - emptyState - .allowsHitTesting(false) - } - } + Group { + if !viewModel.hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.messages.isEmpty { + emptyState + } else { + ChatMessagesTableView( + viewModel: viewModel, + contactName: conversationType.navigationTitle, + deviceName: deviceName, + configuration: bubbleConfiguration, + recentEmojisStore: recentEmojisStore, + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + unseenMentionIDs: unseenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: newMessagesDividerMessageID, + onMentionSeen: onMentionSeen, + onScrollToMention: onScrollToMention, + onRetryMessage: onRetryMessage + ) + } } } @@ -89,75 +102,7 @@ struct ChatConversationMessagesContent: View { } } - // MARK: - Messages Table + Overlays - - private var messagesTable: some View { - let mentionIDSet = Set(unseenMentionIDs) - let contactName = conversationType.navigationTitle - let configuration = bubbleConfiguration - return ChatTableView( - items: viewModel.displayItems, - cellContent: { item in messageBubble(for: item, contactName: contactName, configuration: configuration) }, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { item in - item.containsSelfMention && !item.mentionSeen && mentionIDSet.contains(item.id) - }, - onMentionBecameVisible: { id in - Task { - await onMentionSeen(id) - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { - Task { - await viewModel.loadOlderMessages() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerFAB { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerFAB = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !unseenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: unseenMentionIDs.count, - onTap: { onScrollToMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerFAB) - .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - .onChange(of: newMessagesDividerMessageID) { _, _ in - hasDismissedDividerFAB = false - } - } - - // MARK: - Message Bubble Construction + // MARK: - Bubble Configuration private var bubbleConfiguration: MessageBubbleConfiguration { switch conversationType { @@ -170,87 +115,6 @@ struct ChatConversationMessagesContent: View { ) } } - - private func onReaction(for message: MessageDTO) -> ((String) -> Void) { - { emoji in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - } - } - - @ViewBuilder - private func messageBubble( - for item: MessageDisplayItem, - contactName: String, - configuration: MessageBubbleConfiguration - ) -> some View { - if let message = viewModel.message(for: item) { - UnifiedMessageBubble( - message: message, - contactName: contactName, - deviceName: deviceName, - configuration: configuration, - displayState: MessageDisplayState( - showTimestamp: item.showTimestamp, - showDirectionGap: item.showDirectionGap, - showSenderName: item.showSenderName, - showNewMessagesDivider: item.showNewMessagesDivider, - detectedURL: item.detectedURL, - previewState: item.previewState, - loadedPreview: item.loadedPreview, - isImageURL: item.isImageURL, - decodedImage: viewModel.decodedImage(for: message.id), - decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), - decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), - isGIF: viewModel.isGIFImage(for: message.id), - showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs, - showIncomingPath: showIncomingPath, - showIncomingHopCount: showIncomingHopCount, - formattedText: viewModel.formattedText( - for: message.id, - text: message.text, - isOutgoing: message.isOutgoing, - currentUserName: deviceName, - isHighContrast: colorSchemeContrast == .increased - ) - ), - callbacks: MessageBubbleCallbacks( - onRetry: { onRetryMessage(message) }, - onReaction: onReaction(for: message), - onLongPress: { selectedMessageForActions = message }, - onImageTap: { - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: viewModel.isGIFImage(for: message.id) - ) - } - }, - onRetryImageFetch: { - Task { await viewModel.retryImageFetch(for: message.id) } - }, - onRequestPreviewFetch: { - if item.isImageURL && showInlineImages { - viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) - } else { - viewModel.requestPreviewFetch(for: message.id) - } - }, - onManualPreviewFetch: { - Task { - await viewModel.manualFetchPreview(for: message.id) - } - } - ) - ) - } else { - Text(L10n.Chats.Chats.Message.unavailable) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) - } - } } // MARK: - DM Empty Messages View diff --git a/MC1/Views/Chats/Components/ChatMessagesTableView.swift b/MC1/Views/Chats/Components/ChatMessagesTableView.swift new file mode 100644 index 000000000..5321f777d --- /dev/null +++ b/MC1/Views/Chats/Components/ChatMessagesTableView.swift @@ -0,0 +1,117 @@ +import SwiftUI +import MC1Services + +/// Messages table with ChatTableView, overlay FABs, and divider state management +struct ChatMessagesTableView: View { + @Bindable var viewModel: ChatViewModel + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + let recentEmojisStore: RecentEmojisStore + let showInlineImages: Bool + let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool + + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + + let unseenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + let onMentionSeen: (UUID) async -> Void + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void + + @State private var hasDismissedDividerFAB = false + + private var showDividerFAB: Bool { + newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB + } + + var body: some View { + let mentionIDSet = Set(unseenMentionIDs) + ChatTableView( + items: viewModel.displayItems, + cellContent: { item in + MessageBubbleView( + item: item, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + viewModel: viewModel, + recentEmojisStore: recentEmojisStore, + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + selectedMessageForActions: $selectedMessageForActions, + imageViewerData: $imageViewerData, + onRetryMessage: onRetryMessage + ) + }, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + isUnseenMention: { item in + item.containsSelfMention && !item.mentionSeen && mentionIDSet.contains(item.id) + }, + onMentionBecameVisible: { id in + Task { + await onMentionSeen(id) + } + }, + mentionTargetID: scrollToTargetID, + scrollToDividerRequest: $scrollToDividerRequest, + dividerItemID: newMessagesDividerMessageID, + isDividerVisible: $isDividerVisible, + onNearTop: { + Task { + await viewModel.loadOlderMessages() + } + }, + isLoadingOlderMessages: viewModel.isLoadingOlder + ) + .overlay(alignment: .bottomTrailing) { + VStack(spacing: 12) { + if showDividerFAB { + ScrollToDividerButton( + onTap: { + scrollToDividerRequest += 1 + hasDismissedDividerFAB = true + } + ) + .transition(.scale.combined(with: .opacity)) + } + + if !unseenMentionIDs.isEmpty { + ScrollToMentionButton( + unreadMentionCount: unseenMentionIDs.count, + onTap: { onScrollToMention() } + ) + .transition(.scale.combined(with: .opacity)) + } + + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + } + .animation(.snappy(duration: 0.2), value: showDividerFAB) + .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: newMessagesDividerMessageID) { _, _ in + hasDismissedDividerFAB = false + } + } +} diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift new file mode 100644 index 000000000..8e0101cda --- /dev/null +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -0,0 +1,93 @@ +import SwiftUI +import MC1Services + +/// Constructs a UnifiedMessageBubble for a given display item, resolving message data from the view model +struct MessageBubbleView: View { + let item: MessageDisplayItem + let contactName: String + let deviceName: String + let configuration: MessageBubbleConfiguration + @Bindable var viewModel: ChatViewModel + let recentEmojisStore: RecentEmojisStore + let showInlineImages: Bool + let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool + @Binding var selectedMessageForActions: MessageDTO? + @Binding var imageViewerData: ImageViewerData? + let onRetryMessage: (MessageDTO) -> Void + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + var body: some View { + if let message = viewModel.message(for: item) { + UnifiedMessageBubble( + message: message, + contactName: contactName, + deviceName: deviceName, + configuration: configuration, + displayState: MessageDisplayState( + showTimestamp: item.showTimestamp, + showDirectionGap: item.showDirectionGap, + showSenderName: item.showSenderName, + showNewMessagesDivider: item.showNewMessagesDivider, + detectedURL: item.detectedURL, + previewState: item.previewState, + loadedPreview: item.loadedPreview, + isImageURL: item.isImageURL, + decodedImage: viewModel.decodedImage(for: message.id), + decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), + decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), + isGIF: viewModel.isGIFImage(for: message.id), + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + formattedText: viewModel.formattedText( + for: message.id, + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: deviceName, + isHighContrast: colorSchemeContrast == .increased + ) + ), + callbacks: MessageBubbleCallbacks( + onRetry: { onRetryMessage(message) }, + onReaction: { emoji in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { selectedMessageForActions = message }, + onImageTap: { + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: viewModel.isGIFImage(for: message.id) + ) + } + }, + onRetryImageFetch: { + Task { await viewModel.retryImageFetch(for: message.id) } + }, + onRequestPreviewFetch: { + if item.isImageURL && showInlineImages { + viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) + } else { + viewModel.requestPreviewFetch(for: message.id) + } + }, + onManualPreviewFetch: { + Task { + await viewModel.manualFetchPreview(for: message.id) + } + } + ) + ) + } else { + Text(L10n.Chats.Chats.Message.unavailable) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) + } + } +} diff --git a/MC1/Views/Components/LabelsToggleButton.swift b/MC1/Views/Components/LabelsToggleButton.swift new file mode 100644 index 000000000..c940740ac --- /dev/null +++ b/MC1/Views/Components/LabelsToggleButton.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct LabelsToggleButton: View { + @Binding var showLabels: Bool + + var body: some View { + Button(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { + withAnimation { + showLabels.toggle() + } + } + .font(.body.weight(.medium)) + .foregroundStyle(showLabels ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index ae416dce1..00865f4e7 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -2,22 +2,29 @@ import MapKit import SwiftUI /// Shared toolbar for map control buttons with liquid glass styling. -/// Provides location and layers buttons with a slot for custom content. -struct MapControlsToolbar: View { +/// Provides location and layers buttons with slots for top and bottom custom content. +struct MapControlsToolbar: View { /// MapScope for SwiftUI Map's MapUserLocationButton. Mutually exclusive with onLocationTap. var mapScope: Namespace.ID? - /// Custom action for location button. Used when MapScope isn't available (e.g., MKMapViewRepresentable). + /// Custom action for location button. Used when MapScope isn't available (e.g., MapLibre views). var onLocationTap: (() -> Void)? /// Binding to control layers menu visibility. Parent view handles menu presentation. @Binding var showingLayersMenu: Bool + /// Custom buttons to display above the standard buttons. + @ViewBuilder var topContent: () -> TopContent + /// Custom buttons to display below the standard buttons. @ViewBuilder var customContent: () -> CustomContent var body: some View { VStack(spacing: 0) { + CustomContentStack { + topContent() + } + locationButton Divider() @@ -43,34 +50,45 @@ struct MapControlsToolbar: View { .frame(width: 44, height: 44) .contentShape(.rect) } else if let onLocationTap { - Button(action: onLocationTap) { - Image(systemName: "location.fill") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Controls.centerOnMyLocation) + Button(L10n.Map.Map.Controls.centerOnMyLocation, systemImage: "location.fill", action: onLocationTap) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) } } // MARK: - Layers Button private var layersButton: some View { - Button { + Button(L10n.Map.Map.Controls.layers, systemImage: "square.3.layers.3d.down.right") { withAnimation(.spring(response: 0.3)) { showingLayersMenu.toggle() } - } label: { - Image(systemName: "square.3.layers.3d.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) } + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) - .accessibilityLabel(L10n.Map.Map.Controls.layers) + .labelStyle(.iconOnly) + } +} + +extension MapControlsToolbar where TopContent == EmptyView { + init( + mapScope: Namespace.ID? = nil, + onLocationTap: (() -> Void)? = nil, + showingLayersMenu: Binding, + @ViewBuilder customContent: @escaping () -> CustomContent + ) { + self.mapScope = mapScope + self.onLocationTap = onLocationTap + self._showingLayersMenu = showingLayersMenu + self.topContent = { EmptyView() } + self.customContent = customContent } } @@ -81,19 +99,12 @@ private struct CustomContentStack: View { @ViewBuilder var content: Content var body: some View { - _VariadicView.Tree(DividerLayout()) { - content - } - } -} - -/// Layout that prepends a divider before each child view. -private struct DividerLayout: _VariadicView_MultiViewRoot { - func body(children: _VariadicView.Children) -> some View { - ForEach(children) { child in - Divider() - .frame(width: 36) - child + Group(subviews: content) { subviews in + ForEach(subviews) { subview in + Divider() + .frame(width: 36) + subview + } } } } diff --git a/MC1/Views/Components/NoDoubleTapMapView.swift b/MC1/Views/Components/NoDoubleTapMapView.swift deleted file mode 100644 index 732b7a566..000000000 --- a/MC1/Views/Components/NoDoubleTapMapView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import MapKit - -/// MKMapView subclass that disables double-tap-to-zoom and one-handed zoom gestures. -/// Directly disables VariableDelayTap and OneHandedZoom gesture recognizers on MapKit's -/// content view rather than using `require(toFail:)` blockers, which avoids a ~1s cascading -/// gesture timeout after tapping annotation pins. -final class NoDoubleTapMapView: MKMapView { - override func layoutSubviews() { - super.layoutSubviews() - disableDoubleTapGestures() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - disableDoubleTapGestures() - return super.hitTest(point, with: event) - } - - private func disableDoubleTapGestures() { - guard let contentView = subviews.first(where: { - NSStringFromClass(type(of: $0)).contains("MapContentView") - }) else { return } - - for gesture in contentView.gestureRecognizers ?? [] { - guard gesture.isEnabled else { continue } - let className = NSStringFromClass(type(of: gesture)) - if className.contains("VariableDelayTap") || className.contains("OneHandedZoom") { - gesture.isEnabled = false - } - } - } -} diff --git a/MC1/Views/Components/NorthLockButton.swift b/MC1/Views/Components/NorthLockButton.swift new file mode 100644 index 000000000..d8a91113f --- /dev/null +++ b/MC1/Views/Components/NorthLockButton.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct NorthLockButton: View { + @Binding var isNorthLocked: Bool + + var body: some View { + Button( + isNorthLocked ? L10n.Map.Map.Controls.unlockNorth : L10n.Map.Map.Controls.lockNorth, + systemImage: isNorthLocked ? "location.north.line.fill" : "location.north.line" + ) { + withAnimation { + isNorthLocked.toggle() + } + } + .font(.body.weight(.medium)) + .foregroundStyle(isNorthLocked ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Components/ShareSheet.swift b/MC1/Views/Components/ShareSheet.swift deleted file mode 100644 index eb8295b4e..000000000 --- a/MC1/Views/Components/ShareSheet.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI -import UIKit - -struct ShareSheet: UIViewControllerRepresentable { - let items: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/MC1/Views/Contacts/BatchRTTRow.swift b/MC1/Views/Contacts/BatchRTTRow.swift new file mode 100644 index 000000000..b20c5d857 --- /dev/null +++ b/MC1/Views/Contacts/BatchRTTRow.swift @@ -0,0 +1,28 @@ +import SwiftUI +import MC1Services + +/// Row displaying batch RTT statistics (average, min, max) +struct BatchRTTRow: View { + @Bindable var viewModel: TracePathViewModel + + var body: some View { + if let avg = viewModel.averageRTT, + let min = viewModel.minRTT, + let max = viewModel.maxRTT { + HStack { + Text(L10n.Contacts.Contacts.Results.avgRoundTrip) + .foregroundStyle(.secondary) + Spacer() + VStack(alignment: .trailing) { + Text("\(avg) ms") + .font(.body.monospacedDigit()) + Text("(\(min) – \(max))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) + } + } +} diff --git a/MC1/Views/Contacts/ComparisonRowView.swift b/MC1/Views/Contacts/ComparisonRowView.swift new file mode 100644 index 000000000..566243d01 --- /dev/null +++ b/MC1/Views/Contacts/ComparisonRowView.swift @@ -0,0 +1,61 @@ +import SwiftUI +import MC1Services + +/// Row comparing current trace RTT with a previous saved path run +struct ComparisonRowView: View { + let currentMs: Int + let previousRun: TracePathRunDTO + @Bindable var viewModel: TracePathViewModel + + var body: some View { + let diff = currentMs - previousRun.roundTripMs + let percentChange = previousRun.roundTripMs > 0 + ? Double(diff) / Double(previousRun.roundTripMs) * 100 + : 0 + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(currentMs) ms") + .font(.body.monospacedDigit()) + + // Change indicator + if diff != 0 { + Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") + .foregroundStyle(diff > 0 ? .red : .green) + .accessibilityLabel(diff > 0 + ? L10n.Contacts.Contacts.Results.Comparison.increased + : L10n.Contacts.Contacts.Results.Comparison.decreased) + Text(abs(percentChange), format: .number.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + + Text("%") + .font(.caption) + } + } + + Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) + .font(.caption) + .foregroundStyle(.secondary) + } + + // Sparkline with history link + if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { + HStack { + MiniSparkline(values: savedPath.recentRTTs) + .frame(height: 20) + + Spacer() + + NavigationLink { + SavedPathDetailView(savedPath: savedPath) + } label: { + Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) + .font(.caption) + } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } + } +} diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index b19b6dc45..9ebb394eb 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -736,24 +736,41 @@ private struct ContactInfoSection: View { } private struct ContactLocationSection: View { - let currentContact: ContactDTO + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme - private var contactCoordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: currentContact.latitude, - longitude: currentContact.longitude - ) - } + let currentContact: ContactDTO var body: some View { Section { // Mini map - Map(position: .constant(.region(MKCoordinateRegion( - center: contactCoordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - )))) { - Marker(currentContact.displayName, coordinate: contactCoordinate) - } + MC1MapView( + points: [MapPoint( + id: currentContact.id, + coordinate: currentContact.coordinate, + pinStyle: currentContact.type.pinStyle, + label: currentContact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: false, + showsUserLocation: false, + isInteractive: false, + showsScale: false, + cameraRegion: .constant(MKCoordinateRegion( + center: currentContact.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )), + cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: nil + ) .frame(height: 200) .clipShape(.rect(cornerRadius: 12)) .listRowInsets(EdgeInsets()) @@ -770,7 +787,7 @@ private struct ContactLocationSection: View { } .listRowBackground( UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemGroupedBackground)) ) // Open in Maps @@ -785,7 +802,7 @@ private struct ContactLocationSection: View { } private func openInMaps() { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: contactCoordinate)) + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: currentContact.coordinate)) mapItem.name = currentContact.displayName mapItem.openInMaps() } diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index a9bdd9bcf..3469947e1 100644 --- a/MC1/Views/Contacts/DiscoveryView.swift +++ b/MC1/Views/Contacts/DiscoveryView.swift @@ -390,7 +390,7 @@ private struct DiscoverySortMenu: View { } label: { Label(L10n.Contacts.Contacts.List.sort, systemImage: "arrow.up.arrow.down") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() .accessibilityLabel(L10n.Contacts.Contacts.Discovery.sortMenu) .accessibilityHint(L10n.Contacts.Contacts.Discovery.sortMenuHint) } @@ -413,7 +413,7 @@ private struct DiscoveryMoreMenu: View { } label: { Label(L10n.Contacts.Contacts.Discovery.menu, systemImage: "ellipsis.circle") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() } } diff --git a/MC1/Views/Contacts/DistanceInfoSheetView.swift b/MC1/Views/Contacts/DistanceInfoSheetView.swift new file mode 100644 index 000000000..9c0371649 --- /dev/null +++ b/MC1/Views/Contacts/DistanceInfoSheetView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import MC1Services + +/// Sheet explaining distance calculation details and limitations +struct DistanceInfoSheetView: View { + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var showingDistanceInfo: Bool + + var body: some View { + NavigationStack { + List { + if viewModel.isDistanceUsingFallback { + Section { + Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) + } header: { + Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") + } + Section { + Text(L10n.Contacts.Contacts.Results.fullPathTip) + } header: { + Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") + } + } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { + Section { + Text(L10n.Contacts.Contacts.Results.needsRepeaters) + } + } else if viewModel.repeatersWithoutLocation.isEmpty { + Section { + Text(L10n.Contacts.Contacts.Results.distanceError) + } + } else { + Section { + Text(L10n.Contacts.Contacts.Results.missingLocations) + } + Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { + ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in + Text(name) + } + } + } + } + .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L10n.Contacts.Contacts.Common.done) { + showingDistanceInfo = false + } + } + } + } + } +} diff --git a/MC1/Views/Contacts/GlassButtonModifier.swift b/MC1/Views/Contacts/GlassButtonModifier.swift deleted file mode 100644 index b1b8b291f..000000000 --- a/MC1/Views/Contacts/GlassButtonModifier.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct GlassButtonModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.buttonStyle(.glass) - } else { - content - } - } -} diff --git a/MC1/Views/Contacts/SavePathRowView.swift b/MC1/Views/Contacts/SavePathRowView.swift new file mode 100644 index 000000000..52590a3a7 --- /dev/null +++ b/MC1/Views/Contacts/SavePathRowView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import MC1Services + +/// Row allowing the user to save the current trace path +struct SavePathRowView: View { + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + + @State private var showingSaveDialog = false + @State private var savePathName = "" + + var body: some View { + if showingSaveDialog { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) + .textFieldStyle(.roundedBorder) + + HStack { + Button(L10n.Contacts.Contacts.Common.cancel) { + showingSaveDialog = false + savePathName = "" + } + .buttonStyle(.bordered) + + Spacer() + + Button(L10n.Contacts.Contacts.Common.save) { + Task { + let success = await viewModel.savePath(name: savePathName) + if success { + saveHapticTrigger += 1 + } + showingSaveDialog = false + savePathName = "" + } + } + .buttonStyle(.borderedProminent) + .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) + } + } + .padding(.vertical, 4) + } else { + Button { + savePathName = viewModel.generatePathName() + showingSaveDialog = true + } label: { + HStack { + Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + .disabled(!viewModel.canSavePath) + } + } +} diff --git a/MC1/Views/Contacts/TotalDistanceRow.swift b/MC1/Views/Contacts/TotalDistanceRow.swift new file mode 100644 index 000000000..ddaf77194 --- /dev/null +++ b/MC1/Views/Contacts/TotalDistanceRow.swift @@ -0,0 +1,57 @@ +import SwiftUI +import MC1Services + +/// Row displaying total path distance with optional info sheet +struct TotalDistanceRow: View { + @Bindable var viewModel: TracePathViewModel + let result: TraceResult + @Binding var showingDistanceInfo: Bool + + var body: some View { + HStack { + Text(L10n.Contacts.Contacts.Results.totalDistance) + .foregroundStyle(.secondary) + Spacer() + + if let distance = viewModel.totalPathDistance { + HStack { + Text(formatDistance(distance)) + .font(.body.monospacedDigit()) + if viewModel.isDistanceUsingFallback { + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) + } + } + } else { + HStack { + Text(L10n.Contacts.Contacts.Results.unavailable) + .foregroundStyle(.secondary) + Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { + showingDistanceInfo = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) + .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) + } + } + } + .sheet(isPresented: $showingDistanceInfo) { + DistanceInfoSheetView(result: result, viewModel: viewModel, showingDistanceInfo: $showingDistanceInfo) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + + private func formatDistance(_ meters: Double) -> String { + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) + } +} diff --git a/MC1/Views/Contacts/TracePathListView.swift b/MC1/Views/Contacts/TracePathListView.swift index 7c2a4c0ab..41fc2390c 100644 --- a/MC1/Views/Contacts/TracePathListView.swift +++ b/MC1/Views/Contacts/TracePathListView.swift @@ -15,45 +15,28 @@ struct TracePathListView: View { @Binding var presentedResult: TraceResult? @Binding var showJumpToPath: Bool - @State private var isRepeatersExpanded = false @State private var codeInput = "" @State private var codeInputError: String? @State private var pastedSuccessfully = false - @AppStorage("tracePathShowOnlyFavorites") private var showOnlyFavorites = false - @AppStorage("tracePathIncludeRooms") private var includeRooms = false - @AppStorage("tracePathIncludeDiscovered") private var includeDiscovered = false - - private var filteredNodes: [PickerNode] { - var nodes: [PickerNode] = viewModel.availableRepeaters.map { .contact($0) } - if includeRooms { - nodes += viewModel.availableRooms.map { .contact($0) } - } - if includeDiscovered { - let contactKeys = Set(nodes.compactMap { - if case .contact(let c) = $0 { c.publicKey } else { nil } - }) - nodes += viewModel.discoveredRepeaters - .filter { !contactKeys.contains($0.publicKey) } - .map { .discovered($0) } - } - if showOnlyFavorites { - nodes = nodes.filter { - switch $0 { - case .contact(let c): c.isFavorite - case .discovered: false - } - } - } - return nodes - } var body: some View { List { codeInputSection - availableRepeatersSection + AvailableRepeatersSectionView( + viewModel: viewModel, + recentlyAddedRepeaterID: $recentlyAddedRepeaterID, + addHapticTrigger: $addHapticTrigger + ) outboundPathSection - pathActionsSection - runTraceSection + PathActionsSectionView( + viewModel: viewModel, + showingClearConfirmation: $showingClearConfirmation, + copyHapticTrigger: $copyHapticTrigger + ) + RunTraceSectionView( + viewModel: viewModel, + showJumpToPath: $showJumpToPath + ) Color.clear .frame(height: 1) @@ -119,82 +102,6 @@ struct TracePathListView: View { } } - // MARK: - Repeaters Section - - private var availableRepeatersSection: some View { - Section { - DisclosureGroup(isExpanded: $isRepeatersExpanded) { - Toggle(L10n.Contacts.Contacts.Trace.List.favoritesOnly, isOn: $showOnlyFavorites) - Toggle(L10n.Contacts.Contacts.Trace.List.includeRooms, isOn: $includeRooms) - if !showOnlyFavorites { - Toggle(L10n.Contacts.Contacts.Trace.List.includeDiscovered, isOn: $includeDiscovered) - } - - if filteredNodes.isEmpty { - if showOnlyFavorites { - ContentUnavailableView( - L10n.Contacts.Contacts.Trace.List.NoFavorites.title, - systemImage: "star.slash", - description: Text(L10n.Contacts.Contacts.Trace.List.NoFavorites.description) - ) - } else { - ContentUnavailableView( - L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, - systemImage: "antenna.radiowaves.left.and.right.slash", - description: Text(L10n.Contacts.Contacts.PathEdit.NoRepeaters.description) - ) - } - } else { - ForEach(filteredNodes) { node in - Button { - recentlyAddedRepeaterID = node.id - addHapticTrigger += 1 - viewModel.addNode(node.underlying) - } label: { - HStack { - VStack(alignment: .leading) { - HStack { - Text(node.displayName) - if node.isRoom { - NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.room, color: .orange) - } - if node.isDiscovered { - NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.discovered, color: .blue) - } - } - Text(node.publicKeyHex) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Image(systemName: recentlyAddedRepeaterID == node.id ? "checkmark.circle.fill" : "plus.circle") - .foregroundStyle(recentlyAddedRepeaterID == node.id ? Color.green : Color.accentColor) - .contentTransition(.symbolEffect(.replace)) - } - } - .id(node.id) - .foregroundStyle(.primary) - .accessibilityLabel(L10n.Contacts.Contacts.PathEdit.addToPath(node.displayName)) - } - } - } label: { - HStack { - Text(L10n.Contacts.Contacts.Trace.List.repeaters) - Spacer() - Text("\(filteredNodes.count)") - .foregroundStyle(.secondary) - } - } - .onChange(of: showOnlyFavorites) { _, newValue in - if newValue { - includeDiscovered = false - } - } - } - } - // MARK: - Outbound Path Section private var outboundPathSection: some View { @@ -221,133 +128,4 @@ struct TracePathListView: View { Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } } - - // MARK: - Path Actions Section - - private var pathActionsSection: some View { - Section { - if !viewModel.outboundPath.isEmpty { - Toggle(isOn: $viewModel.autoReturnPath) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.autoReturn) - Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Toggle(isOn: $viewModel.batchEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Contacts.Contacts.Trace.List.batchTrace) - Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if viewModel.batchEnabled { - HStack(spacing: 12) { - Text(L10n.Contacts.Contacts.Trace.List.traces) - .foregroundStyle(.secondary) - Spacer() - BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) - BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) - } - } - - HStack { - Text(viewModel.fullPathString) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - - Spacer() - - Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - viewModel.copyPathToClipboard() - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - } - - Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { - showingClearConfirmation = true - } - .foregroundStyle(.red) - } - } footer: { - if !viewModel.outboundPath.isEmpty { - Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) - } - } - } - - // MARK: - Run Trace Section - - private var runTraceSection: some View { - Section { - HStack { - Spacer() - if viewModel.isRunning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - if viewModel.batchEnabled { - Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) - } else { - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } - .frame(minWidth: 160) - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(.regularMaterial, in: .capsule) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) - } - .accessibilityLabel(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.runningLabel) - .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) - } else { - Button { - Task { - if viewModel.batchEnabled { - await viewModel.runBatchTrace() - } else { - await viewModel.runTrace() - } - } - } label: { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - .frame(minWidth: 160) - .padding(.vertical, 4) - } - .liquidGlassProminentButtonStyle() - .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) - .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) - .accessibilityHint(viewModel.batchEnabled - ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) - : L10n.Contacts.Contacts.Trace.List.singleHint) - } - Spacer() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .id("runTrace") - .onAppear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = false - } - } - .onDisappear { - withAnimation(.easeInOut(duration: 0.2)) { - showJumpToPath = true - } - } - } - .listSectionSeparator(.hidden) - } } diff --git a/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift new file mode 100644 index 000000000..d8292f339 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import MC1Services + +/// Disclosure group section listing available repeaters to add to a trace path +struct AvailableRepeatersSectionView: View { + var viewModel: TracePathViewModel + @Binding var recentlyAddedRepeaterID: UUID? + @Binding var addHapticTrigger: Int + + @State private var isRepeatersExpanded = false + @AppStorage("tracePathShowOnlyFavorites") private var showOnlyFavorites = false + @AppStorage("tracePathIncludeRooms") private var includeRooms = false + @AppStorage("tracePathIncludeDiscovered") private var includeDiscovered = false + + private var filteredNodes: [PickerNode] { + var nodes: [PickerNode] = viewModel.availableRepeaters.map { .contact($0) } + if includeRooms { + nodes += viewModel.availableRooms.map { .contact($0) } + } + if includeDiscovered { + let contactKeys = Set(nodes.compactMap { + if case .contact(let c) = $0 { c.publicKey } else { nil } + }) + nodes += viewModel.discoveredRepeaters + .filter { !contactKeys.contains($0.publicKey) } + .map { .discovered($0) } + } + if showOnlyFavorites { + nodes = nodes.filter { + switch $0 { + case .contact(let c): c.isFavorite + case .discovered: false + } + } + } + return nodes + } + + var body: some View { + let nodes = filteredNodes + Section { + DisclosureGroup(isExpanded: $isRepeatersExpanded) { + Toggle(L10n.Contacts.Contacts.Trace.List.favoritesOnly, isOn: $showOnlyFavorites) + Toggle(L10n.Contacts.Contacts.Trace.List.includeRooms, isOn: $includeRooms) + if !showOnlyFavorites { + Toggle(L10n.Contacts.Contacts.Trace.List.includeDiscovered, isOn: $includeDiscovered) + } + + if nodes.isEmpty { + if showOnlyFavorites { + ContentUnavailableView( + L10n.Contacts.Contacts.Trace.List.NoFavorites.title, + systemImage: "star.slash", + description: Text(L10n.Contacts.Contacts.Trace.List.NoFavorites.description) + ) + } else { + ContentUnavailableView( + L10n.Contacts.Contacts.PathEdit.NoRepeaters.title, + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text(L10n.Contacts.Contacts.PathEdit.NoRepeaters.description) + ) + } + } else { + ForEach(nodes) { node in + Button { + recentlyAddedRepeaterID = node.id + addHapticTrigger += 1 + viewModel.addNode(node.underlying) + } label: { + HStack { + VStack(alignment: .leading) { + HStack { + Text(node.displayName) + if node.isRoom { + NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.room, color: .orange) + } + if node.isDiscovered { + NodeKindBadge(text: L10n.Contacts.Contacts.NodeKind.discovered, color: .blue) + } + } + Text(node.publicKeyHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Image(systemName: recentlyAddedRepeaterID == node.id ? "checkmark.circle.fill" : "plus.circle") + .foregroundStyle(recentlyAddedRepeaterID == node.id ? Color.green : Color.accentColor) + .contentTransition(.symbolEffect(.replace)) + } + } + .id(node.id) + .foregroundStyle(.primary) + .accessibilityLabel(L10n.Contacts.Contacts.PathEdit.addToPath(node.displayName)) + } + } + } label: { + HStack { + Text(L10n.Contacts.Contacts.Trace.List.repeaters) + Spacer() + Text("\(nodes.count)") + .foregroundStyle(.secondary) + } + } + .onChange(of: showOnlyFavorites) { _, newValue in + if newValue { + includeDiscovered = false + } + } + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift b/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift new file mode 100644 index 000000000..cf0f29fa4 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import MC1Services + +/// Section with path configuration toggles, copy, and clear actions +struct PathActionsSectionView: View { + @Bindable var viewModel: TracePathViewModel + @Binding var showingClearConfirmation: Bool + @Binding var copyHapticTrigger: Int + + var body: some View { + Section { + if !viewModel.outboundPath.isEmpty { + Toggle(isOn: $viewModel.autoReturnPath) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.autoReturn) + Text(L10n.Contacts.Contacts.Trace.List.autoReturnDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Toggle(isOn: $viewModel.batchEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Contacts.Contacts.Trace.List.batchTrace) + Text(L10n.Contacts.Contacts.Trace.List.batchTraceDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if viewModel.batchEnabled { + HStack(spacing: 12) { + Text(L10n.Contacts.Contacts.Trace.List.traces) + .foregroundStyle(.secondary) + Spacer() + BatchSizeChip(size: 3, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 5, selectedSize: $viewModel.batchSize) + BatchSizeChip(size: 10, selectedSize: $viewModel.batchSize) + } + } + + HStack { + Text(viewModel.fullPathString) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Spacer() + + Button(L10n.Contacts.Contacts.Trace.List.copyPath, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + viewModel.copyPathToClipboard() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + + Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { + showingClearConfirmation = true + } + } + } footer: { + if !viewModel.outboundPath.isEmpty { + Text(L10n.Contacts.Contacts.Trace.List.rangeWarning) + } + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift b/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift deleted file mode 100644 index 3880da2f5..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift +++ /dev/null @@ -1,72 +0,0 @@ -import MapKit -import MC1Services - -/// Custom polyline overlay that carries signal quality data for styling -/// Note: All properties are immutable - create new overlay instances when signal quality changes -final class PathLineOverlay: MKPolyline { - - /// Signal quality - nil means untraced (pre-trace dashed gray line) - private(set) var signalQuality: SNRQuality? - - /// Distance in meters between the two endpoints - immutable after creation - private(set) var distanceMeters: Double = 0 - - /// SNR value in dB - immutable after creation - private(set) var snr: Double = 0 - - /// Index of this segment in the path (0 = user to first hop) - immutable after creation - private(set) var segmentIndex: Int = 0 - - /// Start coordinate for this segment - private(set) var startCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// End coordinate for this segment - private(set) var endCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// Create overlay between two coordinates - static func line( - from start: CLLocationCoordinate2D, - to end: CLLocationCoordinate2D, - segmentIndex: Int, - signalQuality: SNRQuality? = nil, - snr: Double = 0 - ) -> PathLineOverlay { - var coords = [start, end] - let overlay = PathLineOverlay(coordinates: &coords, count: 2) - overlay.segmentIndex = segmentIndex - overlay.signalQuality = signalQuality - overlay.snr = snr - overlay.startCoordinate = start - overlay.endCoordinate = end - - // Calculate distance - let startLocation = CLLocation(latitude: start.latitude, longitude: start.longitude) - let endLocation = CLLocation(latitude: end.latitude, longitude: end.longitude) - overlay.distanceMeters = startLocation.distance(from: endLocation) - - return overlay - } - - /// Create a new overlay with updated signal quality (immutable pattern) - func withSignalQuality(_ quality: SNRQuality, snr: Double) -> PathLineOverlay { - PathLineOverlay.line( - from: startCoordinate, - to: endCoordinate, - segmentIndex: segmentIndex, - signalQuality: quality, - snr: snr - ) - } - - /// Midpoint coordinate for placing stats badge - var midpoint: CLLocationCoordinate2D { - guard pointCount >= 2 else { return coordinate } - let points = self.points() - let start = points[0] - let end = points[1] - return CLLocationCoordinate2D( - latitude: (start.coordinate.latitude + end.coordinate.latitude) / 2, - longitude: (start.coordinate.longitude + end.coordinate.longitude) / 2 - ) - } -} diff --git a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift b/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift deleted file mode 100644 index ff6d4a8f8..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift +++ /dev/null @@ -1,42 +0,0 @@ -import MapKit -import UIKit - -/// Renderer for PathLineOverlay that draws dashed or solid colored lines -/// Note: Since PathLineOverlay is immutable, create new overlays when signal quality changes -/// rather than calling updateAppearance on existing renderers -final class PathLineRenderer: MKPolylineRenderer { - - override init(overlay: any MKOverlay) { - super.init(overlay: overlay) - configureAppearance() - } - - private func configureAppearance() { - guard let pathOverlay = overlay as? PathLineOverlay else { return } - - guard let quality = pathOverlay.signalQuality else { - // Untraced — dashed gray - strokeColor = UIColor.systemGray - lineWidth = 2 - lineDashPattern = [8, 6] - return - } - - strokeColor = quality.uiColor - - switch quality { - case .excellent, .good: - lineWidth = 4 - lineDashPattern = nil - case .fair: - lineWidth = 3 - lineDashPattern = [12, 4] - case .poor: - lineWidth = 3 - lineDashPattern = [4, 4] - case .unknown: - lineWidth = 2 - lineDashPattern = [8, 6] - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift b/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift new file mode 100644 index 000000000..448c93e62 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import MC1Services + +/// Section with the run trace button and running state indicator +struct RunTraceSectionView: View { + @Environment(\.appState) private var appState + var viewModel: TracePathViewModel + @Binding var showJumpToPath: Bool + + var body: some View { + Section { + HStack { + Spacer() + if viewModel.isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + if viewModel.batchEnabled { + Text(L10n.Contacts.Contacts.Trace.List.runningBatch(viewModel.currentTraceIndex, viewModel.batchSize)) + } else { + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) + } + } + .frame(minWidth: 160) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.regularMaterial, in: .capsule) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + } + .accessibilityLabel(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.runningBatchLabel(viewModel.currentTraceIndex, viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.runningLabel) + .accessibilityHint(L10n.Contacts.Contacts.Trace.List.runningHint) + } else { + Button { + Task { + if viewModel.batchEnabled { + await viewModel.runBatchTrace() + } else { + await viewModel.runTrace() + } + } + } label: { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + .frame(minWidth: 160) + .padding(.vertical, 4) + } + .liquidGlassProminentButtonStyle() + .radioDisabled(for: appState.connectionState, or: !viewModel.canRunTraceWhenConnected) + .accessibilityLabel(L10n.Contacts.Contacts.Trace.List.runTraceLabel) + .accessibilityHint(viewModel.batchEnabled + ? L10n.Contacts.Contacts.Trace.List.batchHint(viewModel.batchSize) + : L10n.Contacts.Contacts.Trace.List.singleHint) + } + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .id("runTrace") + .onAppear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = false + } + } + .onDisappear { + withAnimation(.easeInOut(duration: 0.2)) { + showJumpToPath = true + } + } + } + .listSectionSeparator(.hidden) + } +} diff --git a/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift b/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift deleted file mode 100644 index a53ef4d7f..000000000 --- a/MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift +++ /dev/null @@ -1,37 +0,0 @@ -import MapKit - -/// Annotation for displaying stats badge at path segment midpoint -final class StatsBadgeAnnotation: NSObject, MKAnnotation { - let coordinate: CLLocationCoordinate2D - let distanceMeters: Double - let snr: Double - let segmentIndex: Int - - init(coordinate: CLLocationCoordinate2D, distanceMeters: Double, snr: Double, segmentIndex: Int) { - self.coordinate = coordinate - self.distanceMeters = distanceMeters - self.snr = snr - self.segmentIndex = segmentIndex - super.init() - } - - /// Formatted distance string (e.g., "1.2 mi" or "500 m") - var distanceString: String { - let miles = distanceMeters / 1609.34 - if miles >= 0.1 { - return "\(miles.formatted(.number.precision(.fractionLength(1)))) mi" - } else { - return "\(distanceMeters.formatted(.number.precision(.fractionLength(0)))) m" - } - } - - /// Formatted SNR string (e.g., "8 dB") - var snrString: String { - "\(snr.formatted(.number.precision(.fractionLength(0)))) dB" - } - - /// Combined display string - var displayString: String { - "\(distanceString) • \(snrString)" - } -} diff --git a/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift b/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift deleted file mode 100644 index c37630450..000000000 --- a/MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift +++ /dev/null @@ -1,89 +0,0 @@ -import MapKit -import UIKit - -/// Annotation view displaying stats badge with liquid glass styling -final class StatsBadgeView: MKAnnotationView { - static let reuseIdentifier = "StatsBadgeView" - - private let containerView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - private let label = UILabel() - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupViews() { - // Container with blur - containerView.translatesAutoresizingMaskIntoConstraints = false - containerView.layer.cornerRadius = 12 - containerView.layer.masksToBounds = true - addSubview(containerView) - - // Shadow - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.2 - layer.shadowRadius = 4 - layer.shadowOffset = CGSize(width: 0, height: 2) - - // Label with Dynamic Type - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - containerView.contentView.addSubview(label) - - NSLayoutConstraint.activate([ - containerView.topAnchor.constraint(equalTo: topAnchor), - containerView.bottomAnchor.constraint(equalTo: bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: trailingAnchor), - - label.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), - label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -6), - label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10), - label.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10) - ]) - - canShowCallout = false - isEnabled = false - } - - func configure(with annotation: StatsBadgeAnnotation) { - label.text = annotation.displayString - - // Size to fit content - label.sizeToFit() - let size = CGSize( - width: label.frame.width + 20, - height: label.frame.height + 12 - ) - frame = CGRect(origin: .zero, size: size) - centerOffset = CGPoint(x: 0, y: 0) - - // Accessibility - isAccessibilityElement = true - accessibilityLabel = "Distance: \(annotation.distanceString), Signal: \(Int(annotation.snr)) decibels" - accessibilityTraits = .staticText - } - - override func prepareForReuse() { - super.prepareForReuse() - label.text = nil - accessibilityLabel = nil - } - - override func prepareForDisplay() { - super.prepareForDisplay() - if let statsAnnotation = annotation as? StatsBadgeAnnotation { - configure(with: statsAnnotation) - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift b/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift deleted file mode 100644 index 3aaf0f5a8..000000000 --- a/MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift +++ /dev/null @@ -1,86 +0,0 @@ -import MapKit -import UIKit - -/// Cluster annotation view for grouped repeater pins -final class TracePathClusterView: MKAnnotationView { - - private let countLabel = UILabel() - private let circleView = UIView() - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupViews() { - let size: CGFloat = 32 - - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = size / 2 - circleView.layer.borderColor = UIColor.white.cgColor - circleView.layer.borderWidth = 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - countLabel.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, - weight: .bold - ) - countLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: baseFont) - countLabel.adjustsFontForContentSizeCategory = true - countLabel.textColor = .white - countLabel.textAlignment = .center - circleView.addSubview(countLabel) - - NSLayoutConstraint.activate([ - circleView.widthAnchor.constraint(equalToConstant: size), - circleView.heightAnchor.constraint(equalToConstant: size), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.centerYAnchor.constraint(equalTo: centerYAnchor), - - countLabel.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - countLabel.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - frame = CGRect(x: 0, y: 0, width: size, height: size) - centerOffset = .zero - canShowCallout = false - - displayPriority = .defaultHigh - collisionMode = .circle - } - - func configure(with clusterAnnotation: MKClusterAnnotation) { - let count = clusterAnnotation.memberAnnotations.count - countLabel.text = "\(count)" - - isAccessibilityElement = true - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Cluster.label(count) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Cluster.hint - accessibilityTraits = .button - } - - override func prepareForReuse() { - super.prepareForReuse() - countLabel.text = nil - accessibilityLabel = nil - accessibilityHint = nil - } - - override func prepareForDisplay() { - super.prepareForDisplay() - if let cluster = annotation as? MKClusterAnnotation { - configure(with: cluster) - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift b/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift new file mode 100644 index 000000000..fcfcfb5ff --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import MC1Services + +/// Floating action buttons for trace path map (clear, run trace, view results) +struct TracePathFloatingButtonsView: View { + var mapViewModel: TracePathMapViewModel + @Binding var showingClearConfirmation: Bool + @Binding var presentedResult: TraceResult? + var buttonNamespace: Namespace.ID + + var body: some View { + VStack { + Spacer() + + LiquidGlassContainer(spacing: 12) { + HStack(spacing: 12) { + if mapViewModel.hasPath { + // Clear button + Button { + showingClearConfirmation = true + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.clear) + } + .liquidGlassButtonStyle() + .liquidGlassID("clear", in: buttonNamespace) + .confirmationDialog( + L10n.Contacts.Contacts.Trace.clearPath, + isPresented: $showingClearConfirmation, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { + mapViewModel.clearPath() + } + } message: { + Text(L10n.Contacts.Contacts.Trace.clearPathMessage) + } + + // Run Trace button + Button { + Task { + await mapViewModel.runTrace() + } + } label: { + if mapViewModel.isRunning { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Trace.List.runningTrace) + } + } else { + Text(L10n.Contacts.Contacts.Trace.List.runTrace) + } + } + .liquidGlassProminentButtonStyle() + .liquidGlassID("trace", in: buttonNamespace) + .disabled(!mapViewModel.canRunTrace) + + // View Results button + if let result = mapViewModel.result, result.success { + Button { + presentedResult = result + } label: { + Text(L10n.Contacts.Contacts.Trace.Map.viewResults) + } + .liquidGlassButtonStyle() + .liquidGlassID("viewResults", in: buttonNamespace) + } + } + } + } + .animation(.spring(response: 0.3), value: mapViewModel.hasPath) + .animation(.spring(response: 0.3), value: mapViewModel.result?.id) + .padding(.bottom, 24) + } + } +} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift deleted file mode 100644 index c37c93b6f..000000000 --- a/MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift +++ /dev/null @@ -1,316 +0,0 @@ -import MapKit -import SwiftUI -import MC1Services - -/// UIViewRepresentable for trace path map with custom overlays and interactions -struct TracePathMKMapView: UIViewRepresentable { - let repeaters: [ContactDTO] - let lineOverlays: [PathLineOverlay] - let badgeAnnotations: [StatsBadgeAnnotation] - let mapType: MKMapType - let showLabels: Bool - - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - - // Pre-computed path membership for all repeaters (closure to defer computation to updateUIView) - let pathState: () -> [UUID: TracePathMapViewModel.RepeaterPathInfo] - let onRepeaterTap: (ContactDTO) -> Void - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - mapView.showsUserLocation = true - - // Register annotation views - mapView.register( - TracePathRepeaterPinView.self, - forAnnotationViewWithReuseIdentifier: TracePathRepeaterPinView.reuseIdentifier - ) - mapView.register( - TracePathClusterView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - mapView.register( - StatsBadgeView.self, - forAnnotationViewWithReuseIdentifier: StatsBadgeView.reuseIdentifier - ) - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Update callbacks and state - let pathState = pathState() - coordinator.pathState = pathState - coordinator.onRepeaterTap = onRepeaterTap - coordinator.showLabels = showLabels - - // Update map type - mapView.mapType = mapType - - // Update repeater annotations - updateRepeaterAnnotations(in: mapView, coordinator: coordinator, pathState: pathState) - - // Update overlays (with change detection) - updateOverlays(in: mapView, coordinator: coordinator) - - // Update badge annotations (with change detection) - updateBadgeAnnotations(in: mapView, coordinator: coordinator) - - // Update region only when explicitly requested (version changed) - if cameraRegionVersion != coordinator.lastAppliedRegionVersion, - let region = cameraRegion { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - coordinator.hasPendingProgrammaticRegion = true - mapView.setRegion(region, animated: coordinator.lastAppliedRegion != nil) - coordinator.lastAppliedRegion = region - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(setCameraRegion: { cameraRegion = $0 }) - } - - // MARK: - Annotation Updates - - private func updateRepeaterAnnotations( - in mapView: MKMapView, - coordinator: Coordinator, - pathState: [UUID: TracePathMapViewModel.RepeaterPathInfo] - ) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? RepeaterAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.repeater.id }) - let newIDs = Set(repeaters.map { $0.id }) - - // Remove old - let toRemove = currentAnnotations.filter { !newIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toRemove) - - // Add new - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.repeater.id })) - let toAdd = repeaters.filter { !existingIDs.contains($0.id) } - .map { RepeaterAnnotation(repeater: $0) } - mapView.addAnnotations(toAdd) - - // Determine which annotations changed path membership and need re-adding - // (MapKit doesn't pick up clusteringIdentifier changes on existing views) - let currentInPathIDs = Set(pathState.filter { $0.value.inPath }.map { $0.key }) - let previousInPathIDs = coordinator.previousInPathIDs - let changedIDs = currentInPathIDs.symmetricDifference(previousInPathIDs) - coordinator.previousInPathIDs = currentInPathIDs - - if !changedIDs.isEmpty { - let toReAdd = mapView.annotations - .compactMap { $0 as? RepeaterAnnotation } - .filter { changedIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toReAdd) - mapView.addAnnotations(toReAdd) - } - - // Update visible pin views using pre-computed state - for annotation in mapView.annotations.compactMap({ $0 as? RepeaterAnnotation }) { - guard let view = mapView.view(for: annotation) as? TracePathRepeaterPinView else { - continue - } - let info = pathState[annotation.repeater.id] ?? TracePathMapViewModel.RepeaterPathInfo( - inPath: false, hopIndex: nil, isLastHop: false - ) - view.configure( - for: annotation.repeater, - inPath: info.inPath, - hopIndex: info.hopIndex, - isLastHop: info.isLastHop, - showLabel: showLabels - ) - } - } - - private func updateOverlays(in mapView: MKMapView, coordinator: Coordinator) { - let newIdentities = Set(lineOverlays.map { ObjectIdentifier($0) }) - - guard newIdentities != coordinator.lastOverlayIdentities else { return } - coordinator.lastOverlayIdentities = newIdentities - - let existingPathOverlays = mapView.overlays.compactMap { $0 as? PathLineOverlay } - mapView.removeOverlays(existingPathOverlays) - mapView.addOverlays(lineOverlays) - } - - private func updateBadgeAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let newIdentities = Set(badgeAnnotations.map { ObjectIdentifier($0) }) - - guard newIdentities != coordinator.lastBadgeIdentities else { return } - coordinator.lastBadgeIdentities = newIdentities - - let existingBadges = mapView.annotations.compactMap { $0 as? StatsBadgeAnnotation } - mapView.removeAnnotations(existingBadges) - mapView.addAnnotations(badgeAnnotations) - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate { - var setCameraRegion: (MKCoordinateRegion?) -> Void - - var pathState: [UUID: TracePathMapViewModel.RepeaterPathInfo] = [:] - var onRepeaterTap: ((ContactDTO) -> Void)? - var showLabels: Bool = true - - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var lastAppliedRegionVersion = -1 - var hasPendingProgrammaticRegion = false - - // Change detection state - var previousInPathIDs: Set = [] - var lastOverlayIdentities: Set = [] - var lastBadgeIdentities: Set = [] - - /// Tracks whether the initial MKMapView region change has been received. - /// The first region change is from MKMapView initialization, not a user gesture. - private var hasReceivedInitialRegion = false - - /// Pending region update task for cancellation - private var pendingRegionTask: Task? - - lazy var mapView: MKMapView = NoDoubleTapMapView() - - init(setCameraRegion: @escaping (MKCoordinateRegion?) -> Void) { - self.setCameraRegion = setCameraRegion - } - - deinit { - pendingRegionTask?.cancel() - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - if annotation is MKUserLocation { - return nil - } - - if let clusterAnnotation = annotation as? MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? TracePathClusterView ?? TracePathClusterView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.configure(with: clusterAnnotation) - return view - } - - if let repeaterAnnotation = annotation as? RepeaterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: TracePathRepeaterPinView.reuseIdentifier, - for: annotation - ) as? TracePathRepeaterPinView ?? TracePathRepeaterPinView( - annotation: annotation, - reuseIdentifier: TracePathRepeaterPinView.reuseIdentifier - ) - - let info = pathState[repeaterAnnotation.repeater.id] - ?? TracePathMapViewModel.RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - - view.configure( - for: repeaterAnnotation.repeater, - inPath: info.inPath, - hopIndex: info.hopIndex, - isLastHop: info.isLastHop, - showLabel: showLabels - ) - - view.onTap = { [weak self] in - self?.onRepeaterTap?(repeaterAnnotation.repeater) - } - - return view - } - - if let badgeAnnotation = annotation as? StatsBadgeAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: StatsBadgeView.reuseIdentifier, - for: annotation - ) as? StatsBadgeView ?? StatsBadgeView( - annotation: annotation, - reuseIdentifier: StatsBadgeView.reuseIdentifier - ) - view.configure(with: badgeAnnotation) - return view - } - - return nil - } - - func mapView(_ mapView: MKMapView, rendererFor overlay: any MKOverlay) -> MKOverlayRenderer { - if let pathOverlay = overlay as? PathLineOverlay { - return PathLineRenderer(overlay: pathOverlay) - } - return MKOverlayRenderer(overlay: overlay) - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - mapView.deselectAnnotation(annotation, animated: false) - - if let cluster = annotation as? MKClusterAnnotation { - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - // Repeater taps are handled by UITapGestureRecognizer on the pin view - // to bypass MapKit's ~300ms selection delay - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { return } - - if hasPendingProgrammaticRegion { - hasPendingProgrammaticRegion = false - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - // The first region change is from MKMapView initialization, not a user gesture. - // Don't block programmatic updates during this initial phase. - if !hasReceivedInitialRegion { - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - lastAppliedRegion = mapView.region - - // Debounce region sync back to SwiftUI to avoid update cascade during panning - pendingRegionTask?.cancel() - pendingRegionTask = Task { @MainActor in - guard !Task.isCancelled else { return } - self.setCameraRegion(mapView.region) - } - } - } -} - -// MARK: - Repeater Annotation - -final class RepeaterAnnotation: NSObject, MKAnnotation { - let repeater: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: repeater.latitude, longitude: repeater.longitude) - } - - var title: String? { repeater.displayName } - - init(repeater: ContactDTO) { - self.repeater = repeater - super.init() - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift new file mode 100644 index 000000000..165974fff --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -0,0 +1,65 @@ +import MapKit +import MapLibre +import SwiftUI +import MC1Services + +/// Map controls toolbar for trace path map view (location, labels, layers) +struct TracePathMapToolbarView: View { + @Environment(\.appState) private var appState + @Bindable var mapViewModel: TracePathMapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + if let location = appState.bestAvailableLocation { + mapViewModel.setCameraRegion(MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )) + } else { + appState.locationService.requestLocation() + } + }, + showingLayersMenu: $mapViewModel.showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $mapViewModel.isNorthLocked) + } + ) { + LabelsToggleButton(showLabels: $showLabels) + + // Center on path + if mapViewModel.hasPath { + Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { + mapViewModel.centerOnPath() + } + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } + } + } + } + .overlay(alignment: .bottomTrailing) { + if mapViewModel.showingLayersMenu { + LayersMenu( + selection: $mapStyleSelection, + isPresented: $mapViewModel.showingLayersMenu, + viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 16) + .padding(.bottom, 160) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } +} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 56963689b..d81da5783 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -8,8 +8,11 @@ private let logger = Logger(subsystem: "com.mc1", category: "TracePathMapView") /// Map-based view for building and visualizing trace paths struct TracePathMapView: View { @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme @Bindable var traceViewModel: TracePathViewModel @Binding var presentedResult: TraceResult? + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage("mapShowLabels") private var showLabels = true @State private var mapViewModel = TracePathMapViewModel() @State private var showingSavePrompt = false @@ -28,34 +31,51 @@ struct TracePathMapView: View { // Results banner at top if let result = mapViewModel.result, result.success { - resultsBanner(result: result) + TracePathResultsBanner( + result: result, + totalPathDistance: traceViewModel.totalPathDistance + ) } // Empty state if mapViewModel.repeatersWithLocation.isEmpty { - emptyState + TracePathEmptyState() } // Floating buttons - floatingButtons + TracePathFloatingButtonsView( + mapViewModel: mapViewModel, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + buttonNamespace: buttonNamespace + ) // Map controls toolbar - mapToolbar + TracePathMapToolbarView( + mapViewModel: mapViewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels + ) } .onAppear { mapViewModel.configure( traceViewModel: traceViewModel, userLocation: appState.bestAvailableLocation ) + mapViewModel.showLabels = showLabels mapViewModel.rebuildOverlays() mapViewModel.performInitialCentering() } + .onChange(of: showLabels) { _, newValue in + mapViewModel.showLabels = newValue + } .onChange(of: appState.bestAvailableLocation) { old, new in guard old?.coordinate.latitude != new?.coordinate.latitude || old?.coordinate.longitude != new?.coordinate.longitude else { return } mapViewModel.updateUserLocation(new) } .onChange(of: traceViewModel.availableNodes) { _, _ in + mapViewModel.rebuildPathState() if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { mapViewModel.performInitialCentering() } @@ -99,39 +119,56 @@ struct TracePathMapView: View { // MARK: - Map Content private var mapContent: some View { - TracePathMKMapView( - repeaters: mapViewModel.repeatersWithLocation, - lineOverlays: mapViewModel.lineOverlays, - badgeAnnotations: mapViewModel.badgeAnnotations, - mapType: mapViewModel.mapType, - showLabels: mapViewModel.showLabels, + MC1MapView( + points: mapViewModel.mapPoints, + lines: mapViewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: mapViewModel.isNorthLocked, cameraRegion: $mapViewModel.cameraRegion, cameraRegionVersion: mapViewModel.cameraRegionVersion, - pathState: { mapViewModel.pathState }, - onRepeaterTap: { repeater in - let result = mapViewModel.handleRepeaterTap(repeater) - if result == .rejectedMiddleHop { - rejectedTapHaptic += 1 - } else { - pinTapHaptic += 1 + cameraBottomSheetFraction: 0, + onPointTap: { point, _ in + if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + let result = mapViewModel.handleRepeaterTap(repeater) + if result == .rejectedMiddleHop { + rejectedTapHaptic += 1 + } else { + pinTapHaptic += 1 + } } - } + }, + onMapTap: nil, + onCameraRegionChange: { region in + mapViewModel.cameraRegion = region + }, ) .ignoresSafeArea() } - // MARK: - Results Banner +} + +// MARK: - Results Banner - private func resultsBanner(result: TraceResult) -> some View { +private struct TracePathResultsBanner: View { + let result: TraceResult + let totalPathDistance: Double? + + var body: some View { VStack { HStack { let hopCount = result.hops.count - 2 Text(L10n.Contacts.Contacts.Trace.Map.hops(hopCount)) - if let distance = traceViewModel.totalPathDistance { + if let distance = totalPathDistance { Text("•") - let miles = distance / 1609.34 - Text("\(miles, format: .number.precision(.fractionLength(1))) mi") + Text(Measurement(value: distance, unit: UnitLength.meters), + format: .measurement(width: .abbreviated, usage: .road)) } } .font(.subheadline.weight(.medium)) @@ -141,14 +178,16 @@ struct TracePathMapView: View { Spacer() } - .padding(.top, 8) + .safeAreaPadding(.top) .transition(.move(edge: .top).combined(with: .opacity)) .animation(.spring(response: 0.3), value: result.id) } +} - // MARK: - Empty State +// MARK: - Empty State - private var emptyState: some View { +private struct TracePathEmptyState: View { + var body: some View { VStack { Spacer() ContentUnavailableView( @@ -156,140 +195,9 @@ struct TracePathMapView: View { systemImage: "map", description: Text(L10n.Contacts.Contacts.Trace.Map.Empty.description) ) - Spacer() - } - .background(.regularMaterial) - } - - // MARK: - Floating Buttons - - private var floatingButtons: some View { - VStack { - Spacer() - - LiquidGlassContainer(spacing: 12) { - HStack(spacing: 12) { - if mapViewModel.hasPath { - // Clear button - Button { - showingClearConfirmation = true - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.clear) - } - .liquidGlassButtonStyle() - .liquidGlassID("clear", in: buttonNamespace) - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - mapViewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - - // Run Trace button - Button { - Task { - await mapViewModel.runTrace() - } - } label: { - if mapViewModel.isRunning { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } else { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - } - } - .liquidGlassProminentButtonStyle() - .liquidGlassID("trace", in: buttonNamespace) - .disabled(!mapViewModel.canRunTrace) - - // View Results button - if let result = mapViewModel.result, result.success { - Button { - presentedResult = result - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.viewResults) - } - .liquidGlassButtonStyle() - .liquidGlassID("viewResults", in: buttonNamespace) - } - } - } - } - .animation(.spring(response: 0.3), value: mapViewModel.hasPath) - .animation(.spring(response: 0.3), value: mapViewModel.result?.id) - .padding(.bottom, 24) - } - } - - // MARK: - Map Toolbar - - private var mapToolbar: some View { - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - if let location = appState.bestAvailableLocation { - mapViewModel.cameraRegion = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - mapViewModel.cameraRegionVersion += 1 - } else { - appState.locationService.requestLocation() - } - }, - showingLayersMenu: $mapViewModel.showingLayersMenu - ) { - // Labels toggle - Button { - mapViewModel.showLabels.toggle() - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(mapViewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(mapViewModel.showLabels ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels) - - // Center on path - if mapViewModel.hasPath { - Button { - mapViewModel.centerOnPath() - } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - } - } - } - } - .overlay(alignment: .bottomTrailing) { - if mapViewModel.showingLayersMenu { - LayersMenu( - selection: $mapViewModel.mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) - } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 16)) + .padding() } - .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) } } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 31d1d5ce4..43bb1b6f4 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -14,32 +14,27 @@ final class TracePathMapViewModel { var cameraRegion: MKCoordinateRegion? /// Incremented when code intentionally moves the camera (not from user gesture sync) - var cameraRegionVersion = 0 - var mapStyleSelection: MapStyleSelection = .standard - var showLabels: Bool = true + private(set) var cameraRegionVersion = 0 + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + var isNorthLocked = false var showingLayersMenu: Bool = false /// Tracks whether initial centering on repeaters has been performed private(set) var hasInitiallyCenteredOnRepeaters = false - /// MKMapType for UIKit map view - var mapType: MKMapType { - switch mapStyleSelection { - case .standard: .standard - case .satellite: .satellite - case .hybrid: .hybrid - } - } - // MARK: - Path Overlays - private(set) var lineOverlays: [PathLineOverlay] = [] - private(set) var badgeAnnotations: [StatsBadgeAnnotation] = [] + private(set) var mapLines: [MapLine] = [] + private(set) var badgePoints: [MapPoint] = [] + private(set) var mapPoints: [MapPoint] = [] // MARK: - Dependencies private weak var traceViewModel: TracePathViewModel? private var userLocation: CLLocation? + private var lastRebuildLocation: CLLocation? // MARK: - Path State @@ -50,33 +45,8 @@ final class TracePathMapViewModel { } /// Pre-computed path membership for all repeaters, keyed by repeater ID. - /// Iterates the path once (O(M) resolutions) then does O(N) dictionary lookups, - /// instead of O(N × M × N) per-repeater closure calls. - var pathState: [UUID: RepeaterPathInfo] { - let repeaters = repeatersWithLocation - - // Build path lookup: resolve each hop to a repeater UUID - var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] - if let path = traceViewModel?.outboundPath { - for (index, hop) in path.enumerated() { - if let repeater = findRepeater(for: hop) { - pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) - } - } - } - - // Build state for all repeaters with O(1) lookups - var state: [UUID: RepeaterPathInfo] = [:] - state.reserveCapacity(repeaters.count) - for repeater in repeaters { - if let info = pathLookup[repeater.id] { - state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) - } else { - state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) - } - } - return state - } + /// Stored to avoid reallocation on every body eval. Rebuilt via `rebuildPathState()`. + private(set) var pathState: [UUID: RepeaterPathInfo] = [:] // MARK: - Computed Properties @@ -119,32 +89,67 @@ final class TracePathMapViewModel { func updateUserLocation(_ location: CLLocation?) { self.userLocation = location + + // Only rebuild if the path is non-empty and user moved meaningfully + guard traceViewModel?.outboundPath.isEmpty == false else { return } + if let location, let last = lastRebuildLocation, location.distance(from: last) < 10 { return } + lastRebuildLocation = location rebuildOverlays() } - // MARK: - Path Building + // MARK: - Path State Rebuild - /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. - private func findRepeater(for hop: PathHop) -> ContactDTO? { - RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) - } + /// Rebuilds stored `pathState` and `mapPoints`. Call when path, available nodes, or user location changes. + func rebuildPathState() { + let repeaters = repeatersWithLocation - /// Whether a hop matches a specific repeater. - private func hopMatches(_ hop: PathHop, repeater: ContactDTO) -> Bool { - findRepeater(for: hop)?.publicKey == repeater.publicKey + var pathLookup: [UUID: (index: Int, isLast: Bool)] = [:] + if let path = traceViewModel?.outboundPath { + for (index, hop) in path.enumerated() { + if let repeater = findRepeater(for: hop) { + pathLookup[repeater.id] = (index: index + 1, isLast: index == path.count - 1) + } + } + } + + var state: [UUID: RepeaterPathInfo] = [:] + state.reserveCapacity(repeaters.count) + for repeater in repeaters { + if let info = pathLookup[repeater.id] { + state[repeater.id] = RepeaterPathInfo(inPath: true, hopIndex: info.index, isLastHop: info.isLast) + } else { + state[repeater.id] = RepeaterPathInfo(inPath: false, hopIndex: nil, isLastHop: false) + } + } + pathState = state + rebuildMapPoints(repeaters: repeaters) } - /// Check if a repeater is currently in the path - func isRepeaterInPath(_ repeater: ContactDTO) -> Bool { - guard let path = traceViewModel?.outboundPath else { return false } - return path.contains { hopMatches($0, repeater: repeater) } + private func rebuildMapPoints(repeaters: [ContactDTO]? = nil) { + let nodes = repeaters ?? repeatersWithLocation + var points: [MapPoint] = [] + for repeater in nodes { + let info = pathState[repeater.id] + let inPath = info?.inPath ?? false + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: inPath ? .repeaterRingWhite : .repeater, + label: showLabels ? repeater.displayName : nil, + isClusterable: false, + hopIndex: info?.hopIndex, + badgeText: nil + )) + } + points.append(contentsOf: badgePoints) + mapPoints = points } - /// Check if repeater is the last hop (can be removed) - func isLastHop(_ repeater: ContactDTO) -> Bool { - guard let path = traceViewModel?.outboundPath, - let lastHop = path.last else { return false } - return hopMatches(lastHop, repeater: repeater) + // MARK: - Path Building + + /// Find the repeater or room for a hop using full public key or RepeaterResolver fallback. + private func findRepeater(for hop: PathHop) -> ContactDTO? { + RepeaterResolver.bestMatch(for: hop, in: traceViewModel?.availableNodes ?? [], userLocation: userLocation) } enum RepeaterTapResult { @@ -159,19 +164,17 @@ final class TracePathMapViewModel { func handleRepeaterTap(_ repeater: ContactDTO) -> RepeaterTapResult { guard let traceViewModel else { return .ignored } + let info = pathState[repeater.id] let result: RepeaterTapResult - if isLastHop(repeater) { - // Remove last hop + if info?.isLastHop == true { if let lastIndex = traceViewModel.outboundPath.indices.last { traceViewModel.removeRepeater(at: lastIndex) } result = .removed - } else if !isRepeaterInPath(repeater) { - // Add to path + } else if info?.inPath != true { traceViewModel.addNode(repeater) result = .added } else { - // Tapping middle hop - provide feedback that this action is not allowed result = .rejectedMiddleHop } @@ -183,6 +186,7 @@ final class TracePathMapViewModel { func clearPath() { traceViewModel?.clearPath() clearOverlays() + rebuildPathState() } // MARK: - Trace Execution @@ -198,101 +202,120 @@ final class TracePathMapViewModel { } func generatePathName() -> String { - traceViewModel?.generatePathName() ?? "Path" + traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName } // MARK: - Overlay Management - /// Rebuild line overlays and badge annotations based on current path + /// Rebuild map lines based on current path func rebuildOverlays() { clearOverlays() + rebuildPathState() guard let traceViewModel, !traceViewModel.outboundPath.isEmpty else { return } - // Start from user location or default var previousCoordinate: CLLocationCoordinate2D? if let userLocation { previousCoordinate = userLocation.coordinate } - // Build overlays for each hop for (index, hop) in traceViewModel.outboundPath.enumerated() { - // Find repeater location guard let repeater = findRepeater(for: hop), - repeater.hasLocation else { - logger.warning("Hop \(index) has no location data, skipping line segment") - continue - } + repeater.hasLocation else { continue } let hopCoordinate = CLLocationCoordinate2D( latitude: repeater.latitude, longitude: repeater.longitude ) - // Validate coordinate - guard CLLocationCoordinate2DIsValid(hopCoordinate) else { - logger.warning("Invalid coordinate for hop \(index): (\(repeater.latitude), \(repeater.longitude))") - continue - } + guard CLLocationCoordinate2DIsValid(hopCoordinate) else { continue } - // Create line from previous point if let prevCoord = previousCoordinate, CLLocationCoordinate2DIsValid(prevCoord) { - let overlay = PathLineOverlay.line( - from: prevCoord, - to: hopCoordinate, - segmentIndex: index - ) - lineOverlays.append(overlay) + mapLines.append(MapLine( + id: "trace-\(index)", + coordinates: [prevCoord, hopCoordinate], + style: .traceUntraced, + opacity: 1.0, + pathIndex: index + )) } previousCoordinate = hopCoordinate } - - logger.debug("Rebuilt \(self.lineOverlays.count) line overlays") } - /// Update overlays with trace results (creates new overlays since they're immutable) + /// Update lines with trace results and add badge points at segment midpoints func updateOverlaysWithResults() { guard let result = traceViewModel?.result, result.success else { return } - // Clear existing badges - badgeAnnotations.removeAll() + badgePoints.removeAll() - // Create new overlays with signal quality (immutable pattern) - var updatedOverlays: [PathLineOverlay] = [] - for (index, overlay) in lineOverlays.enumerated() { - // Find corresponding hop SNR (index + 1 because 0 is start node) - let hopIndex = index + 1 + var updatedLines: [MapLine] = [] + for line in mapLines { + guard let pathIndex = line.pathIndex else { + updatedLines.append(line) + continue + } + let hopIndex = pathIndex + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let quality = SNRQuality(snr: hop.snr) - - // Create new overlay with signal quality - let updatedOverlay = overlay.withSignalQuality(quality, snr: hop.snr) - updatedOverlays.append(updatedOverlay) - - // Add badge annotation at midpoint - let badge = StatsBadgeAnnotation( - coordinate: updatedOverlay.midpoint, - distanceMeters: updatedOverlay.distanceMeters, - snr: hop.snr, - segmentIndex: index - ) - badgeAnnotations.append(badge) + let style = lineStyle(for: hop.snr) + + updatedLines.append(MapLine( + id: line.id, + coordinates: line.coordinates, + style: style, + opacity: 1.0, + pathIndex: pathIndex + )) + + // Badge at midpoint + if line.coordinates.count >= 2 { + let mid = CLLocationCoordinate2D( + latitude: (line.coordinates[0].latitude + line.coordinates[1].latitude) / 2, + longitude: (line.coordinates[0].longitude + line.coordinates[1].longitude) / 2 + ) + let distance = CLLocation(latitude: line.coordinates[0].latitude, longitude: line.coordinates[0].longitude) + .distance(from: CLLocation(latitude: line.coordinates[1].latitude, longitude: line.coordinates[1].longitude)) + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) + + badgePoints.append(MapPoint( + id: UUID(hopIndex: hopIndex), + coordinate: mid, + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: "\(distFormatted) · \(snrFormatted) dB" + )) + } } else { - updatedOverlays.append(overlay) + updatedLines.append(line) } } - lineOverlays = updatedOverlays - logger.debug("Updated overlays with results, added \(self.badgeAnnotations.count) badges") + mapLines = updatedLines + rebuildMapPoints() + } + + // MARK: - Signal Quality + + private func lineStyle(for snr: Double?) -> MapLine.LineStyle { + switch SNRQuality(snr: snr) { + case .excellent, .good: .traceGood + case .fair: .traceMedium + case .poor: .traceWeak + case .unknown: .traceUntraced + } } /// Clear all overlays func clearOverlays() { - lineOverlays.removeAll() - badgeAnnotations.removeAll() + mapLines.removeAll() + badgePoints.removeAll() } // MARK: - Camera @@ -305,41 +328,11 @@ final class TracePathMapViewModel { coordinates.append(userLocation.coordinate) } - for overlay in lineOverlays { - let points = overlay.points() - for i in 0.. Void)? - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private let selectionRing = UIView() - private var numberBadge: UILabel? - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringSize: CGFloat = 44 - - // Selection ring (behind circle) - selectionRing.translatesAutoresizingMaskIntoConstraints = false - selectionRing.backgroundColor = .clear - selectionRing.layer.borderColor = UIColor.white.cgColor - selectionRing.layer.borderWidth = 2 - selectionRing.layer.cornerRadius = ringSize / 2 - selectionRing.isHidden = true - addSubview(selectionRing) - - // Circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = circleSize / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - iconImageView.image = UIImage(systemName: "antenna.radiowaves.left.and.right") - circleView.addSubview(iconImageView) - - // Triangle - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - triangleImageView.tintColor = .systemCyan - addSubview(triangleImageView) - - // All constraints use constant values — activate once - NSLayoutConstraint.activate([ - // Selection ring - selectionRing.widthAnchor.constraint(equalToConstant: ringSize), - selectionRing.heightAnchor.constraint(equalToConstant: ringSize), - selectionRing.centerXAnchor.constraint(equalTo: centerXAnchor), - selectionRing.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - // Circle - circleView.widthAnchor.constraint(equalToConstant: circleSize), - circleView.heightAnchor.constraint(equalToConstant: circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor, constant: 4), - - // Icon - iconImageView.widthAnchor.constraint(equalToConstant: iconSize), - iconImageView.heightAnchor.constraint(equalToConstant: iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - // Triangle - triangleImageView.widthAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.heightAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - let totalHeight = circleSize + triangleSize + 4 - frame = CGRect(x: 0, y: 0, width: ringSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - - canShowCallout = false - - // Tap gesture fires immediately, bypassing MapKit's ~300ms selection delay - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGesture) - } - - @objc private func handleTap() { - onTap?() - } - - // MARK: - Configuration - - func configure( - for repeater: ContactDTO, - inPath: Bool, - hopIndex: Int?, - isLastHop: Bool, - showLabel: Bool - ) { - // Clustering: in-path pins are always visible, others cluster - if inPath { - clusteringIdentifier = nil - displayPriority = .required - } else { - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } - - // Update selection ring - selectionRing.isHidden = !inPath - - // Update number badge - if let index = hopIndex { - showNumberBadge(index) - } else { - hideNumberBadge() - } - - // Update name label - if showLabel { - showNameLabel(repeater.displayName) - } else { - hideNameLabel() - } - - // Accessibility - isAccessibilityElement = true - if inPath { - if isLastHop { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.inPathLabel(repeater.displayName, hopIndex ?? 0) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.removableHint - accessibilityTraits = [.button, .selected] - } else { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.inPathLabel(repeater.displayName, hopIndex ?? 0) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.notRemovableHint - accessibilityTraits = [.button, .selected, .notEnabled] - } - } else { - accessibilityLabel = L10n.Contacts.Contacts.Trace.Map.Pin.availableLabel(repeater.displayName) - accessibilityHint = L10n.Contacts.Contacts.Trace.Map.Pin.addHint - accessibilityTraits = .button - } - } - - // MARK: - Number Badge - - private func showNumberBadge(_ number: Int) { - if numberBadge == nil { - let badge = UILabel() - badge.translatesAutoresizingMaskIntoConstraints = false - // Dynamic Type with caption2 style - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .bold) - badge.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - badge.adjustsFontForContentSizeCategory = true - badge.textColor = .black - badge.textAlignment = .center - badge.backgroundColor = .white - badge.layer.cornerRadius = 9 - badge.layer.masksToBounds = true - addSubview(badge) - - NSLayoutConstraint.activate([ - badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.heightAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.trailingAnchor.constraint(equalTo: circleView.trailingAnchor, constant: 4), - badge.topAnchor.constraint(equalTo: circleView.topAnchor, constant: -4) - ]) - - numberBadge = badge - } - - numberBadge?.text = "\(number)" - numberBadge?.isHidden = false - } - - private func hideNumberBadge() { - numberBadge?.isHidden = true - } - - // MARK: - Name Label - - private func showNameLabel(_ name: String) { - if nameLabelContainer == nil { - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.layer.masksToBounds = true - addSubview(blur) - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - // Dynamic Type with caption2 style - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - blur.contentView.addSubview(label) - - // Internal + position constraints (all set once at creation time) - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: blur.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.trailingAnchor, constant: -8), - blur.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - blur.bottomAnchor.constraint(equalTo: circleView.topAnchor, constant: -4) - ]) - - nameLabelContainer = blur - nameLabel = label - } - - nameLabel?.text = name - nameLabelContainer?.isHidden = false - } - - private func hideNameLabel() { - nameLabelContainer?.isHidden = true - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onTap = nil - selectionRing.isHidden = true - hideNumberBadge() - hideNameLabel() - accessibilityLabel = nil - accessibilityHint = nil - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } -} diff --git a/MC1/Views/Contacts/TraceResultHopRow.swift b/MC1/Views/Contacts/TraceResultHopRow.swift new file mode 100644 index 000000000..4667bd4f8 --- /dev/null +++ b/MC1/Views/Contacts/TraceResultHopRow.swift @@ -0,0 +1,95 @@ +import SwiftUI +import MC1Services + +// MARK: - Result Hop Row + +/// Row for displaying a hop in the trace results +struct TraceResultHopRow: View { + let hop: TraceHop + let hopIndex: Int + var batchStats: (avg: Double, min: Double, max: Double)? + var latestSNR: Double? + var isBatchInProgress: Bool = false + + /// SNR value to use for signal bars (latest during progress, average when complete) + private var displaySNR: Double { + if isBatchInProgress { + return latestSNR ?? hop.snr + } else if let stats = batchStats { + return stats.avg + } else { + return hop.snr + } + } + + private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } + + private var signalLevel: Double { snrQuality.barLevel } + + private var signalColor: Color { snrQuality.color } + + var body: some View { + HStack { + VStack(alignment: .leading) { + // Node identifier + if hop.isStartNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + Text(L10n.Contacts.Contacts.Results.Hop.started) + .font(.caption) + .foregroundStyle(.secondary) + } else if hop.isEndNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.Hop.received) + .font(.caption) + .foregroundStyle(.secondary) + } else if let hashDisplay = hop.hashDisplayString { + HStack { + Text(hashDisplay) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + if let name = hop.resolvedName { + Text(name) + } + } + Text(L10n.Contacts.Contacts.Results.Hop.repeated) + .font(.caption) + .foregroundStyle(.secondary) + } + + // SNR display - batch mode shows avg with range, single shows plain SNR + if !hop.isStartNode { + if let stats = batchStats { + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + } else { + Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + + // Signal strength indicator (not for start node - it didn't receive) + if !hop.isStartNode { + Image(systemName: "cellularbars", variableValue: signalLevel) + .foregroundStyle(signalColor) + .font(.title2) + } + } + .padding(.vertical, 4) + } +} diff --git a/MC1/Views/Contacts/TraceResultsSectionView.swift b/MC1/Views/Contacts/TraceResultsSectionView.swift new file mode 100644 index 000000000..15e0f805e --- /dev/null +++ b/MC1/Views/Contacts/TraceResultsSectionView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import MC1Services + +/// Section displaying trace result hops, RTT info, distance, and save action +struct TraceResultsSectionView: View { + let result: TraceResult + @Bindable var viewModel: TracePathViewModel + @Binding var saveHapticTrigger: Int + @Binding var showingDistanceInfo: Bool + + var body: some View { + Section { + if result.success { + ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in + TraceResultHopRow( + hop: hop, + hopIndex: index, + batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, + latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, + isBatchInProgress: viewModel.isBatchInProgress + ) + } + + // Batch status row (progress or completion) + if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { + HStack { + if viewModel.isBatchComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel( + viewModel.isBatchComplete + ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) + : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) + ) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } + + // Duration row with batch or single display + if viewModel.batchEnabled && viewModel.successCount > 0 { + BatchRTTRow(viewModel: viewModel) + } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { + ComparisonRowView(currentMs: result.durationMs, previousRun: previous, viewModel: viewModel) + } else { + HStack { + Text(L10n.Contacts.Contacts.PathDetail.roundTrip) + .foregroundStyle(.secondary) + Spacer() + Text("\(result.durationMs) ms") + .font(.body.monospacedDigit()) + } + } + + // Total distance row + TotalDistanceRow(viewModel: viewModel, result: result, showingDistanceInfo: $showingDistanceInfo) + + // Save path action (only for successful traces when not running a saved path) + if !viewModel.isRunningSavedPath { + SavePathRowView(viewModel: viewModel, saveHapticTrigger: $saveHapticTrigger) + } + } else if let error = result.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + } +} diff --git a/MC1/Views/Contacts/TraceResultsSheet.swift b/MC1/Views/Contacts/TraceResultsSheet.swift index 6e9bcc53c..bb2bba4d7 100644 --- a/MC1/Views/Contacts/TraceResultsSheet.swift +++ b/MC1/Views/Contacts/TraceResultsSheet.swift @@ -8,8 +8,6 @@ struct TraceResultsSheet: View { @Environment(\.dismiss) private var dismiss // Save dialog state - @State private var showingSaveDialog = false - @State private var savePathName = "" @State private var saveHapticTrigger = 0 @State private var copyHapticTrigger = 0 @State private var showingDistanceInfo = false @@ -17,7 +15,12 @@ struct TraceResultsSheet: View { var body: some View { NavigationStack { List { - resultsSection + TraceResultsSectionView( + result: result, + viewModel: viewModel, + saveHapticTrigger: $saveHapticTrigger, + showingDistanceInfo: $showingDistanceInfo + ) roundTripPathSection } .navigationTitle(L10n.Contacts.Contacts.Results.title) @@ -56,389 +59,4 @@ struct TraceResultsSheet: View { Text(L10n.Contacts.Contacts.Trace.List.roundTripPath) } } - - // MARK: - Results Section - - private var resultsSection: some View { - Section { - if result.success { - ForEach(Array(result.hops.enumerated()), id: \.element.id) { index, hop in - TraceResultHopRow( - hop: hop, - hopIndex: index, - batchStats: viewModel.batchEnabled ? viewModel.hopStats(at: index) : nil, - latestSNR: viewModel.batchEnabled ? viewModel.latestHopSNR(at: index) : nil, - isBatchInProgress: viewModel.isBatchInProgress - ) - } - - // Batch status row (progress or completion) - if viewModel.batchEnabled && (viewModel.isBatchInProgress || viewModel.isBatchComplete) { - HStack { - if viewModel.isBatchComplete { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.batchSuccess(viewModel.successCount, viewModel.batchSize)) - .foregroundStyle(.secondary) - } else { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Results.batchProgress(viewModel.currentTraceIndex, viewModel.batchSize)) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel( - viewModel.isBatchComplete - ? L10n.Contacts.Contacts.Results.batchCompleteLabel(viewModel.successCount, viewModel.batchSize) - : L10n.Contacts.Contacts.Results.batchProgressLabel(viewModel.currentTraceIndex, viewModel.batchSize) - ) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - - // Duration row with batch or single display - if viewModel.batchEnabled && viewModel.successCount > 0 { - batchRTTRow - } else if viewModel.isRunningSavedPath, let previous = viewModel.previousRun { - comparisonRow(currentMs: result.durationMs, previousRun: previous) - } else { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(result.durationMs) ms") - .font(.body.monospacedDigit()) - } - } - - // Total distance row - totalDistanceRow - - // Save path action (only for successful traces when not running a saved path) - if !viewModel.isRunningSavedPath { - savePathRow - } - } else if let error = result.errorMessage { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } - } - } - - // MARK: - Save Path Row - - @ViewBuilder - private var savePathRow: some View { - if showingSaveDialog { - VStack(alignment: .leading, spacing: 8) { - TextField(L10n.Contacts.Contacts.Trace.Map.pathName, text: $savePathName) - .textFieldStyle(.roundedBorder) - - HStack { - Button(L10n.Contacts.Contacts.Common.cancel) { - showingSaveDialog = false - savePathName = "" - } - .buttonStyle(.bordered) - - Spacer() - - Button(L10n.Contacts.Contacts.Common.save) { - Task { - let success = await viewModel.savePath(name: savePathName) - if success { - saveHapticTrigger += 1 - } - showingSaveDialog = false - savePathName = "" - } - } - .buttonStyle(.borderedProminent) - .disabled(savePathName.trimmingCharacters(in: .whitespaces).isEmpty || !viewModel.canSavePath) - } - } - .padding(.vertical, 4) - } else { - Button { - savePathName = viewModel.generatePathName() - showingSaveDialog = true - } label: { - HStack { - Label(L10n.Contacts.Contacts.Results.savePath, systemImage: "bookmark") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } - } - .foregroundStyle(.primary) - .disabled(!viewModel.canSavePath) - } - } - - // MARK: - Comparison Row - - @ViewBuilder - private func comparisonRow(currentMs: Int, previousRun: TracePathRunDTO) -> some View { - let diff = currentMs - previousRun.roundTripMs - let percentChange = previousRun.roundTripMs > 0 - ? Double(diff) / Double(previousRun.roundTripMs) * 100 - : 0 - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(L10n.Contacts.Contacts.PathDetail.roundTrip) - .foregroundStyle(.secondary) - Spacer() - Text("\(currentMs) ms") - .font(.body.monospacedDigit()) - - // Change indicator - if diff != 0 { - Text(diff > 0 ? "\u{25B2}" : "\u{25BC}") - .foregroundStyle(diff > 0 ? .red : .green) - Text(abs(percentChange), format: .number.precision(.fractionLength(0))) - .font(.caption.monospacedDigit()) - + Text("%") - .font(.caption) - } - } - - Text(L10n.Contacts.Contacts.Results.comparison(previousRun.roundTripMs, previousRun.date.formatted(date: .abbreviated, time: .omitted))) - .font(.caption) - .foregroundStyle(.secondary) - } - - // Sparkline with history link - if let savedPath = viewModel.activeSavedPath, !savedPath.recentRTTs.isEmpty { - HStack { - MiniSparkline(values: savedPath.recentRTTs) - .frame(height: 20) - - Spacer() - - NavigationLink { - SavedPathDetailView(savedPath: savedPath) - } label: { - Text(L10n.Contacts.Contacts.Results.viewRuns(savedPath.runCount)) - .font(.caption) - } - } - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - } - - // MARK: - Batch RTT Row - - @ViewBuilder - private var batchRTTRow: some View { - if let avg = viewModel.averageRTT, - let min = viewModel.minRTT, - let max = viewModel.maxRTT { - HStack { - Text(L10n.Contacts.Contacts.Results.avgRoundTrip) - .foregroundStyle(.secondary) - Spacer() - VStack(alignment: .trailing) { - Text("\(avg) ms") - .font(.body.monospacedDigit()) - Text("(\(min) – \(max))") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Contacts.Contacts.Results.avgRTTLabel(avg, min, max)) - } - } - - // MARK: - Total Distance Row - - private func formatDistance(_ meters: Double) -> String { - let measurement = Measurement(value: meters, unit: UnitLength.meters) - return measurement.formatted(.measurement(width: .abbreviated, usage: .road)) - } - - @ViewBuilder - private var totalDistanceRow: some View { - HStack { - Text(L10n.Contacts.Contacts.Results.totalDistance) - .foregroundStyle(.secondary) - Spacer() - - if let distance = viewModel.totalPathDistance { - HStack { - Text(formatDistance(distance)) - .font(.body.monospacedDigit()) - if viewModel.isDistanceUsingFallback { - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.partialDistanceLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.partialDistanceHint) - } - } - } else { - HStack { - Text(L10n.Contacts.Contacts.Results.unavailable) - .foregroundStyle(.secondary) - Button(L10n.Contacts.Contacts.Results.distanceInfo, systemImage: "info.circle") { - showingDistanceInfo = true - } - .labelStyle(.iconOnly) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.distanceUnavailableLabel) - .accessibilityHint(L10n.Contacts.Contacts.Results.distanceInfoHint) - } - } - } - .sheet(isPresented: $showingDistanceInfo) { - distanceInfoSheet - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - } - - private var distanceInfoSheet: some View { - NavigationStack { - List { - if viewModel.isDistanceUsingFallback { - Section { - Text(L10n.Contacts.Contacts.Results.partialDistanceExplanation) - } header: { - Label(L10n.Contacts.Contacts.Results.partialDistanceHeader, systemImage: "location.slash") - } - Section { - Text(L10n.Contacts.Contacts.Results.fullPathTip) - } header: { - Label(L10n.Contacts.Contacts.Results.fullPathHeader, systemImage: "lightbulb") - } - } else if result.hops.filter({ !$0.isStartNode && !$0.isEndNode }).count < 2 { - Section { - Text(L10n.Contacts.Contacts.Results.needsRepeaters) - } - } else if viewModel.repeatersWithoutLocation.isEmpty { - Section { - Text(L10n.Contacts.Contacts.Results.distanceError) - } - } else { - Section { - Text(L10n.Contacts.Contacts.Results.missingLocations) - } - Section(L10n.Contacts.Contacts.Results.repeatersWithoutLocations) { - ForEach(viewModel.repeatersWithoutLocation, id: \.self) { name in - Text(name) - } - } - } - } - .navigationTitle(viewModel.isDistanceUsingFallback ? L10n.Contacts.Contacts.Results.distanceInfoTitlePartial : L10n.Contacts.Contacts.Results.distanceInfoTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(L10n.Contacts.Contacts.Common.done) { - showingDistanceInfo = false - } - } - } - } - } -} - -// MARK: - Result Hop Row - -/// Row for displaying a hop in the trace results -struct TraceResultHopRow: View { - let hop: TraceHop - let hopIndex: Int - var batchStats: (avg: Double, min: Double, max: Double)? - var latestSNR: Double? - var isBatchInProgress: Bool = false - - /// SNR value to use for signal bars (latest during progress, average when complete) - private var displaySNR: Double { - if isBatchInProgress { - return latestSNR ?? hop.snr - } else if let stats = batchStats { - return stats.avg - } else { - return hop.snr - } - } - - private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } - - private var signalLevel: Double { snrQuality.barLevel } - - private var signalColor: Color { snrQuality.color } - - var body: some View { - HStack { - VStack(alignment: .leading) { - // Node identifier - if hop.isStartNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - Text(L10n.Contacts.Contacts.Results.Hop.started) - .font(.caption) - .foregroundStyle(.secondary) - } else if hop.isEndNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.Hop.received) - .font(.caption) - .foregroundStyle(.secondary) - } else if let hashDisplay = hop.hashDisplayString { - HStack { - Text(hashDisplay) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - if let name = hop.resolvedName { - Text(name) - } - } - Text(L10n.Contacts.Contacts.Results.Hop.repeated) - .font(.caption) - .foregroundStyle(.secondary) - } - - // SNR display - batch mode shows avg with range, single shows plain SNR - if !hop.isStartNode { - if let stats = batchStats { - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) - Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - } else { - Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Spacer() - - // Signal strength indicator (not for start node - it didn't receive) - if !hop.isStartNode { - Image(systemName: "cellularbars", variableValue: signalLevel) - .foregroundStyle(signalColor) - .font(.title2) - } - } - .padding(.vertical, 4) - } } diff --git a/MC1/Views/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/LineOfSight/AddRepeaterRowView.swift new file mode 100644 index 000000000..3bdcbabb8 --- /dev/null +++ b/MC1/Views/LineOfSight/AddRepeaterRowView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct AddRepeaterRowView: View { + let onAdd: () -> Void + + var body: some View { + Button { + onAdd() + } label: { + HStack { + // Purple R marker (matches full row) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + Text(L10n.Tools.Tools.LineOfSight.addRepeater) + .font(.subheadline) + + Spacer() + + Image(systemName: "plus.circle.fill") + .foregroundStyle(.purple) + } + .padding(.vertical, 8) + } + .liquidGlassSecondaryButtonStyle() + } +} diff --git a/MC1/Views/LineOfSight/AnalysisErrorView.swift b/MC1/Views/LineOfSight/AnalysisErrorView.swift new file mode 100644 index 000000000..129562b9c --- /dev/null +++ b/MC1/Views/LineOfSight/AnalysisErrorView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct AnalysisErrorView: View { + let message: String + let hasRepeater: Bool + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + + Text(L10n.Tools.Tools.LineOfSight.analysisFailed) + .font(.headline) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button(L10n.Tools.Tools.LineOfSight.retry) { + onRetry() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding() + } +} diff --git a/MC1/Views/LineOfSight/HeightEditorGrid.swift b/MC1/Views/LineOfSight/HeightEditorGrid.swift new file mode 100644 index 000000000..029c3b302 --- /dev/null +++ b/MC1/Views/LineOfSight/HeightEditorGrid.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct HeightEditorGrid: View { + let groundElevation: Double? + @Binding var additionalHeight: Int + let range: ClosedRange + var onHeightChanged: (() -> Void)? + + var body: some View { + Grid(alignment: .leading, verticalSpacing: 8) { + if let groundElevation { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + } else { + GridRow { + Text(L10n.Tools.Tools.LineOfSight.groundElevation) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + ProgressView() + .controlSize(.mini) + } + } + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.additionalHeight) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Stepper(value: $additionalHeight, in: range) { + Text(Measurement(value: Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + } + .controlSize(.small) + .onChange(of: additionalHeight) { + onHeightChanged?() + } + } + + if let groundElevation { + Divider() + .gridCellColumns(2) + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.totalHeight) + .font(.caption) + .bold() + + Spacer() + + Text(Measurement(value: groundElevation + Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) + .font(.caption) + .monospacedDigit() + .bold() + } + } + } + } +} diff --git a/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift b/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift new file mode 100644 index 000000000..e2e1076f3 --- /dev/null +++ b/MC1/Views/LineOfSight/LineOfSightLayoutMode.swift @@ -0,0 +1,5 @@ +enum LineOfSightLayoutMode { + case map + case panel + case mapWithSheet +} diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index a33bfeb16..856a8ca20 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -6,48 +6,6 @@ import SwiftUI private let analysisSheetDetentCollapsed: PresentationDetent = .fraction(0.25) private let analysisSheetDetentHalf: PresentationDetent = .fraction(0.5) private let analysisSheetDetentExpanded: PresentationDetent = .large -private let analysisSheetBottomInsetPadding: CGFloat = 16 - -enum LineOfSightLayoutMode { - case map - case panel - case mapWithSheet -} - -// MARK: - PointID Identifiable Conformance - -extension PointID: Identifiable { - var id: Self { self } -} - -// MARK: - Map Style Selection - -/// Map style selection for Picker, maps to MKMapType -private enum LOSMapStyleSelection: String, CaseIterable, Hashable { - case standard - case terrain - - var label: String { - switch self { - case .standard: L10n.Tools.Tools.LineOfSight.MapStyle.standard - case .terrain: L10n.Tools.Tools.LineOfSight.MapStyle.terrain - } - } - - var icon: String { - switch self { - case .standard: "map" - case .terrain: "mountain.2" - } - } - - var mkMapType: MKMapType { - switch self { - case .standard: .standard - case .terrain: .hybridFlyover - } - } -} // MARK: - Line of Sight View @@ -59,19 +17,16 @@ struct LineOfSightView: View { @State private var viewModel: LineOfSightViewModel @State private var sheetDetent: PresentationDetent = analysisSheetDetentCollapsed @State private var enableHalfDetent = false - @State private var screenHeight: CGFloat = 0 - @State private var baseScreenHeight: CGFloat = 0 @State private var showAnalysisSheet: Bool @State private var editingPoint: PointID? @State private var isDropPinMode = false - @State private var mapStyleSelection: LOSMapStyleSelection = .terrain + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .topo + @AppStorage("mapShowLabels") private var showLabels = true @State private var sheetBottomInset: CGFloat = 220 @State private var isResultsExpanded = false @State private var isRFSettingsExpanded = false @State private var showingMapStyleMenu = false - @State private var showLabels = true @State private var copyHapticTrigger = 0 - @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 private let layoutMode: LineOfSightLayoutMode @@ -141,29 +96,10 @@ struct LineOfSightView: View { showLabels: $showLabels, isDropPinMode: $isDropPinMode, mapOverlayBottomPadding: mapOverlayBottomPadding, + cameraBottomSheetFraction: showSheet ? 0.25 : 0, onRepeaterTap: { handleRepeaterTap($0) }, onMapTap: { handleMapTap(at: $0) } ) - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { height in - if height > 0 { - screenHeight = height - - if baseScreenHeight == 0 || height > baseScreenHeight || height < baseScreenHeight * 0.7 { - baseScreenHeight = height - } - } - - if showSheet, showAnalysisSheet { - updateSheetBottomInset() - } - } - .onChange(of: sheetDetent) { _, _ in - if showSheet, showAnalysisSheet { - updateSheetBottomInset() - } - } .onChange(of: viewModel.pointA) { oldValue, newValue in if oldValue == nil, newValue != nil, viewModel.pointB != nil { if showSheet { @@ -231,9 +167,13 @@ struct LineOfSightView: View { .task { appState.locationService.requestPermissionIfNeeded() viewModel.configure(appState: appState) + viewModel.showLabels = showLabels await viewModel.loadRepeaters() viewModel.centerOnAllRepeaters() } + .onChange(of: showLabels) { _, newValue in + viewModel.showLabels = newValue + } if showSheet { base @@ -255,6 +195,13 @@ struct LineOfSightView: View { } .sheet(isPresented: $showAnalysisSheet) { analysisSheet + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height - proxy.safeAreaInsets.bottom + 15 + } action: { inset in + if sheetDetent == analysisSheetDetentCollapsed { + sheetBottomInset = max(0, inset) + } + } .presentationDetents(availableSheetDetents, selection: $sheetDetent) .presentationDragIndicator(.visible) .presentationBackgroundInteraction(.enabled) @@ -275,17 +222,14 @@ struct LineOfSightView: View { showAnalysisSheet = false viewModel.relocatingPoint = nil + // Yield to let showAnalysisSheet = false commit before dismiss fires, + // avoiding a sheet-dismissal animation conflict. Task { @MainActor in await Task.yield() dismiss() } } - private var collapsedSheetFraction: Double { - guard showAnalysisSheet else { return 0 } - return 0.30 - } - // MARK: - Analysis Sheet private var analysisSheet: some View { @@ -294,23 +238,23 @@ struct LineOfSightView: View { analysisSheetContent } .scrollDismissesKeyboard(.immediately) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) } } private var analysisSheetContent: some View { - analysisSheetVStack - .padding() - } - - private var analysisSheetVStack: some View { VStack(alignment: .leading, spacing: 16) { - pointsSummarySection + PointsSummarySectionView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: { withAnimation { sheetDetent = analysisSheetDetentCollapsed } } + ) // Before analysis: show analyze button, then RF settings if viewModel.canAnalyze, !hasAnalysisResult { analyzeButtonSection - rfSettingsSection + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } // After analysis: show button, results, terrain, then RF settings @@ -320,8 +264,12 @@ struct LineOfSightView: View { resultSummarySection(result) if shouldShowExpandedAnalysis { - terrainProfileSection - rfSettingsSection + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } @@ -332,577 +280,42 @@ struct LineOfSightView: View { RelayResultsCardView(result: result, isExpanded: $isResultsExpanded) if shouldShowExpandedAnalysis { - terrainProfileSection - rfSettingsSection - } - } - - if case .error(let message) = viewModel.analysisStatus { - errorSection(message) - } - } - } - - // MARK: - Points Summary Section - - private var pointsSummarySection: some View { - VStack(alignment: .leading, spacing: 12) { - // Header with optional cancel button - HStack { - Text(L10n.Tools.Tools.LineOfSight.points) - .font(.headline) - - Spacer() - - if isRelocating { - Button(L10n.Tools.Tools.LineOfSight.cancel) { - viewModel.relocatingPoint = nil - } - .glassButtonStyle() - .controlSize(.small) - } - } - - // Show relocating message OR point rows - if let relocatingPoint = viewModel.relocatingPoint { - relocatingMessageView(for: relocatingPoint) - } else { - // Point A row - pointRow( - label: "A", - color: .blue, - point: viewModel.pointA, - pointID: .pointA, - onClear: { viewModel.clearPointA() } - ) - - // Repeater row (placeholder or full, positioned between A and B) - // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency - if let repeater = viewModel.repeaterPoint { - repeaterRow - .id("repeater-\(repeater.coordinate.latitude)-\(repeater.coordinate.longitude)") - } else if viewModel.shouldShowRepeaterPlaceholder { - addRepeaterRow - } - - // Point B row - pointRow( - label: "B", - color: .green, - point: viewModel.pointB, - pointID: .pointB, - onClear: { viewModel.clearPointB() } - ) - - if viewModel.pointA == nil || viewModel.pointB == nil { - Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) - .font(.caption) - .foregroundStyle(.secondary) - } - - if viewModel.elevationFetchFailed { - Label( - L10n.Tools.Tools.LineOfSight.elevationUnavailable, - systemImage: "exclamationmark.triangle.fill" + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter ) - .font(.caption) - .foregroundStyle(.orange) - } - } - } - } - - @ViewBuilder - private func relocatingMessageView(for pointID: PointID) -> some View { - let pointName: String = switch pointID { - case .pointA: L10n.Tools.Tools.LineOfSight.pointA - case .pointB: L10n.Tools.Tools.LineOfSight.pointB - case .repeater: L10n.Tools.Tools.LineOfSight.repeater - } - - VStack(alignment: .leading, spacing: 8) { - Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) - .font(.subheadline) - .bold() - - Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") - } - - @ViewBuilder - private func pointRow( - label: String, - color: Color, - point: SelectedPoint?, - pointID: PointID, - onClear: @escaping () -> Void - ) -> some View { - let isEditing = editingPoint == pointID - - VStack(alignment: .leading, spacing: 12) { - // Header row (always visible) - HStack { - // Point marker - Circle() - .fill(point != nil ? color : .gray.opacity(0.3)) - .frame(width: 24, height: 24) - .overlay { - Text(label) - .font(.caption) - .bold() - .foregroundStyle(.white) - } - - // Point info - if let point { - VStack(alignment: .leading, spacing: 2) { - Text(point.displayName) - .font(.subheadline) - .lineLimit(1) - - if point.isLoadingElevation { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - Text(L10n.Tools.Tools.LineOfSight.loadingElevation) - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let elevation = point.groundElevation { - Text("\(Int(elevation) + point.additionalHeight)m") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - pointRowButtons( - pointID: pointID, - isEditing: isEditing, - onClear: onClear - ) - } else { - Text(L10n.Tools.Tools.LineOfSight.notSelected) - .font(.subheadline) - .foregroundStyle(.secondary) - - Spacer() - } - } - - // Expanded editor (when editing) - if isEditing, let point { - Divider() - - pointHeightEditor(point: point, pointID: pointID) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) - } - - @ViewBuilder - private func pointRowButtons( - pointID: PointID, - isEditing: Bool, - onClear: @escaping () -> Void - ) -> some View { - let point = pointID == .pointA ? viewModel.pointA : viewModel.pointB - - // Share menu - Menu { - if let coord = point?.coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = pointID == .pointA ? L10n.Tools.Tools.LineOfSight.pointA : L10n.Tools.Tools.LineOfSight.pointB - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - UIPasteboard.general.string = coordText - } - - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == pointID { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = pointID - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) - - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : pointID - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - - // Clear button - Button(action: onClear) { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - } - - @ViewBuilder - private func pointHeightEditor(point: SelectedPoint, pointID: PointID) -> some View { - Grid(alignment: .leading, verticalSpacing: 8) { - // Ground elevation row - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - if let elevation = point.groundElevation { - Text("\(Int(elevation)) m") - .font(.caption) - .monospacedDigit() - } else { - ProgressView() - .controlSize(.mini) - } - } - - // Additional height row - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - Stepper( - value: Binding( - get: { point.additionalHeight }, - set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } - ), - in: 0...200 - ) { - Text("\(point.additionalHeight) m") - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - } - - // Total row - if let elevation = point.groundElevation { - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() - - Spacer() - - Text("\(Int(elevation) + point.additionalHeight) m") - .font(.caption) - .monospacedDigit() - .bold() - } - } - } - } - - // MARK: - Repeater Row - - @ViewBuilder - private var repeaterRow: some View { - let isEditing = editingPoint == .repeater - - VStack(alignment: .leading, spacing: 12) { - // Header row - HStack { - // Repeater marker (purple) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) - } - - VStack(alignment: .leading, spacing: 2) { - Text(L10n.Tools.Tools.LineOfSight.repeater) - .font(.subheadline) - .lineLimit(1) - - if let elevation = viewModel.repeaterGroundElevation { - let totalHeight = Int(elevation) + (viewModel.repeaterPoint?.additionalHeight ?? 0) - Text("\(totalHeight)m") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - // Share menu - Menu { - if let coord = viewModel.repeaterPoint?.coordinate { - Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { - let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) - mapItem.name = L10n.Tools.Tools.LineOfSight.repeaterLocation - mapItem.openInMaps() - } - - Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { - copyHapticTrigger += 1 - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - UIPasteboard.general.string = coordText - } - - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { - Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .sensoryFeedback(.success, trigger: copyHapticTrigger) - .controlSize(.small) - - // Relocate button (toggles on/off) - Button { - if viewModel.relocatingPoint == .repeater { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = .repeater - withAnimation { - sheetDetent = analysisSheetDetentCollapsed - } - } - } label: { - Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != .repeater) - - // Edit/Done toggle - Button { - withAnimation { - editingPoint = isEditing ? nil : .repeater - } - } label: { - Group { - if isEditing { - Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") - .labelStyle(.iconOnly) - } else { - Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") - .labelStyle(.iconOnly) - .rotationEffect(.degrees(90)) - } - } - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - - // Clear button - Button { - viewModel.clearRepeater() - } label: { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .glassButtonStyle() - .controlSize(.small) - } - - // Expanded editor - if isEditing, let repeaterPoint = viewModel.repeaterPoint { - Divider() - repeaterHeightEditor(repeaterPoint: repeaterPoint) - } - } - .padding(12) - .animation(.easeInOut(duration: 0.2), value: isEditing) - } - - @ViewBuilder - private func repeaterHeightEditor(repeaterPoint: RepeaterPoint) -> some View { - Grid(alignment: .leading, verticalSpacing: 8) { - if let groundElevation = viewModel.repeaterGroundElevation { - GridRow { - Text(L10n.Tools.Tools.LineOfSight.groundElevation) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text("\(Int(groundElevation)) m") - .font(.caption) - .monospacedDigit() + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Stepper( - value: Binding( - get: { repeaterPoint.additionalHeight }, - set: { - viewModel.updateRepeaterHeight(meters: $0) + if case .error(let message) = viewModel.analysisStatus { + AnalysisErrorView( + message: message, + hasRepeater: viewModel.repeaterPoint != nil, + onRetry: { + if viewModel.repeaterPoint != nil { viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() } - ), - in: 0...200 - ) { - Text("\(repeaterPoint.additionalHeight) m") - .font(.caption) - .monospacedDigit() - } - .controlSize(.small) - } - - if let groundElevation = viewModel.repeaterGroundElevation { - Divider() - .gridCellColumns(2) - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.totalHeight) - .font(.caption) - .bold() - Spacer() - Text("\(Int(groundElevation) + repeaterPoint.additionalHeight) m") - .font(.caption) - .monospacedDigit() - .bold() - } - } - } - } - - // MARK: - Add Repeater Row (Placeholder) - - /// Placeholder row shown when analysis is marginal/obstructed but no repeater exists yet - private var addRepeaterRow: some View { - Button { - viewModel.addRepeater() - viewModel.analyzeWithRepeater() - } label: { - HStack { - // Purple R marker (matches full row) - Circle() - .fill(.purple) - .frame(width: 24, height: 24) - .overlay { - Text("R") - .font(.caption) - .bold() - .foregroundStyle(.white) } - - Text(L10n.Tools.Tools.LineOfSight.addRepeater) - .font(.subheadline) - - Spacer() - - Image(systemName: "plus.circle.fill") - .foregroundStyle(.purple) + ) } - .padding(.vertical, 8) } - .glassButtonStyle() + .padding() } // MARK: - Analyze Button Section private var analyzeButtonSection: some View { - Button { - viewModel.shouldAutoZoomOnNextResult = true - - withAnimation { - sheetDetent = analysisSheetDetentExpanded - } - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } label: { - if viewModel.isAnalyzing { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Tools.Tools.LineOfSight.analyzing) - } - .frame(maxWidth: .infinity) - } else { - Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") - .frame(maxWidth: .infinity) + AnalyzeButton( + viewModel: viewModel, + hasAnalysisResult: hasAnalysisResult, + onAnalyze: { + withAnimation { sheetDetent = analysisSheetDetentExpanded } } - } - .glassProminentButtonStyle() - .controlSize(.large) - .disabled(viewModel.isAnalyzing || hasAnalysisResult) + ) } // MARK: - Result Summary Section @@ -912,122 +325,6 @@ struct LineOfSightView: View { ResultsCardView(result: result, isExpanded: $isResultsExpanded) } - // MARK: - Terrain Profile Section - - private var terrainProfileSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(L10n.Tools.Tools.LineOfSight.terrainProfile) - .font(.headline) - - Spacer() - - Label( - L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), - systemImage: "globe" - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - - TerrainProfileCanvas( - elevationProfile: viewModel.terrainElevationProfile, - profileSamples: viewModel.profileSamples, - profileSamplesRB: viewModel.profileSamplesRB, - // Show repeater marker for both on-path and off-path - repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, - repeaterHeight: viewModel.repeaterPoint.map { Double($0.additionalHeight) }, - // Only enable drag for on-path repeaters - onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in - viewModel.updateRepeaterPosition(pathFraction: pathFraction) - viewModel.analyzeWithRepeater() - } : nil, - onRepeaterMarkerPosition: { center in - repeaterMarkerCenter = center - }, - // Off-path segment distances for separator and labels - segmentARDistanceMeters: viewModel.segmentARDistanceMeters, - segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters - ) - .overlay { - if showDragHint, let center = repeaterMarkerCenter { - Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) - .font(.caption) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.15), radius: 4, y: 2) - .transition(.opacity.combined(with: .scale)) - .position(x: center.x, y: center.y + 30) - } - } - } - } - - // MARK: - RF Settings Section - - private var rfSettingsSection: some View { - DisclosureGroup(isExpanded: $isRFSettingsExpanded) { - VStack(spacing: 12) { - // Frequency input - extracted to separate view for @FocusState to work in sheet - FrequencyInputRow(viewModel: viewModel) - - Divider() - - // Refraction k-factor picker - HStack { - Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") - .foregroundStyle(.secondary) - Spacer() - Picker("", selection: Binding( - get: { viewModel.refractionK }, - set: { viewModel.refractionK = $0 } - )) { - Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) - Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) - } - .pickerStyle(.menu) - } - } - .padding(.top, 8) - } label: { - Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") - .font(.headline) - } - .tint(.primary) - } - - // MARK: - Error Section - - private func errorSection(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - - Text(L10n.Tools.Tools.LineOfSight.analysisFailed) - .font(.headline) - - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button(L10n.Tools.Tools.LineOfSight.retry) { - if viewModel.repeaterPoint != nil { - viewModel.analyzeWithRepeater() - } else { - viewModel.analyze() - } - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) - .padding() - } - // MARK: - Computed Properties private var analysisResult: PathAnalysisResult? { @@ -1045,23 +342,6 @@ struct LineOfSightView: View { // MARK: - Helper Methods - private func updateSheetBottomInset() { - let fraction: CGFloat - if sheetDetent == analysisSheetDetentExpanded { - // When fullscreen, map is covered - cap inset at 0.9 to avoid layout issues - fraction = 0.9 - } else if sheetDetent == analysisSheetDetentHalf { - fraction = 0.5 - } else { - fraction = 0.25 - } - - let referenceHeight = baseScreenHeight > 0 ? baseScreenHeight : screenHeight - guard referenceHeight > 0 else { return } - - sheetBottomInset = referenceHeight * fraction + analysisSheetBottomInsetPadding - } - private func handleMapTap(at coordinate: CLLocationCoordinate2D) { // Handle relocation mode if let relocating = viewModel.relocatingPoint { @@ -1098,9 +378,7 @@ struct LineOfSightView: View { switch status { case .result: if showSheet { - withAnimation { - sheetDetent = analysisSheetDetentExpanded - } + sheetDetent = analysisSheetDetentExpanded } case .relayResult: break @@ -1110,7 +388,7 @@ struct LineOfSightView: View { if viewModel.shouldAutoZoomOnNextResult { viewModel.shouldAutoZoomOnNextResult = false - viewModel.zoomToShowBothPoints(bottomInsetFraction: collapsedSheetFraction) + viewModel.zoomToShowBothPoints() } } @@ -1124,29 +402,41 @@ struct LineOfSightView: View { private struct LOSMapCanvasView: View { @Bindable var viewModel: LineOfSightViewModel let appState: AppState - @Binding var mapStyleSelection: LOSMapStyleSelection + @Environment(\.colorScheme) private var colorScheme + @Binding var mapStyleSelection: MapStyleSelection @Binding var showingMapStyleMenu: Bool @Binding var showLabels: Bool @Binding var isDropPinMode: Bool let mapOverlayBottomPadding: CGFloat + let cameraBottomSheetFraction: CGFloat? let onRepeaterTap: (ContactDTO) -> Void let onMapTap: (CLLocationCoordinate2D) -> Void var body: some View { ZStack { - LOSMKMapView( - repeaters: viewModel.repeatersWithLocation, - pointA: viewModel.pointA, - pointB: viewModel.pointB, - repeaterTarget: viewModel.repeaterPoint, - relocatingPoint: viewModel.relocatingPoint, - mapType: mapStyleSelection.mkMapType, + MC1MapView( + points: viewModel.mapPoints, + lines: viewModel.mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, cameraRegion: $viewModel.cameraRegion, cameraRegionVersion: viewModel.cameraRegionVersion, - selectionState: { [viewModel] in viewModel.selectionState }, - onRepeaterTap: onRepeaterTap, - onMapTap: onMapTap + cameraBottomSheetFraction: cameraBottomSheetFraction, + onPointTap: { point, _ in + if let repeater = viewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { + onRepeaterTap(repeater) + } + }, + onMapTap: onMapTap, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + }, ) .ignoresSafeArea() @@ -1158,39 +448,29 @@ private struct LOSMapCanvasView: View { onLocationTap: { Task { if let location = try? await appState.locationService.requestCurrentLocation() { - viewModel.cameraRegion = MKCoordinateRegion( + viewModel.setCameraRegion(MKCoordinateRegion( center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - viewModel.cameraRegionVersion += 1 + )) } } }, - showingLayersMenu: $showingMapStyleMenu - ) { - Button { - showLabels.toggle() - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) + showingLayersMenu: $showingMapStyleMenu, + topContent: { + NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) } - .buttonStyle(.plain) - .accessibilityLabel(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels) + ) { + LabelsToggleButton(showLabels: $showLabels) - Button { + Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { isDropPinMode.toggle() - } label: { - Image(systemName: isDropPinMode ? "mappin.slash" : "mappin") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(isDropPinMode ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) } + .font(.body.weight(.medium)) + .foregroundStyle(isDropPinMode ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) - .accessibilityLabel(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin) + .labelStyle(.iconOnly) } } } @@ -1198,48 +478,22 @@ private struct LOSMapCanvasView: View { if showingMapStyleMenu { Button { - withAnimation { - showingMapStyleMenu = false - } + withAnimation { showingMapStyleMenu = false } } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() + Color.black.opacity(0.3).ignoresSafeArea() } .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) VStack { Spacer() HStack { Spacer() - VStack(spacing: 0) { - ForEach(LOSMapStyleSelection.allCases, id: \.self) { style in - Button { - mapStyleSelection = style - withAnimation { - showingMapStyleMenu = false - } - } label: { - HStack { - Text(style.label) - .foregroundStyle(.primary) - Spacer() - if mapStyleSelection == style { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - - if style != LOSMapStyleSelection.allCases.last { - Divider() - } - } - } - .frame(width: 140) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .shadow(radius: 8) + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingMapStyleMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) .padding(.trailing) } } @@ -1247,6 +501,7 @@ private struct LOSMapCanvasView: View { } } } + } // MARK: - Frequency Input Row @@ -1254,7 +509,7 @@ private struct LOSMapCanvasView: View { /// Extracted view for frequency input with its own @FocusState /// This is necessary because @FocusState doesn't work properly when declared in a parent view /// and used in sheet content. -private struct FrequencyInputRow: View { +struct FrequencyInputRow: View { @Bindable var viewModel: LineOfSightViewModel @FocusState private var isFocused: Bool @State private var text: String = "" @@ -1315,25 +570,38 @@ private struct FrequencyInputRow: View { } } -// MARK: - Glass Button Style Helpers +// MARK: - Analyze Button -extension View { - @ViewBuilder - func glassButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } - } +private struct AnalyzeButton: View { + var viewModel: LineOfSightViewModel + let hasAnalysisResult: Bool + let onAnalyze: () -> Void - @ViewBuilder - func glassProminentButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) + var body: some View { + Button { + viewModel.shouldAutoZoomOnNextResult = true + onAnalyze() + if viewModel.repeaterPoint != nil { + viewModel.analyzeWithRepeater() + } else { + viewModel.analyze() + } + } label: { + if viewModel.isAnalyzing { + HStack { + ProgressView() + .controlSize(.small) + Text(L10n.Tools.Tools.LineOfSight.analyzing) + } + .frame(maxWidth: .infinity) + } else { + Label(L10n.Tools.Tools.LineOfSight.analyze, systemImage: "waveform.path") + .frame(maxWidth: .infinity) + } } + .liquidGlassProminentButtonStyle() + .controlSize(.large) + .disabled(viewModel.isAnalyzing) } } diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 4143807e7..32ff98e78 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -15,6 +15,12 @@ enum PointID: Hashable { case repeater } +// MARK: - PointID Identifiable Conformance + +extension PointID: Identifiable { + var id: Self { self } +} + // MARK: - Repeater Point /// A repeater point for relay analysis. @@ -114,17 +120,29 @@ struct LOSRepeaterSelectionInfo { @MainActor @Observable final class LineOfSightViewModel { + // MARK: - Stable Map IDs + + let pointAMapID = UUID() + let pointBMapID = UUID() + let repeaterTargetMapID = UUID() + // MARK: - Point Selection State - var pointA: SelectedPoint? - var pointB: SelectedPoint? - var relocatingPoint: PointID? + var pointA: SelectedPoint? { + didSet { rebuildSelectionState() } + } + var pointB: SelectedPoint? { + didSet { rebuildSelectionState() } + } + var relocatingPoint: PointID? { + didSet { rebuildMapLines() } + } var shouldAutoZoomOnNextResult = false // MARK: - Camera State (MKMapView) var cameraRegion: MKCoordinateRegion? - var cameraRegionVersion = 0 + private(set) var cameraRegionVersion = 0 // MARK: - RF Parameters @@ -145,14 +163,30 @@ final class LineOfSightViewModel { reanalyzeWithCachedProfileIfNeeded() } + // MARK: - Map Display State + + var isNorthLocked = false + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } + private(set) var mapPoints: [MapPoint] = [] + private(set) var mapLines: [MapLine] = [] + // MARK: - Repeaters State - private(set) var repeatersWithLocation: [ContactDTO] = [] + private(set) var repeatersWithLocation: [ContactDTO] = [] { + didSet { rebuildSelectionState() } + } // MARK: - Repeater State /// The active repeater point (nil when not in use) - var repeaterPoint: RepeaterPoint? + var repeaterPoint: RepeaterPoint? { + didSet { + rebuildMapPoints() + rebuildMapLines() + } + } /// Whether repeater row should be visible (analysis shows marginal or worse) var shouldShowRepeaterRow: Bool { @@ -242,6 +276,13 @@ final class LineOfSightViewModel { private var pointBElevationTask: Task? private var repeaterElevationTask: Task? + isolated deinit { + analysisTask?.cancel() + pointAElevationTask?.cancel() + pointBElevationTask?.cancel() + repeaterElevationTask?.cancel() + } + // MARK: - Dependencies private let elevationService: ElevationServiceProtocol @@ -254,9 +295,10 @@ final class LineOfSightViewModel { pointA?.groundElevation != nil && pointB?.groundElevation != nil } - /// Pre-computes selection state for all repeaters in a single O(N) pass. - /// Returns a dictionary mapping repeater ID to its selection info. - var selectionState: [UUID: LOSRepeaterSelectionInfo] { + /// Pre-computed selection state for all repeaters. Rebuilt via `rebuildSelectionState()`. + private(set) var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] + + private func rebuildSelectionState() { var result = [UUID: LOSRepeaterSelectionInfo]() result.reserveCapacity(repeatersWithLocation.count) @@ -274,62 +316,120 @@ final class LineOfSightViewModel { } result[contact.id] = LOSRepeaterSelectionInfo(selectedAs: selectedAs) } - return result + selectionState = result + rebuildMapPoints() + rebuildMapLines() + } + + // MARK: - Map Data Rebuild + + func rebuildMapPoints() { + var points: [MapPoint] = [] + + for repeater in repeatersWithLocation { + let selectedAs = selectionState[repeater.id]?.selectedAs + let style: MapPoint.PinStyle = switch selectedAs { + case .pointA: .repeaterRingBlue + case .pointB: .repeaterRingGreen + case .repeater, nil: .repeater + } + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: style, + label: showLabels ? repeater.displayName : nil, + isClusterable: selectedAs == nil, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointA, pointA.contact == nil { + points.append(MapPoint( + id: pointAMapID, + coordinate: pointA.coordinate, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointB, pointB.contact == nil { + points.append(MapPoint( + id: pointBMapID, + coordinate: pointB.coordinate, + pinStyle: .pointB, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let target = repeaterPoint { + points.append(MapPoint( + id: repeaterTargetMapID, + coordinate: target.coordinate, + pinStyle: .crosshair, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + mapPoints = points + } + + func rebuildMapLines() { + guard let a = pointA?.coordinate, + let b = pointB?.coordinate else { + mapLines = [] + return + } + + let activeOpacity = 0.7 + let dimOpacity = 0.3 + + if let r = repeaterPoint?.coordinate { + let opacityAR = relocatingPoint == .pointA ? dimOpacity : activeOpacity + let opacityRB = relocatingPoint == .pointB ? dimOpacity : activeOpacity + mapLines = [ + MapLine(id: "los-ar", coordinates: [a, r], style: .los, + opacity: relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: [r, b], style: .los, + opacity: relocatingPoint == .repeater ? dimOpacity : opacityRB) + ] + } else { + let opacity = relocatingPoint != nil ? dimOpacity : activeOpacity + mapLines = [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + } } // MARK: - Camera Methods func centerOnAllRepeaters() { - let coordinates = repeatersWithLocation.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } + let coordinates = repeatersWithLocation.map(\.coordinate) setCameraRegion(fitting: coordinates) } - func zoomToShowBothPoints(bottomInsetFraction: Double = 0) { + func zoomToShowBothPoints() { guard let pointA, let pointB else { return } - setCameraRegion( - fitting: [pointA.coordinate, pointB.coordinate], - bottomInsetFraction: bottomInsetFraction - ) + setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) } - private func setCameraRegion( - fitting coordinates: [CLLocationCoordinate2D], - paddingMultiplier: Double = 1.5, - bottomInsetFraction: Double = 0 - ) { - guard !coordinates.isEmpty else { return } - let lats = coordinates.map(\.latitude) - let lons = coordinates.map(\.longitude) - let latDelta = max(0.01, (lats.max()! - lats.min()!) * paddingMultiplier) - let lonDelta = max(0.01, (lons.max()! - lons.min()!) * paddingMultiplier) - - var centerLat = (lats.min()! + lats.max()!) / 2 - var adjustedLatDelta = latDelta - - // Expand region south so content fits above the bottom sheet - if bottomInsetFraction > 0, bottomInsetFraction < 1 { - let southExtra = latDelta * bottomInsetFraction / (1 - bottomInsetFraction) - adjustedLatDelta = latDelta + southExtra - centerLat -= southExtra / 2 - } - - // Clamp to valid MKCoordinateRegion bounds to prevent MKMapView crash - let clampedLatDelta = min(adjustedLatDelta, 180) - let clampedLonDelta = min(lonDelta, 360) - let clampedCenterLat = centerLat.clamped(to: -90...90) - - cameraRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: clampedCenterLat, - longitude: (lons.min()! + lons.max()!) / 2 - ), - span: MKCoordinateSpan(latitudeDelta: clampedLatDelta, longitudeDelta: clampedLonDelta) - ) + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region cameraRegionVersion += 1 } + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } + /// Returns the elevation profile to display in terrain visualization. /// For on-path or no repeater: returns cached A-B profile. /// For off-path: returns concatenated A→R→B profiles. @@ -426,7 +526,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointAElevationTask = Task { @MainActor in + pointAElevationTask = Task { await fetchElevationForPointA() } } @@ -454,7 +554,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointBElevationTask = Task { @MainActor in + pointBElevationTask = Task { await fetchElevationForPointB() } } @@ -487,7 +587,7 @@ final class LineOfSightViewModel { /// - If contact is already selected as A or B, clear that point /// - Otherwise, auto-assign to A (if empty) or B func toggleContact(_ contact: ContactDTO) { - let coordinate = CLLocationCoordinate2D(latitude: contact.latitude, longitude: contact.longitude) + let coordinate = contact.coordinate // Check if already selected as point A if let pointA, pointA.contact?.id == contact.id { @@ -689,9 +789,7 @@ final class LineOfSightViewModel { to: repeaterCoord, sampleCount: sampleCountAR ) - let profileAR = try await elevationService.fetchElevations(along: sampleCoordsAR) - - // Fetch R→B profile + // Fetch R→B profile (computed before async let to avoid capture issues) let distanceRB = RFCalculator.distance(from: repeaterCoord, to: pointBCoord) let sampleCountRB = ElevationService.optimalSampleCount(distanceMeters: distanceRB) let sampleCoordsRB = ElevationService.sampleCoordinates( @@ -699,7 +797,11 @@ final class LineOfSightViewModel { to: pointBCoord, sampleCount: sampleCountRB ) - let profileRB = try await elevationService.fetchElevations(along: sampleCoordsRB) + + // Fetch both elevation profiles in parallel + async let profileARTask = elevationService.fetchElevations(along: sampleCoordsAR) + async let profileRBTask = elevationService.fetchElevations(along: sampleCoordsRB) + let (profileAR, profileRB) = try await (profileARTask, profileRBTask) // Offset R→B profile distances to continue from A→R endpoint // (fetchElevations returns distances relative to segment start, not global A) @@ -828,24 +930,16 @@ final class LineOfSightViewModel { analysisTask = Task { do { - // Calculate optimal sample count based on distance let distance = RFCalculator.distance(from: pointACoord, to: pointBCoord) let sampleCount = ElevationService.optimalSampleCount(distanceMeters: distance) - - // Generate sample coordinates along the path let sampleCoordinates = ElevationService.sampleCoordinates( from: pointACoord, to: pointBCoord, sampleCount: sampleCount ) - - // Fetch elevation profile (async network call) let profile = try await elevationService.fetchElevations(along: sampleCoordinates) - - // Check for cancellation if Task.isCancelled { return } - // Run path analysis off main actor to avoid UI hitching let result = await Task.detached { RFCalculator.analyzePath( elevationProfile: profile, @@ -858,7 +952,6 @@ final class LineOfSightViewModel { if Task.isCancelled { return } - // Update state on MainActor elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( from: profile, @@ -934,7 +1027,6 @@ final class LineOfSightViewModel { let k = refractionK analysisTask = Task { - // Run path analysis off main actor let result = await Task.detached { RFCalculator.analyzePath( elevationProfile: profile, diff --git a/MC1/Views/LineOfSight/Map/LOSAnnotations.swift b/MC1/Views/LineOfSight/Map/LOSAnnotations.swift deleted file mode 100644 index 2e672241e..000000000 --- a/MC1/Views/LineOfSight/Map/LOSAnnotations.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CoreLocation -import MapKit -import MC1Services - -// MARK: - Repeater Annotation - -/// MKAnnotation wrapper for repeater contacts on the line of sight map -final class LOSRepeaterAnnotation: NSObject, MKAnnotation { - let repeater: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: repeater.latitude, longitude: repeater.longitude) - } - - var title: String? { repeater.displayName } - - init(repeater: ContactDTO) { - self.repeater = repeater - super.init() - } -} - -// MARK: - Point Annotation - -/// MKAnnotation for dropped-pin A/B markers -final class LOSPointAnnotation: NSObject, MKAnnotation { - let pointID: PointID - let label: String - - dynamic var coordinate: CLLocationCoordinate2D - - init(pointID: PointID, label: String, coordinate: CLLocationCoordinate2D) { - self.pointID = pointID - self.label = label - self.coordinate = coordinate - super.init() - } -} - -// MARK: - Repeater Target Annotation - -/// MKAnnotation for the crosshairs repeater target marker -final class LOSRepeaterTargetAnnotation: NSObject, MKAnnotation { - dynamic var coordinate: CLLocationCoordinate2D - - init(coordinate: CLLocationCoordinate2D) { - self.coordinate = coordinate - super.init() - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSMKMapView.swift b/MC1/Views/LineOfSight/Map/LOSMKMapView.swift deleted file mode 100644 index 412596b56..000000000 --- a/MC1/Views/LineOfSight/Map/LOSMKMapView.swift +++ /dev/null @@ -1,497 +0,0 @@ -import MapKit -import MC1Services -import SwiftUI - -/// UIViewRepresentable for line of sight map with custom overlays and interactions -struct LOSMKMapView: UIViewRepresentable { - let repeaters: [ContactDTO] - let pointA: SelectedPoint? - let pointB: SelectedPoint? - let repeaterTarget: RepeaterPoint? - let relocatingPoint: PointID? - let mapType: MKMapType - let showLabels: Bool - - @Binding var cameraRegion: MKCoordinateRegion? - let cameraRegionVersion: Int - - /// Closure-wrapped to defer computation to updateUIView, avoiding SwiftUI observation overhead - let selectionState: () -> [UUID: LOSRepeaterSelectionInfo] - let onRepeaterTap: (ContactDTO) -> Void - let onMapTap: (CLLocationCoordinate2D) -> Void - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - mapView.delegate = context.coordinator - mapView.showsUserLocation = true - mapView.showsCompass = true - - let scaleView = MKScaleView(mapView: mapView) - scaleView.translatesAutoresizingMaskIntoConstraints = false - scaleView.scaleVisibility = .adaptive - mapView.addSubview(scaleView) - NSLayoutConstraint.activate([ - scaleView.leadingAnchor.constraint(equalTo: mapView.leadingAnchor, constant: 16), - scaleView.bottomAnchor.constraint(equalTo: mapView.safeAreaLayoutGuide.bottomAnchor, constant: -8), - ]) - - mapView.register( - LOSRepeaterPinView.self, - forAnnotationViewWithReuseIdentifier: LOSRepeaterPinView.reuseIdentifier - ) - mapView.register( - TracePathClusterView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - mapView.register( - LOSPointPinView.self, - forAnnotationViewWithReuseIdentifier: LOSPointPinView.reuseIdentifier - ) - mapView.register( - LOSRepeaterTargetPinView.self, - forAnnotationViewWithReuseIdentifier: LOSRepeaterTargetPinView.reuseIdentifier - ) - - // Map tap gesture for drop pin / relocation - let tapGesture = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleMapTap(_:)) - ) - tapGesture.delegate = context.coordinator - mapView.addGestureRecognizer(tapGesture) - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Evaluate selection state once - let selState = selectionState() - coordinator.selectionState = selState - coordinator.onRepeaterTap = onRepeaterTap - coordinator.onMapTap = onMapTap - coordinator.relocatingPoint = relocatingPoint - coordinator.showLabels = showLabels - - // Update map type - mapView.mapType = mapType - - // Update repeater annotations - updateRepeaterAnnotations(in: mapView, coordinator: coordinator, selectionState: selState) - - // Update point A/B annotations - updatePointAnnotations(in: mapView, coordinator: coordinator) - - // Update repeater target annotation - updateRepeaterTargetAnnotation(in: mapView, coordinator: coordinator) - - // Update path overlays - updatePathOverlays(in: mapView, coordinator: coordinator) - - // Update visible pin views with current state - updateVisiblePinViews(in: mapView, coordinator: coordinator, selectionState: selState) - - // Update path overlay opacity when relocatingPoint changes - if relocatingPoint != coordinator.lastRelocatingPoint { - coordinator.lastRelocatingPoint = relocatingPoint - for overlay in mapView.overlays { - if let pathOverlay = overlay as? LOSPathOverlay, - let renderer = mapView.renderer(for: pathOverlay) as? LOSPathRenderer { - renderer.alpha = coordinator.lineOpacity(connectsTo: pathOverlay.connectsTo) - renderer.setNeedsDisplay() - } - } - } - - // Update region only when version changes - if cameraRegionVersion != coordinator.lastAppliedRegionVersion, - let region = cameraRegion { - coordinator.lastAppliedRegionVersion = cameraRegionVersion - coordinator.hasPendingProgrammaticRegion = true - let animated = coordinator.lastAppliedRegion != nil - let fittedRegion = mapView.regionThatFits(region) - mapView.setRegion(fittedRegion, animated: animated) - - coordinator.lastAppliedRegion = fittedRegion - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(setCameraRegion: { cameraRegion = $0 }) - } - - static func dismantleUIView(_ mapView: MKMapView, coordinator: Coordinator) { - coordinator.pendingRegionTask?.cancel() - } - - // MARK: - Repeater Annotation Updates - - private func updateRepeaterAnnotations( - in mapView: MKMapView, - coordinator: Coordinator, - selectionState: [UUID: LOSRepeaterSelectionInfo] - ) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? LOSRepeaterAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.repeater.id }) - let newIDs = Set(repeaters.map { $0.id }) - - // Remove old - let toRemove = currentAnnotations.filter { !newIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toRemove) - - // Add new - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.repeater.id })) - let toAdd = repeaters.filter { !existingIDs.contains($0.id) } - .map { LOSRepeaterAnnotation(repeater: $0) } - mapView.addAnnotations(toAdd) - - // Re-add annotations whose selection state changed (MapKit doesn't pick up clusteringIdentifier changes) - let currentSelectedIDs = Set(selectionState.filter { $0.value.selectedAs != nil }.map { $0.key }) - let previousSelectedIDs = coordinator.previousSelectedIDs - let changedIDs = currentSelectedIDs.symmetricDifference(previousSelectedIDs) - coordinator.previousSelectedIDs = currentSelectedIDs - - if !changedIDs.isEmpty { - let toReAdd = mapView.annotations - .compactMap { $0 as? LOSRepeaterAnnotation } - .filter { changedIDs.contains($0.repeater.id) } - mapView.removeAnnotations(toReAdd) - mapView.addAnnotations(toReAdd) - } - } - - // MARK: - Point Annotation Updates - - private func updatePointAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let existingPoints = mapView.annotations.compactMap { $0 as? LOSPointAnnotation } - - // Point A (only if dropped pin, not contact) - let existingA = existingPoints.first { $0.pointID == .pointA } - if let pointA, pointA.contact == nil { - if let existing = existingA { - // Update coordinate if changed - if existing.coordinate.latitude != pointA.coordinate.latitude || - existing.coordinate.longitude != pointA.coordinate.longitude { - existing.coordinate = pointA.coordinate - } - } else { - let annotation = LOSPointAnnotation( - pointID: .pointA, - label: "A", - coordinate: pointA.coordinate - ) - mapView.addAnnotation(annotation) - } - } else if let existing = existingA { - mapView.removeAnnotation(existing) - } - - // Point B (only if dropped pin, not contact) - let existingB = existingPoints.first { $0.pointID == .pointB } - if let pointB, pointB.contact == nil { - if let existing = existingB { - if existing.coordinate.latitude != pointB.coordinate.latitude || - existing.coordinate.longitude != pointB.coordinate.longitude { - existing.coordinate = pointB.coordinate - } - } else { - let annotation = LOSPointAnnotation( - pointID: .pointB, - label: "B", - coordinate: pointB.coordinate - ) - mapView.addAnnotation(annotation) - } - } else if let existing = existingB { - mapView.removeAnnotation(existing) - } - } - - // MARK: - Repeater Target Annotation Updates - - private func updateRepeaterTargetAnnotation(in mapView: MKMapView, coordinator: Coordinator) { - let existing = mapView.annotations.compactMap { $0 as? LOSRepeaterTargetAnnotation }.first - - if let repeaterTarget { - if let existing { - if existing.coordinate.latitude != repeaterTarget.coordinate.latitude || - existing.coordinate.longitude != repeaterTarget.coordinate.longitude { - existing.coordinate = repeaterTarget.coordinate - } - } else { - let annotation = LOSRepeaterTargetAnnotation(coordinate: repeaterTarget.coordinate) - mapView.addAnnotation(annotation) - } - } else if let existing { - mapView.removeAnnotation(existing) - } - } - - // MARK: - Path Overlay Updates - - private func updatePathOverlays(in mapView: MKMapView, coordinator: Coordinator) { - // Build desired path segments - var newOverlays: [LOSPathOverlay] = [] - - if let pointA, let pointB { - if let repeaterTarget { - // A -> R - let coordsAR = [pointA.coordinate, repeaterTarget.coordinate] - let overlayAR = LOSPathOverlay(coordinates: coordsAR, count: coordsAR.count) - overlayAR.connectsTo = .pointA - newOverlays.append(overlayAR) - - // R -> B - let coordsRB = [repeaterTarget.coordinate, pointB.coordinate] - let overlayRB = LOSPathOverlay(coordinates: coordsRB, count: coordsRB.count) - overlayRB.connectsTo = .pointB - newOverlays.append(overlayRB) - } else { - // A -> B - let coords = [pointA.coordinate, pointB.coordinate] - let overlay = LOSPathOverlay(coordinates: coords, count: coords.count) - overlay.connectsTo = .pointA - newOverlays.append(overlay) - } - } - - // Check if overlays need updating - let existingOverlays = mapView.overlays.compactMap { $0 as? LOSPathOverlay } - let needsUpdate = existingOverlays.count != newOverlays.count || - !coordinatesEqual(coordinator.lastOverlayPointACoord, pointA?.coordinate) || - !coordinatesEqual(coordinator.lastOverlayPointBCoord, pointB?.coordinate) || - !coordinatesEqual(coordinator.lastOverlayRepeaterCoord, repeaterTarget?.coordinate) - - if needsUpdate { - mapView.removeOverlays(existingOverlays) - mapView.addOverlays(newOverlays) - coordinator.lastOverlayPointACoord = pointA?.coordinate - coordinator.lastOverlayPointBCoord = pointB?.coordinate - coordinator.lastOverlayRepeaterCoord = repeaterTarget?.coordinate - } - } - - // MARK: - Update Visible Pin Views - - private func updateVisiblePinViews(in mapView: MKMapView, coordinator: Coordinator, selectionState: [UUID: LOSRepeaterSelectionInfo]) { - for annotation in mapView.annotations { - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation, - let view = mapView.view(for: repeaterAnnotation) as? LOSRepeaterPinView { - let info = selectionState[repeaterAnnotation.repeater.id] - let selectedAs = info?.selectedAs - let opacity = coordinator.markerOpacity(for: selectedAs) - view.configure(selectedAs: selectedAs, opacity: opacity, showLabel: coordinator.showLabels) - } - - if let pointAnnotation = annotation as? LOSPointAnnotation, - let view = mapView.view(for: pointAnnotation) as? LOSPointPinView { - let color: UIColor = pointAnnotation.pointID == .pointA ? .systemBlue : .systemGreen - let opacity = coordinator.markerOpacity(for: pointAnnotation.pointID) - view.configure(label: pointAnnotation.label, color: color, opacity: opacity) - } - - if annotation is LOSRepeaterTargetAnnotation, - let view = mapView.view(for: annotation) as? LOSRepeaterTargetPinView { - let opacity = coordinator.markerOpacity(for: .repeater) - view.configure(opacity: opacity) - } - } - } - - // MARK: - Coordinate Comparison - - private func coordinatesEqual(_ lhs: CLLocationCoordinate2D?, _ rhs: CLLocationCoordinate2D?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): return true - case (nil, _), (_, nil): return false - case let (l?, r?): return l.latitude == r.latitude && l.longitude == r.longitude - } - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate { - var setCameraRegion: (MKCoordinateRegion?) -> Void - - var selectionState: [UUID: LOSRepeaterSelectionInfo] = [:] - var onRepeaterTap: ((ContactDTO) -> Void)? - var onMapTap: ((CLLocationCoordinate2D) -> Void)? - var relocatingPoint: PointID? - var showLabels = true - - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var lastAppliedRegionVersion = -1 - var hasPendingProgrammaticRegion = false - - // Change detection - var previousSelectedIDs: Set = [] - var lastOverlayPointACoord: CLLocationCoordinate2D? - var lastOverlayPointBCoord: CLLocationCoordinate2D? - var lastOverlayRepeaterCoord: CLLocationCoordinate2D? - var lastRelocatingPoint: PointID? - - private var hasReceivedInitialRegion = false - var pendingRegionTask: Task? - - lazy var mapView: MKMapView = NoDoubleTapMapView() - - init(setCameraRegion: @escaping (MKCoordinateRegion?) -> Void) { - self.setCameraRegion = setCameraRegion - } - - // MARK: - Map Tap Handling - - @objc func handleMapTap(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: mapView) - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - onMapTap?(coordinate) - } - - // Avoid intercepting annotation view taps - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { - !(touch.view is MKAnnotationView) - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - if annotation is MKUserLocation { - return nil - } - - if let clusterAnnotation = annotation as? MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? TracePathClusterView ?? TracePathClusterView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.configure(with: clusterAnnotation) - return view - } - - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSRepeaterPinView.reuseIdentifier, - for: annotation - ) as? LOSRepeaterPinView ?? LOSRepeaterPinView( - annotation: annotation, - reuseIdentifier: LOSRepeaterPinView.reuseIdentifier - ) - - let info = selectionState[repeaterAnnotation.repeater.id] - let selectedAs = info?.selectedAs - view.configure(selectedAs: selectedAs, opacity: markerOpacity(for: selectedAs), showLabel: showLabels) - - view.onTap = { [weak self] in - self?.onRepeaterTap?(repeaterAnnotation.repeater) - } - - return view - } - - if let pointAnnotation = annotation as? LOSPointAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSPointPinView.reuseIdentifier, - for: annotation - ) as? LOSPointPinView ?? LOSPointPinView( - annotation: annotation, - reuseIdentifier: LOSPointPinView.reuseIdentifier - ) - - let color: UIColor = pointAnnotation.pointID == .pointA ? .systemBlue : .systemGreen - view.configure(label: pointAnnotation.label, color: color, opacity: markerOpacity(for: pointAnnotation.pointID)) - return view - } - - if annotation is LOSRepeaterTargetAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: LOSRepeaterTargetPinView.reuseIdentifier, - for: annotation - ) as? LOSRepeaterTargetPinView ?? LOSRepeaterTargetPinView( - annotation: annotation, - reuseIdentifier: LOSRepeaterTargetPinView.reuseIdentifier - ) - - view.configure(opacity: markerOpacity(for: .repeater)) - return view - } - - return nil - } - - func mapView(_ mapView: MKMapView, rendererFor overlay: any MKOverlay) -> MKOverlayRenderer { - if let pathOverlay = overlay as? LOSPathOverlay { - let opacity = lineOpacity(connectsTo: pathOverlay.connectsTo) - return LOSPathRenderer(overlay: pathOverlay, opacity: opacity) - } - return MKOverlayRenderer(overlay: overlay) - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - mapView.deselectAnnotation(annotation, animated: false) - - if let cluster = annotation as? MKClusterAnnotation { - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { return } - - if hasPendingProgrammaticRegion { - hasPendingProgrammaticRegion = false - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - if !hasReceivedInitialRegion { - hasReceivedInitialRegion = true - lastAppliedRegion = mapView.region - return - } - - lastAppliedRegion = mapView.region - - pendingRegionTask?.cancel() - pendingRegionTask = Task { @MainActor in - guard !Task.isCancelled else { return } - self.setCameraRegion(mapView.region) - } - } - - // MARK: - Opacity Helpers - - func markerOpacity(for pointID: PointID?) -> CGFloat { - guard let relocating = relocatingPoint else { return 1.0 } - guard let pointID else { return 1.0 } - return relocating == pointID ? 0.4 : 1.0 - } - - func lineOpacity(connectsTo pointID: PointID) -> CGFloat { - guard let relocating = relocatingPoint else { return 0.7 } - - if relocating == .repeater { return 0.3 } - - switch pointID { - case .pointA: - return relocating == .pointA ? 0.3 : 0.7 - case .pointB: - return relocating == .pointB ? 0.3 : 0.7 - case .repeater: - return 0.7 - } - } - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift b/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift deleted file mode 100644 index 58a218331..000000000 --- a/MC1/Views/LineOfSight/Map/LOSPathOverlay.swift +++ /dev/null @@ -1,18 +0,0 @@ -import MapKit - -/// MKPolyline overlay for path lines between points A, R, and B -final class LOSPathOverlay: MKPolyline { - /// Which point this line connects to (used for opacity calculation during relocation) - var connectsTo: PointID = .pointA -} - -/// Renderer for LOS path overlays - blue dashed lines -final class LOSPathRenderer: MKPolylineRenderer { - init(overlay: LOSPathOverlay, opacity: CGFloat) { - super.init(overlay: overlay) - strokeColor = .systemBlue - lineWidth = 3 - lineDashPattern = [8, 4] - alpha = opacity - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSPointPinView.swift b/MC1/Views/LineOfSight/Map/LOSPointPinView.swift deleted file mode 100644 index 24d057123..000000000 --- a/MC1/Views/LineOfSight/Map/LOSPointPinView.swift +++ /dev/null @@ -1,88 +0,0 @@ -import MapKit -import UIKit - -/// Pin view for dropped-pin A/B markers on the line of sight map -final class LOSPointPinView: MKAnnotationView { - static let reuseIdentifier = "LOSPointPinView" - - // MARK: - UI Components - - private let circleView = UIView() - private let labelView = UILabel() - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let size: CGFloat = 32 - - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.layer.cornerRadius = size / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - labelView.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, - weight: .bold - ) - labelView.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: baseFont) - labelView.adjustsFontForContentSizeCategory = true - labelView.textColor = .white - labelView.textAlignment = .center - circleView.addSubview(labelView) - - NSLayoutConstraint.activate([ - circleView.widthAnchor.constraint(equalToConstant: size), - circleView.heightAnchor.constraint(equalToConstant: size), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.centerYAnchor.constraint(equalTo: centerYAnchor), - - labelView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - labelView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - frame = CGRect(x: 0, y: 0, width: size, height: size) - centerOffset = .zero - canShowCallout = false - displayPriority = .required - } - - // MARK: - Configuration - - func configure(label: String, color: UIColor, opacity: CGFloat) { - circleView.backgroundColor = color - labelView.text = label - alpha = opacity - - isAccessibilityElement = true - accessibilityTraits = .image - accessibilityLabel = label == "A" - ? L10n.Tools.Tools.LineOfSight.pointA - : L10n.Tools.Tools.LineOfSight.pointB - accessibilityHint = L10n.Tools.Tools.LineOfSight.PointPin.accessibilityHint - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - alpha = 1.0 - labelView.text = nil - accessibilityLabel = nil - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift b/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift deleted file mode 100644 index 0c5210a8c..000000000 --- a/MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import MapKit -import UIKit -import MC1Services - -/// Custom pin view for repeaters in line of sight map with selection state and clustering -final class LOSRepeaterPinView: MKAnnotationView { - static let reuseIdentifier = "LOSRepeaterPinView" - static let clusteringID = "losRepeater" - - // MARK: - Tap Handling - - var onTap: (() -> Void)? - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private let selectionRing = UIView() - private var pointBadge: UILabel? - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let circleSize: CGFloat = 36 - let iconSize: CGFloat = 16 - let triangleSize: CGFloat = 10 - let ringSize: CGFloat = 44 - - // Selection ring (behind circle) - selectionRing.translatesAutoresizingMaskIntoConstraints = false - selectionRing.backgroundColor = .clear - selectionRing.layer.borderWidth = 3 - selectionRing.layer.cornerRadius = ringSize / 2 - selectionRing.isHidden = true - addSubview(selectionRing) - - // Circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.backgroundColor = .systemCyan - circleView.layer.cornerRadius = circleSize / 2 - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - iconImageView.image = UIImage(systemName: "antenna.radiowaves.left.and.right") - circleView.addSubview(iconImageView) - - // Triangle - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - triangleImageView.tintColor = .systemCyan - addSubview(triangleImageView) - - NSLayoutConstraint.activate([ - selectionRing.widthAnchor.constraint(equalToConstant: ringSize), - selectionRing.heightAnchor.constraint(equalToConstant: ringSize), - selectionRing.centerXAnchor.constraint(equalTo: centerXAnchor), - selectionRing.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - circleView.widthAnchor.constraint(equalToConstant: circleSize), - circleView.heightAnchor.constraint(equalToConstant: circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor, constant: 4), - - iconImageView.widthAnchor.constraint(equalToConstant: iconSize), - iconImageView.heightAnchor.constraint(equalToConstant: iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), - - triangleImageView.widthAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.heightAnchor.constraint(equalToConstant: triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - let totalHeight = circleSize + triangleSize + 4 - frame = CGRect(x: 0, y: 0, width: ringSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - - canShowCallout = false - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGesture) - } - - @objc private func handleTap() { - onTap?() - } - - // MARK: - Configuration - - func configure(selectedAs: PointID?, opacity: CGFloat, showLabel: Bool = false) { - let isSelected = selectedAs != nil - - // Clustering: selected pins always visible, others cluster - if isSelected { - clusteringIdentifier = nil - displayPriority = .required - } else { - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } - - // Selection ring color: blue for A, green for B - if let selectedAs { - selectionRing.isHidden = false - selectionRing.layer.borderColor = (selectedAs == .pointA ? UIColor.systemBlue : UIColor.systemGreen).cgColor - showPointBadge(selectedAs == .pointA ? "A" : "B", color: selectedAs == .pointA ? .systemBlue : .systemGreen) - } else { - selectionRing.isHidden = true - hidePointBadge() - } - - alpha = opacity - - // Name label - if showLabel, let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - showNameLabel(repeaterAnnotation.repeater.displayName) - } else { - hideNameLabel() - } - - // Accessibility - isAccessibilityElement = true - if let repeaterAnnotation = annotation as? LOSRepeaterAnnotation { - if isSelected { - accessibilityLabel = repeaterAnnotation.repeater.displayName - accessibilityTraits = [.button, .selected] - } else { - accessibilityLabel = repeaterAnnotation.repeater.displayName - accessibilityTraits = .button - } - accessibilityHint = L10n.Tools.Tools.LineOfSight.RepeaterPin.accessibilityHint - } - } - - // MARK: - Point Badge - - private func showPointBadge(_ text: String, color: UIColor) { - if pointBadge == nil { - let badge = UILabel() - badge.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .bold - ) - badge.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - badge.adjustsFontForContentSizeCategory = true - badge.textColor = .white - badge.textAlignment = .center - badge.layer.cornerRadius = 9 - badge.layer.masksToBounds = true - addSubview(badge) - - NSLayoutConstraint.activate([ - badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.heightAnchor.constraint(greaterThanOrEqualToConstant: 18), - badge.centerXAnchor.constraint(equalTo: centerXAnchor), - badge.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: 8) - ]) - - pointBadge = badge - } - - pointBadge?.text = text - pointBadge?.backgroundColor = color - pointBadge?.isHidden = false - } - - private func hidePointBadge() { - pointBadge?.isHidden = true - } - - // MARK: - Name Label - - private func showNameLabel(_ name: String) { - if nameLabelContainer == nil { - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.clipsToBounds = true - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .medium - ) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - blur.contentView.addSubview(label) - - addSubview(blur) - - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: blur.contentView.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.contentView.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.contentView.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.contentView.trailingAnchor, constant: -8), - - blur.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - blur.bottomAnchor.constraint(equalTo: circleView.topAnchor, constant: -4), - ]) - - nameLabelContainer = blur - nameLabel = label - } - - nameLabel?.text = name - nameLabelContainer?.isHidden = false - } - - private func hideNameLabel() { - nameLabelContainer?.isHidden = true - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onTap = nil - selectionRing.isHidden = true - hidePointBadge() - hideNameLabel() - alpha = 1.0 - accessibilityLabel = nil - clusteringIdentifier = Self.clusteringID - displayPriority = .defaultLow - } -} diff --git a/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift b/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift deleted file mode 100644 index ebff33c1b..000000000 --- a/MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import MapKit -import UIKit - -/// Crosshairs pin view for the simulated repeater target on the line of sight map -final class LOSRepeaterTargetPinView: MKAnnotationView { - static let reuseIdentifier = "LOSRepeaterTargetPinView" - - // MARK: - UI Components - - private let crosshairLayer = CAShapeLayer() - private var badgeLabel: UILabel? - private var badgeBackground: UIView? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - let size: CGFloat = 44 - let gapRadius: CGFloat = 4 - let outerRadius = size / 2 - - frame = CGRect(x: 0, y: 0, width: size, height: size + 24) - centerOffset = CGPoint(x: 0, y: 12) - canShowCallout = false - displayPriority = .required - - // Crosshair lines - let center = CGPoint(x: size / 2, y: size / 2) - let path = UIBezierPath() - - // Top - path.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) - path.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) - // Bottom - path.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) - path.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) - // Left - path.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) - path.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) - // Right - path.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) - path.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) - - crosshairLayer.path = path.cgPath - crosshairLayer.strokeColor = UIColor.systemPurple.cgColor - crosshairLayer.lineWidth = 2 - crosshairLayer.fillColor = nil - crosshairLayer.shadowColor = UIColor.black.cgColor - crosshairLayer.shadowOpacity = 0.3 - crosshairLayer.shadowRadius = 2 - crosshairLayer.shadowOffset = CGSize(width: 0, height: 2) - layer.addSublayer(crosshairLayer) - - // "R" badge below - let bg = UIView() - bg.translatesAutoresizingMaskIntoConstraints = false - bg.backgroundColor = .systemPurple - bg.layer.cornerRadius = 9 - addSubview(bg) - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - let baseFont = UIFont.systemFont( - ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, - weight: .bold - ) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .white - label.textAlignment = .center - label.text = "R" - bg.addSubview(label) - - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: bg.topAnchor, constant: 2), - label.bottomAnchor.constraint(equalTo: bg.bottomAnchor, constant: -2), - label.leadingAnchor.constraint(equalTo: bg.leadingAnchor, constant: 6), - label.trailingAnchor.constraint(equalTo: bg.trailingAnchor, constant: -6), - - bg.centerXAnchor.constraint(equalTo: centerXAnchor), - bg.topAnchor.constraint(equalTo: topAnchor, constant: size + 2) - ]) - - badgeLabel = label - badgeBackground = bg - - isAccessibilityElement = true - accessibilityTraits = .image - accessibilityLabel = L10n.Tools.Tools.LineOfSight.repeater - accessibilityHint = L10n.Tools.Tools.LineOfSight.RepeaterTarget.accessibilityHint - } - - // MARK: - Configuration - - func configure(opacity: CGFloat) { - alpha = opacity - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - alpha = 1.0 - } -} diff --git a/MC1/Views/LineOfSight/PointHeightEditorView.swift b/MC1/Views/LineOfSight/PointHeightEditorView.swift new file mode 100644 index 000000000..064200342 --- /dev/null +++ b/MC1/Views/LineOfSight/PointHeightEditorView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct PointHeightEditorView: View { + var viewModel: LineOfSightViewModel + let point: SelectedPoint + let pointID: PointID + + var body: some View { + HeightEditorGrid( + groundElevation: point.groundElevation, + additionalHeight: Binding( + get: { point.additionalHeight }, + set: { viewModel.updateAdditionalHeight(for: pointID, meters: $0) } + ), + range: 0...200 + ) + } +} diff --git a/MC1/Views/LineOfSight/PointRowButtonsView.swift b/MC1/Views/LineOfSight/PointRowButtonsView.swift new file mode 100644 index 000000000..b3754a6a0 --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -0,0 +1,103 @@ +import CoreLocation +import MapKit +import SwiftUI + +struct PointRowButtonsView: View { + var viewModel: LineOfSightViewModel + let pointID: PointID + let isEditing: Bool + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void + + private let iconButtonSize: CGFloat = 22 + + private var coordinate: CLLocationCoordinate2D? { + switch pointID { + case .pointA: viewModel.pointA?.coordinate + case .pointB: viewModel.pointB?.coordinate + case .repeater: viewModel.repeaterPoint?.coordinate + } + } + + var body: some View { + // Share menu + Menu { + if let coord = coordinate { + Button(L10n.Tools.Tools.LineOfSight.openInMaps, systemImage: "map") { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coord)) + mapItem.name = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeaterLocation + } + mapItem.openInMaps() + } + + Button(L10n.Tools.Tools.LineOfSight.copyCoordinates, systemImage: "doc.on.doc") { + copyHapticTrigger += 1 + UIPasteboard.general.string = coord.formattedString + } + + ShareLink(item: coord.formattedString) { + Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") + } + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.shareLabel, systemImage: "square.and.arrow.up") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .controlSize(.small) + + // Relocate button (toggles on/off) + Button { + if viewModel.relocatingPoint == pointID { + viewModel.relocatingPoint = nil + } else { + viewModel.relocatingPoint = pointID + onRelocate() + } + } label: { + Label(L10n.Tools.Tools.LineOfSight.relocate, systemImage: "mappin") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) + + // Edit/Done toggle + Button { + withAnimation { + editingPoint = isEditing ? nil : pointID + } + } label: { + Group { + if isEditing { + Label(L10n.Tools.Tools.LineOfSight.done, systemImage: "checkmark") + .labelStyle(.iconOnly) + } else { + Label(L10n.Tools.Tools.LineOfSight.edit, systemImage: "ruler") + .labelStyle(.iconOnly) + .rotationEffect(.degrees(90)) + } + } + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + + // Clear button + Button(action: onClear) { + Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(width: iconButtonSize, height: iconButtonSize) + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } +} diff --git a/MC1/Views/LineOfSight/PointRowView.swift b/MC1/Views/LineOfSight/PointRowView.swift new file mode 100644 index 000000000..c94fd256a --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct PointRowView: View { + var viewModel: LineOfSightViewModel + let label: String + let color: Color + let point: SelectedPoint? + let pointID: PointID + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + let onClear: () -> Void + + private var isEditing: Bool { editingPoint == pointID } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row (always visible) + HStack { + // Point marker + Circle() + .fill(point != nil ? color : .gray.opacity(0.3)) + .frame(width: 24, height: 24) + .overlay { + Text(label) + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + // Point info + if let point { + VStack(alignment: .leading, spacing: 2) { + Text(point.displayName) + .font(.subheadline) + .lineLimit(1) + + if point.isLoadingElevation { + HStack(spacing: 4) { + ProgressView() + .controlSize(.mini) + Text(L10n.Tools.Tools.LineOfSight.loadingElevation) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let elevation = point.groundElevation { + Text(Measurement( + value: Double(Int(elevation) + point.additionalHeight), + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + PointRowButtonsView( + viewModel: viewModel, + pointID: pointID, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: onClear + ) + } else { + Text(L10n.Tools.Tools.LineOfSight.notSelected) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + } + } + + // Expanded editor (when editing) + if isEditing, let point { + Divider() + + PointHeightEditorView(viewModel: viewModel, point: point, pointID: pointID) + } + } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } +} diff --git a/MC1/Views/LineOfSight/PointsSummarySectionView.swift b/MC1/Views/LineOfSight/PointsSummarySectionView.swift new file mode 100644 index 000000000..e1646e794 --- /dev/null +++ b/MC1/Views/LineOfSight/PointsSummarySectionView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct PointsSummarySectionView: View { + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + + private var isRelocating: Bool { viewModel.relocatingPoint != nil } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with optional cancel button + HStack { + Text(L10n.Tools.Tools.LineOfSight.points) + .font(.headline) + + Spacer() + + if isRelocating { + Button(L10n.Tools.Tools.LineOfSight.cancel) { + viewModel.relocatingPoint = nil + } + .liquidGlassSecondaryButtonStyle() + .controlSize(.small) + } + } + + // Show relocating message OR point rows + if let relocatingPoint = viewModel.relocatingPoint { + relocatingMessageView(for: relocatingPoint) + } else { + // Point A row + PointRowView( + viewModel: viewModel, + label: "A", + color: .blue, + point: viewModel.pointA, + pointID: .pointA, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointA() } + ) + + // Repeater row (placeholder or full, positioned between A and B) + // Inline check for repeaterPoint to ensure SwiftUI properly tracks the dependency + if let repeater = viewModel.repeaterPoint { + RepeaterRowView( + viewModel: viewModel, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate + ) + .id("repeater-\(repeater.coordinate.latitude)-\(repeater.coordinate.longitude)") + } else if viewModel.shouldShowRepeaterPlaceholder { + AddRepeaterRowView { + viewModel.addRepeater() + viewModel.analyzeWithRepeater() + } + } + + // Point B row + PointRowView( + viewModel: viewModel, + label: "B", + color: .green, + point: viewModel.pointB, + pointID: .pointB, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearPointB() } + ) + + if viewModel.pointA == nil || viewModel.pointB == nil { + Text(L10n.Tools.Tools.LineOfSight.selectPointsHint) + .font(.caption) + .foregroundStyle(.secondary) + } + + if viewModel.elevationFetchFailed { + Label( + L10n.Tools.Tools.LineOfSight.elevationUnavailable, + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } + } + } + + @ViewBuilder + private func relocatingMessageView(for pointID: PointID) -> some View { + let pointName: String = switch pointID { + case .pointA: L10n.Tools.Tools.LineOfSight.pointA + case .pointB: L10n.Tools.Tools.LineOfSight.pointB + case .repeater: L10n.Tools.Tools.LineOfSight.repeater + } + + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Tools.Tools.LineOfSight.relocating(pointName)) + .font(.subheadline) + .bold() + + Text(L10n.Tools.Tools.LineOfSight.tapMapInstruction) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(L10n.Tools.Tools.LineOfSight.relocating(pointName)) \(L10n.Tools.Tools.LineOfSight.tapMapInstruction)") + } +} diff --git a/MC1/Views/LineOfSight/RFSettingsSectionView.swift b/MC1/Views/LineOfSight/RFSettingsSectionView.swift new file mode 100644 index 000000000..4855b08f7 --- /dev/null +++ b/MC1/Views/LineOfSight/RFSettingsSectionView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct RFSettingsSectionView: View { + @Bindable var viewModel: LineOfSightViewModel + @Binding var isRFSettingsExpanded: Bool + + var body: some View { + DisclosureGroup(isExpanded: $isRFSettingsExpanded) { + VStack(spacing: 12) { + // Frequency input - extracted to separate view for @FocusState to work in sheet + FrequencyInputRow(viewModel: viewModel) + + Divider() + + // Refraction k-factor picker + HStack { + Label(L10n.Tools.Tools.LineOfSight.refraction, systemImage: "globe") + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: Binding( + get: { viewModel.refractionK }, + set: { viewModel.refractionK = $0 } + )) { + Text(L10n.Tools.Tools.LineOfSight.Refraction.none).tag(1.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.standard).tag(4.0 / 3.0) + Text(L10n.Tools.Tools.LineOfSight.Refraction.ducting).tag(4.0) + } + .pickerStyle(.menu) + } + } + .padding(.top, 8) + } label: { + Label(L10n.Tools.Tools.LineOfSight.rfSettings, systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) + } + .tint(.primary) + } +} diff --git a/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift new file mode 100644 index 000000000..3a68f1747 --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct RepeaterHeightEditorView: View { + var viewModel: LineOfSightViewModel + let repeaterPoint: RepeaterPoint + + var body: some View { + HeightEditorGrid( + groundElevation: viewModel.repeaterGroundElevation, + additionalHeight: Binding( + get: { repeaterPoint.additionalHeight }, + set: { viewModel.updateRepeaterHeight(meters: $0) } + ), + range: 0...200, + onHeightChanged: { viewModel.analyzeWithRepeater() } + ) + } +} diff --git a/MC1/Views/LineOfSight/RepeaterRowView.swift b/MC1/Views/LineOfSight/RepeaterRowView.swift new file mode 100644 index 000000000..3abcf2bd8 --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct RepeaterRowView: View { + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + + private var isEditing: Bool { editingPoint == .repeater } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack { + // Repeater marker (purple) + Circle() + .fill(.purple) + .frame(width: 24, height: 24) + .overlay { + Text("R") + .font(.caption) + .bold() + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Tools.Tools.LineOfSight.repeater) + .font(.subheadline) + .lineLimit(1) + + if let elevation = viewModel.repeaterGroundElevation { + let totalHeight = Int(elevation) + (viewModel.repeaterPoint?.additionalHeight ?? 0) + Text(Measurement( + value: Double(totalHeight), + unit: UnitLength.meters + ).formatted()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + PointRowButtonsView( + viewModel: viewModel, + pointID: .repeater, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearRepeater() } + ) + } + + // Expanded editor + if isEditing, let repeaterPoint = viewModel.repeaterPoint { + Divider() + RepeaterHeightEditorView(viewModel: viewModel, repeaterPoint: repeaterPoint) + } + } + .padding(12) + .animation(.easeInOut(duration: 0.2), value: isEditing) + } +} diff --git a/MC1/Views/LineOfSight/TerrainProfileSectionView.swift b/MC1/Views/LineOfSight/TerrainProfileSectionView.swift new file mode 100644 index 000000000..45c7075b5 --- /dev/null +++ b/MC1/Views/LineOfSight/TerrainProfileSectionView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct TerrainProfileSectionView: View { + var viewModel: LineOfSightViewModel + @Binding var showDragHint: Bool + @Binding var repeaterMarkerCenter: CGPoint? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.Tools.Tools.LineOfSight.terrainProfile) + .font(.headline) + + Spacer() + + Label( + L10n.Tools.Tools.LineOfSight.earthCurvature(LOSFormatters.formatKFactor(viewModel.refractionK)), + systemImage: "globe" + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + + TerrainProfileCanvas( + elevationProfile: viewModel.terrainElevationProfile, + profileSamples: viewModel.profileSamples, + profileSamplesRB: viewModel.profileSamplesRB, + // Show repeater marker for both on-path and off-path + repeaterPathFraction: viewModel.repeaterVisualizationPathFraction, + repeaterHeight: viewModel.repeaterPoint.map { Double($0.additionalHeight) }, + // Only enable drag for on-path repeaters + onRepeaterDrag: viewModel.repeaterPoint?.isOnPath == true ? { pathFraction in + viewModel.updateRepeaterPosition(pathFraction: pathFraction) + viewModel.analyzeWithRepeater() + } : nil, + onRepeaterMarkerPosition: { center in + repeaterMarkerCenter = center + }, + // Off-path segment distances for separator and labels + segmentARDistanceMeters: viewModel.segmentARDistanceMeters, + segmentRBDistanceMeters: viewModel.segmentRBDistanceMeters + ) + .overlay { + if showDragHint, let center = repeaterMarkerCenter { + Text(L10n.Tools.Tools.LineOfSight.dragToAdjust) + .font(.caption) + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.regularMaterial, in: .capsule) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + .transition(.opacity.combined(with: .scale)) + .position(x: center.x, y: center.y + 30) + } + } + } + } +} diff --git a/MC1/Views/Map/ContactAnnotation.swift b/MC1/Views/Map/ContactAnnotation.swift deleted file mode 100644 index db5b65f52..000000000 --- a/MC1/Views/Map/ContactAnnotation.swift +++ /dev/null @@ -1,41 +0,0 @@ -import MapKit -import MC1Services - -/// MKAnnotation wrapper for ContactDTO to display on MKMapView -final class ContactAnnotation: NSObject, MKAnnotation { - let contact: ContactDTO - - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D(latitude: contact.latitude, longitude: contact.longitude) - } - - var title: String? { contact.displayName } - - var subtitle: String? { - switch contact.type { - case .chat: - contact.isFavorite ? L10n.Map.Map.Annotation.favorite : nil - case .repeater: - L10n.Map.Map.Annotation.repeater - case .room: - L10n.Map.Map.Annotation.room - } - } - - init(contact: ContactDTO) { - self.contact = contact - super.init() - } -} - -extension ContactAnnotation { - /// Unique identifier for comparing annotations - override var hash: Int { - contact.id.hashValue - } - - override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? ContactAnnotation else { return false } - return contact.id == other.contact.id - } -} diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 11b5b897c..4dc401f11 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -1,7 +1,7 @@ import SwiftUI import MC1Services -/// SwiftUI content view displayed inside the native MKAnnotationView callout +/// SwiftUI content displayed in a popover callout when a map pin is tapped struct ContactCalloutContent: View { let contact: ContactDTO let onDetail: () -> Void @@ -9,14 +9,17 @@ struct ContactCalloutContent: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - // Type indicator only (name is in native callout title) + Text(contact.displayName) + .font(.headline) + HStack(spacing: 6) { - Image(systemName: typeIconName) - .foregroundStyle(typeColor) + Image(systemName: contact.type.iconSystemName) + .foregroundStyle(contact.type.displayColor) Text(typeDisplayName) .font(.subheadline) .foregroundStyle(.secondary) } + .accessibilityElement(children: .combine) Divider() @@ -24,44 +27,22 @@ struct ContactCalloutContent: View { VStack(spacing: 6) { Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) .buttonStyle(.bordered) - .controlSize(.small) + .accessibilityHint(contact.displayName) if contact.type == .chat || contact.type == .room { Button(L10n.Map.Map.Callout.message, systemImage: "message.fill", action: onMessage) .buttonStyle(.bordered) - .controlSize(.small) + .accessibilityHint(contact.displayName) } } .frame(maxWidth: .infinity) } .padding(12) - .frame(width: 160) + .frame(minWidth: 160) } // MARK: - Computed Properties - private var typeIconName: String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } - - private var typeColor: Color { - switch contact.type { - case .chat: - .blue - case .repeater: - .green - case .room: - .purple - } - } - private var typeDisplayName: String { switch contact.type { case .chat: diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift new file mode 100644 index 000000000..1a535b687 --- /dev/null +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -0,0 +1,209 @@ +import SwiftUI +import MC1Services + +// MARK: - Contact Detail Sheet + +struct ContactDetailSheet: View { + let contact: ContactDTO + let onMessage: () -> Void + @Environment(\.dismiss) private var dismiss + @Environment(\.appState) private var appState + + /// Sheet types for repeater flows + private enum ActiveSheet: Identifiable, Hashable { + case telemetryAuth + case telemetryStatus(RemoteNodeSessionDTO) + case adminAuth + case adminSettings(RemoteNodeSessionDTO) + case roomJoin + + var id: String { + switch self { + case .telemetryAuth: "telemetryAuth" + case .telemetryStatus(let s): "telemetryStatus-\(s.id)" + case .adminAuth: "adminAuth" + case .adminSettings(let s): "adminSettings-\(s.id)" + case .roomJoin: "roomJoin" + } + } + } + + @State private var activeSheet: ActiveSheet? + @State private var pendingSheet: ActiveSheet? + + var body: some View { + NavigationStack { + List { + // Basic info section + Section(L10n.Map.Map.Detail.Section.contactInfo) { + LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) + + LabeledContent(L10n.Map.Map.Detail.type) { + HStack { + Image(systemName: contact.type.iconSystemName) + Text(typeDisplayName) + } + .foregroundStyle(contact.type.displayColor) + } + + if contact.isFavorite { + LabeledContent(L10n.Map.Map.Detail.status) { + HStack { + Image(systemName: "star.fill") + Text(L10n.Map.Map.Detail.favorite) + } + .foregroundStyle(.orange) + } + } + + if contact.lastAdvertTimestamp > 0 { + LabeledContent(L10n.Map.Map.Detail.lastAdvert) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) + } + } + } + + // Location section + Section(L10n.Map.Map.Detail.Section.location) { + LabeledContent(L10n.Map.Map.Detail.latitude) { + Text(contact.latitude, format: .number.precision(.fractionLength(6))) + } + + LabeledContent(L10n.Map.Map.Detail.longitude) { + Text(contact.longitude, format: .number.precision(.fractionLength(6))) + } + } + + // Path info section + Section(L10n.Map.Map.Detail.Section.networkPath) { + if contact.isFloodRouted { + LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) + } else { + let hopCount = contact.pathHopCount + LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) + } + } + + // Actions section + Section { + switch contact.type { + case .repeater: + Button { + activeSheet = .telemetryAuth + } label: { + Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: appState.connectionState) + + Button { + activeSheet = .adminAuth + } label: { + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") + } + .radioDisabled(for: appState.connectionState) + + case .room: + Button { + activeSheet = .roomJoin + } label: { + Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") + } + .radioDisabled(for: appState.connectionState) + + case .chat: + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") + } + .radioDisabled(for: appState.connectionState) + } + } + } + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + dismiss() + } + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .telemetryAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet( + contact: contact, + role: role, + customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle + ) { session in + pendingSheet = .telemetryStatus(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .telemetryStatus(let session): + RepeaterStatusView(session: session) + + case .adminAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + pendingSheet = .adminSettings(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .adminSettings(let session): + NavigationStack { + RepeaterSettingsView(session: session) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + activeSheet = nil + } + } + } + } + .presentationSizing(.page) + + case .roomJoin: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + activeSheet = nil + dismiss() + appState.navigation.navigateToRoom(with: session) + } + .presentationSizing(.page) + } + } + } + } + } + + // MARK: - Sheet Management + + private func presentPendingSheet() { + if let next = pendingSheet { + pendingSheet = nil + activeSheet = next + } + } + + // MARK: - Computed Properties + + private var typeDisplayName: String { + switch contact.type { + case .chat: + L10n.Map.Map.NodeKind.chatContact + case .repeater: + L10n.Map.Map.NodeKind.repeater + case .room: + L10n.Map.Map.NodeKind.room + } + } + +} diff --git a/MC1/Views/Map/ContactNameLabel.swift b/MC1/Views/Map/ContactNameLabel.swift deleted file mode 100644 index 462a32539..000000000 --- a/MC1/Views/Map/ContactNameLabel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import SwiftUI -import MC1Services - -/// Small label displaying contact name above map pins -struct ContactNameLabel: View { - let name: String - - var body: some View { - Text(name) - .font(.caption2) - .fontWeight(.medium) - .lineLimit(1) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.regularMaterial, in: .capsule) - .shadow(color: .black.opacity(0.25), radius: 3, y: 1.5) - } -} - -#Preview { - VStack(spacing: 20) { - ContactNameLabel(name: "Alice") - ContactNameLabel(name: "Hilltop Repeater Station") - ContactNameLabel(name: "Emergency Room") - } - .padding() -} diff --git a/MC1/Views/Map/ContactPinView.swift b/MC1/Views/Map/ContactPinView.swift deleted file mode 100644 index e0db5ca47..000000000 --- a/MC1/Views/Map/ContactPinView.swift +++ /dev/null @@ -1,298 +0,0 @@ -import MapKit -import SwiftUI -import MC1Services - -/// Custom annotation view displaying a colored circle with icon and pointer triangle -final class ContactPinView: MKAnnotationView { - static let reuseIdentifier = "ContactPinView" - - // MARK: - UI Components - - private let circleView = UIView() - private let iconImageView = UIImageView() - private let triangleImageView = UIImageView() - private var nameLabel: UILabel? - private var nameLabelContainer: UIView? - private var nameLabelShadow: UIView? - private var hostingController: UIHostingController? - - // MARK: - Configuration - - var showsNameLabel: Bool = false { - didSet { updateNameLabel() } - } - - /// Callbacks for callout actions - var onDetail: (() -> Void)? - var onMessage: (() -> Void)? - - // MARK: - Initialization - - override init(annotation: (any MKAnnotation)?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupViews() - canShowCallout = true - clusteringIdentifier = "contact" - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupViews() { - // Configure circle - circleView.translatesAutoresizingMaskIntoConstraints = false - circleView.layer.shadowColor = UIColor.black.cgColor - circleView.layer.shadowOpacity = 0.3 - circleView.layer.shadowRadius = 2 - circleView.layer.shadowOffset = CGSize(width: 0, height: 2) - addSubview(circleView) - - // Configure icon - iconImageView.translatesAutoresizingMaskIntoConstraints = false - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = .white - circleView.addSubview(iconImageView) - - // Configure triangle pointer - triangleImageView.translatesAutoresizingMaskIntoConstraints = false - triangleImageView.contentMode = .scaleAspectFit - triangleImageView.image = UIImage(systemName: "triangle.fill") - triangleImageView.transform = CGAffineTransform(rotationAngle: .pi) - addSubview(triangleImageView) - - // Initial layout for unselected state - updateLayout(selected: false) - } - - // MARK: - Configuration - - func configure(for contact: ContactDTO) { - // Set colors based on contact type - let backgroundColor = pinColor(for: contact) - circleView.backgroundColor = backgroundColor - triangleImageView.tintColor = backgroundColor - - // Set icon - let iconName = iconName(for: contact) - iconImageView.image = UIImage(systemName: iconName) - - // Set display priority - displayPriority = contact.isFavorite ? .defaultHigh : .defaultLow - - // Update layout - updateLayout(selected: isSelected) - } - - // MARK: - Selection - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - if animated { - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - self.updateLayout(selected: selected) - } - } else { - updateLayout(selected: selected) - } - - // Update name label visibility since it depends on isSelected state - updateNameLabel() - - // Configure callout content when selected - if selected, let contactAnnotation = annotation as? ContactAnnotation { - configureCalloutContent(for: contactAnnotation.contact) - } - } - - private func configureCalloutContent(for contact: ContactDTO) { - let calloutContent = ContactCalloutContent( - contact: contact, - onDetail: { [weak self] in self?.onDetail?() }, - onMessage: { [weak self] in self?.onMessage?() } - ) - - let hosting = UIHostingController(rootView: calloutContent) - hosting.view.backgroundColor = .clear - - // Size the hosting view - MKMapView uses intrinsic content size for callout layout - let size = hosting.sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) - hosting.view.frame = CGRect(origin: .zero, size: size) - - detailCalloutAccessoryView = hosting.view - hostingController = hosting - } - - // MARK: - Layout - - private func updateLayout(selected: Bool) { - let circleSize: CGFloat = selected ? 44 : 36 - let iconSize: CGFloat = selected ? 20 : 16 - let triangleSize: CGFloat = 10 - - // Remove existing constraints - circleView.constraints.forEach { circleView.removeConstraint($0) } - iconImageView.constraints.forEach { iconImageView.removeConstraint($0) } - triangleImageView.constraints.forEach { triangleImageView.removeConstraint($0) } - - // Circle constraints - NSLayoutConstraint.activate([ - circleView.widthConstraint(circleSize), - circleView.heightConstraint(circleSize), - circleView.centerXAnchor.constraint(equalTo: centerXAnchor), - circleView.topAnchor.constraint(equalTo: topAnchor) - ]) - - // Icon constraints - NSLayoutConstraint.activate([ - iconImageView.widthConstraint(iconSize), - iconImageView.heightConstraint(iconSize), - iconImageView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), - iconImageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor) - ]) - - // Triangle constraints - NSLayoutConstraint.activate([ - triangleImageView.widthConstraint(triangleSize), - triangleImageView.heightConstraint(triangleSize), - triangleImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - triangleImageView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: -3) - ]) - - // Update circle corner radius - circleView.layer.cornerRadius = circleSize / 2 - - // Update border for selected state - if selected { - circleView.layer.borderWidth = 3 - circleView.layer.borderColor = UIColor.white.cgColor - } else { - circleView.layer.borderWidth = 0 - } - - // Update frame - let totalHeight = circleSize + triangleSize - 3 - frame = CGRect(x: 0, y: 0, width: circleSize, height: totalHeight) - centerOffset = CGPoint(x: 0, y: -totalHeight / 2) - } - - // MARK: - Name Label - - private func updateNameLabel() { - if showsNameLabel && !isSelected { - if nameLabel == nil { - // Blur background matching app's material style - let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - blur.translatesAutoresizingMaskIntoConstraints = false - blur.layer.cornerRadius = 8 - blur.layer.masksToBounds = true - addSubview(blur) - - // Shadow container (separate from blur since blur clips) - let shadow = UIView() - shadow.translatesAutoresizingMaskIntoConstraints = false - shadow.backgroundColor = .clear - shadow.layer.shadowColor = UIColor.black.cgColor - shadow.layer.shadowOpacity = 0.3 - shadow.layer.shadowRadius = 3 - shadow.layer.shadowOffset = CGSize(width: 0, height: 1.5) - insertSubview(shadow, belowSubview: blur) - nameLabelContainer = blur - nameLabelShadow = shadow - - // Label with Dynamic Type support - let label = UILabel() - let baseFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .medium) - label.font = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: baseFont) - label.adjustsFontForContentSizeCategory = true - label.textColor = .label - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - blur.contentView.addSubview(label) - nameLabel = label - - NSLayoutConstraint.activate([ - blur.centerXAnchor.constraint(equalTo: centerXAnchor), - blur.bottomAnchor.constraint(equalTo: topAnchor, constant: -4), - shadow.topAnchor.constraint(equalTo: blur.topAnchor), - shadow.bottomAnchor.constraint(equalTo: blur.bottomAnchor), - shadow.leadingAnchor.constraint(equalTo: blur.leadingAnchor), - shadow.trailingAnchor.constraint(equalTo: blur.trailingAnchor), - label.topAnchor.constraint(equalTo: blur.topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: blur.bottomAnchor, constant: -4), - label.leadingAnchor.constraint(equalTo: blur.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: blur.trailingAnchor, constant: -8) - ]) - } - - if let contactAnnotation = annotation as? ContactAnnotation { - nameLabel?.text = contactAnnotation.contact.displayName - } - nameLabelContainer?.isHidden = false - nameLabelShadow?.isHidden = false - } else { - nameLabelContainer?.isHidden = true - nameLabelShadow?.isHidden = true - } - } - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - onDetail = nil - onMessage = nil - hostingController = nil - detailCalloutAccessoryView = nil - nameLabelContainer?.isHidden = true - nameLabelShadow?.isHidden = true - } - - override func prepareForDisplay() { - super.prepareForDisplay() - - if let contactAnnotation = annotation as? ContactAnnotation { - configure(for: contactAnnotation.contact) - } - } - - // MARK: - Helpers - - private func pinColor(for contact: ContactDTO) -> UIColor { - switch contact.type { - case .chat: - UIColor(red: 204.0 / 255.0, green: 122.0 / 255.0, blue: 92.0 / 255.0, alpha: 1) // coral #cc7a5c - case .repeater: - UIColor(red: 0, green: 170.0 / 255.0, blue: 1, alpha: 1) // MeshCore cyan #00aaff - case .room: - UIColor(red: 1, green: 136.0 / 255.0, blue: 0, alpha: 1) // orange #ff8800 (matches Nodes) - } - } - - private func iconName(for contact: ContactDTO) -> String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } -} - -// MARK: - Constraint Helpers - -private extension UIView { - func widthConstraint(_ constant: CGFloat) -> NSLayoutConstraint { - widthAnchor.constraint(equalToConstant: constant) - } - - func heightConstraint(_ constant: CGFloat) -> NSLayoutConstraint { - heightAnchor.constraint(equalToConstant: constant) - } -} diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index 55f4b08c9..4f577cd94 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -1,13 +1,20 @@ +import MapLibre import SwiftUI /// Dropdown menu for selecting map layers struct LayersMenu: View { + @Environment(\.appState) private var appState @Binding var selection: MapStyleSelection @Binding var isPresented: Bool + var viewportBounds: MLNCoordinateBounds? var body: some View { VStack(spacing: 0) { ForEach(MapStyleSelection.allCases, id: \.self) { style in + let isDisabled = !appState.offlineMapService.isNetworkAvailable + && (style.requiresNetwork + || !hasOfflineCoverage(for: style)) + Button { selection = style withAnimation { @@ -16,16 +23,18 @@ struct LayersMenu: View { } label: { HStack { Text(style.label) - .foregroundStyle(.primary) + .foregroundStyle(isDisabled ? .secondary : .primary) Spacer() if selection == style { Image(systemName: "checkmark") - .foregroundStyle(.blue) + .foregroundStyle(.tint) } } .padding(.horizontal, 16) .padding(.vertical, 12) } + .disabled(isDisabled) + .accessibilityHint(isDisabled ? disabledReason(for: style) : "") if style != MapStyleSelection.allCases.last { Divider() @@ -35,6 +44,24 @@ struct LayersMenu: View { .frame(width: 140) .liquidGlass(in: .rect(cornerRadius: 12)) .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.Map.Map.Style.accessibilityLabel) + } + + private func hasOfflineCoverage(for style: MapStyleSelection) -> Bool { + if let viewportBounds { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer, overlapping: viewportBounds) + } else { + appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) + } + } + + private func disabledReason(for style: MapStyleSelection) -> String { + if style.requiresNetwork { + L10n.Map.Map.Style.requiresNetwork + } else { + L10n.Map.Map.Style.noOfflineCoverage + } } } @@ -44,4 +71,5 @@ struct LayersMenu: View { isPresented: .constant(true) ) .padding() + .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift new file mode 100644 index 000000000..094c46aaa --- /dev/null +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -0,0 +1,389 @@ +import MapLibre +import UIKit + +/// Font stack available on the OpenFreeMap glyph server. +/// MapLibre's default ("Open Sans Regular") returns 404, causing silent symbol dropout. +/// Safety: immutable after initialization, only read from @MainActor coordinator methods. +private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: ["Noto Sans Regular"]) + +// MARK: - Layer and source identifiers + +enum MapLayerID { + static let clusterCircles = "cluster-circles" + static let clusterLabels = "cluster-labels" + static let unclusteredIcons = "unclustered-icons" + static let nameLabels = "name-labels" + static let badgeText = "badge-text" + static let fixedIcons = "fixed-icons" + static let fixedNameLabels = "fixed-name-labels" + static let fixedBadgeText = "fixed-badge-text" + static let lineLOS = "line-los" + static let lineTraceUntraced = "line-trace-untraced" + static let lineTraceWeak = "line-trace-weak" + static let lineTraceMedium = "line-trace-medium" + static let lineTraceGood = "line-trace-good" + static let lineTraceUntracedCasing = "line-trace-untraced-casing" + static let lineTraceWeakCasing = "line-trace-weak-casing" + static let lineTraceMediumCasing = "line-trace-medium-casing" + static let lineTraceGoodCasing = "line-trace-good-casing" + static let satelliteLayer = "satellite-layer" + static let topoLayer = "topo-layer" +} + +enum MapSourceID { + static let points = "points" + static let fixedPoints = "fixed-points" + static let lines = "lines" + static let satelliteTiles = "satellite-tiles" + static let topoTiles = "topo-tiles" +} + +extension MC1MapView.Coordinator { + + // MARK: - Update point source data + + /// Point sources and layers use deferred creation: they are created here + /// on first data arrival, not during style load. This avoids a MapLibre + /// bug where sources initialized without features ignore later `.shape` + /// updates. + func updatePointSource(mapView: MLNMapView) { + guard let style = mapView.style else { return } + + var clusterablePoints: [MapPoint] = [] + var fixedPoints: [MapPoint] = [] + for point in currentPoints { + if point.isClusterable { + clusterablePoints.append(point) + } else { + fixedPoints.append(point) + } + } + + // Clustered source — deferred creation on first data arrival + if let source = clusterSource { + source.shape = MLNShapeCollectionFeature( + shapes: clusterablePoints.map { pointFeature(for: $0) } + ) + } else if !clusterablePoints.isEmpty { + let features = clusterablePoints.map { pointFeature(for: $0) } + let source = MLNShapeSource( + identifier: MapSourceID.points, + features: features, + options: [ + .clustered: true, + .clusterRadius: 44, + .maximumZoomLevelForClustering: 14, + ] + ) + style.addSource(source) + self.clusterSource = source + addClusteredPointLayers(source: source, style: style) + } + + // Fixed source — deferred creation + if let source = fixedSource { + source.shape = MLNShapeCollectionFeature( + shapes: fixedPoints.map { pointFeature(for: $0) } + ) + } else if !fixedPoints.isEmpty { + let features = fixedPoints.map { pointFeature(for: $0) } + let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) + style.addSource(source) + self.fixedSource = source + addFixedPointLayers(source: source, style: style) + } + } + + func updateLabelVisibility(mapView: MLNMapView, showLabels: Bool) { + for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { + guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } + layer.isVisible = showLabels + } + } + + // MARK: - Clustered point layers + + private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { + // Cluster circles + let circleLayer = MLNCircleStyleLayer(identifier: MapLayerID.clusterCircles, source: source) + circleLayer.predicate = NSPredicate(format: "cluster == YES") + let radiusStops: [NSNumber: NSNumber] = [0: 18, 50: 24, 100: 30, 200: 38] + circleLayer.circleRadius = NSExpression( + forMLNStepping: NSExpression(forKeyPath: "point_count"), + from: NSExpression(forConstantValue: 18), + stops: NSExpression(forConstantValue: radiusStops) + ) + circleLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue) + circleLayer.circleOpacity = NSExpression(forConstantValue: 0.85) + circleLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.8)) + circleLayer.circleStrokeWidth = NSExpression(forConstantValue: 2) + style.addLayer(circleLayer) + + // Cluster count labels + let clusterLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.clusterLabels, source: source) + clusterLabelLayer.predicate = NSPredicate(format: "cluster == YES") + clusterLabelLayer.text = NSExpression(format: "CAST(point_count, 'NSString')") + clusterLabelLayer.textColor = NSExpression(forConstantValue: UIColor.white) + clusterLabelLayer.textFontSize = NSExpression(forConstantValue: 13) + clusterLabelLayer.textFontNames = mapFontNames + clusterLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + clusterLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + style.addLayer(clusterLabelLayer) + + // Unclustered pin icons + let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) + iconLayer.predicate = NSPredicate(format: "cluster != YES") + iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + iconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + iconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + iconLayer.text = nil + style.addLayer(iconLayer) + + // Name labels (above pins) with pill background + let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") + configureNameLabelLayer(nameLabelLayer) + style.addLayer(nameLabelLayer) + + // Stats badge text (trace path midpoints) with pill background + let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) + badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") + configureBadgeLayer(badgeLayer) + style.addLayer(badgeLayer) + } + + // MARK: - Fixed point layers + + private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { + let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) + fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") + fixedIconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") + fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + fixedIconLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + fixedIconLayer.text = nil + style.addLayer(fixedIconLayer) + + let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) + fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") + configureNameLabelLayer(fixedNameLayer) + style.addLayer(fixedNameLayer) + + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) + fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") + configureBadgeLayer(fixedBadgeLayer) + style.addLayer(fixedBadgeLayer) + } + + // MARK: - Line layers + + func setupLineLayers(style: MLNStyle) { + guard style.source(withIdentifier: MapSourceID.lines) == nil else { return } + let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) + style.addSource(source) + + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) + losLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.los.rawValue) + losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) + losLayer.lineWidth = NSExpression(forConstantValue: 3) + losLayer.lineDashPattern = NSExpression(forConstantValue: [8, 4]) + losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") + style.addLayer(losLayer) + + let white = NSExpression(forConstantValue: UIColor.white) + let casingOpacity = NSExpression(forConstantValue: 0.8) + let roundJoin = NSExpression(forConstantValue: "round") + let roundCap = NSExpression(forConstantValue: "round") + + // Untraced: width 2, dash [8, 6] → casing width 5, dash scaled by 2/5 + let untracedCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntracedCasing, source: source) + untracedCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedCasing.lineColor = white + untracedCasing.lineOpacity = casingOpacity + untracedCasing.lineWidth = NSExpression(forConstantValue: 5) + untracedCasing.lineDashPattern = NSExpression(forConstantValue: [1.6, 1.2]) + untracedCasing.lineJoin = roundJoin + untracedCasing.lineCap = roundCap + style.addLayer(untracedCasing) + + let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) + untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) + untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) + untracedLayer.lineWidth = NSExpression(forConstantValue: 2) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [4, 3]) + style.addLayer(untracedLayer) + + // Weak: width 3, dash [4, 4] → casing width 6, dash scaled by 3/6 + let weakCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeakCasing, source: source) + weakCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakCasing.lineColor = white + weakCasing.lineOpacity = casingOpacity + weakCasing.lineWidth = NSExpression(forConstantValue: 6) + weakCasing.lineDashPattern = NSExpression(forConstantValue: [1, 1]) + weakCasing.lineJoin = roundJoin + weakCasing.lineCap = roundCap + style.addLayer(weakCasing) + + let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) + weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) + weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [2, 2]) + style.addLayer(weakLayer) + + // Medium: width 3, dash [12, 4] → casing width 6, dash scaled by 3/6 + let mediumCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMediumCasing, source: source) + mediumCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumCasing.lineColor = white + mediumCasing.lineOpacity = casingOpacity + mediumCasing.lineWidth = NSExpression(forConstantValue: 6) + mediumCasing.lineDashPattern = NSExpression(forConstantValue: [3, 1]) + mediumCasing.lineJoin = roundJoin + mediumCasing.lineCap = roundCap + style.addLayer(mediumCasing) + + let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) + mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) + mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [6, 2]) + style.addLayer(mediumLayer) + + // Good: width 4, solid → casing width 7 + let goodCasing = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGoodCasing, source: source) + goodCasing.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodCasing.lineColor = white + goodCasing.lineOpacity = casingOpacity + goodCasing.lineWidth = NSExpression(forConstantValue: 7) + goodCasing.lineJoin = roundJoin + goodCasing.lineCap = roundCap + style.addLayer(goodCasing) + + let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) + goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) + goodLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGreen) + goodLayer.lineWidth = NSExpression(forConstantValue: 4) + style.addLayer(goodLayer) + } + + func updateLineSource(mapView: MLNMapView) { + guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } + + let features = currentLines.map { line -> MLNPolylineFeature in + var coords = line.coordinates + let feature = MLNPolylineFeature(coordinates: &coords, count: UInt(coords.count)) + feature.attributes = [ + "lineStyle": line.style.rawValue, + "segmentOpacity": line.opacity, + ] + return feature + } + source.shape = MLNShapeCollectionFeature(shapes: features) + } + + // MARK: - Raster tile sources + + func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { + guard style.source(withIdentifier: MapSourceID.satelliteTiles) == nil else { + updateRasterLayerVisibility(mapView: mapView) + return + } + let satSource = MLNRasterTileSource( + identifier: MapSourceID.satelliteTiles, + tileURLTemplates: [MapTileURLs.esriWorldImagery], + options: [ + .tileSize: 256, + .maximumZoomLevel: 19, + .attributionHTMLString: "Esri", + ] + ) + style.addSource(satSource) + let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) + satLayer.isVisible = false + style.addLayer(satLayer) + + let topoSource = MLNRasterTileSource( + identifier: MapSourceID.topoTiles, + tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], + options: [ + .tileSize: 256, + .maximumZoomLevel: 17, + .attributionHTMLString: "OpenTopoMap", + ] + ) + style.addSource(topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) + topoLayer.isVisible = false + style.addLayer(topoLayer) + + updateRasterLayerVisibility(mapView: mapView) + } + + func updateRasterLayerVisibility(mapView: MLNMapView) { + guard let style = mapView.style else { return } + style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo + } + + // MARK: - Shared layer configuration + + private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { + layer.iconImageName = NSExpression(forKeyPath: "labelSpriteName") + layer.iconAnchor = NSExpression(forConstantValue: "bottom") + layer.iconOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -48))) // -4.8 ems × 10pt font + layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") + layer.iconAllowsOverlap = NSExpression(forConstantValue: true) + layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + } + + private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "badgeText") + layer.textFontSize = NSExpression(forConstantValue: 11) + layer.textFontNames = mapFontNames + layer.textColor = NSExpression(forConstantValue: UIColor.black) + layer.textAllowsOverlap = NSExpression(forConstantValue: true) + layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconImageName = NSExpression(forConstantValue: "pill-bg") + layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + } + + // MARK: - Private helpers + + private func pointFeature(for point: MapPoint) -> MLNPointFeature { + let feature = MLNPointFeature() + feature.coordinate = point.coordinate + var attributes: [String: Any] = [ + "pointId": point.id.uuidString, + "spriteName": spriteName(for: point), + ] + if let label = point.label { + attributes["labelSpriteName"] = "\(PinSpriteRenderer.labelSpritePrefix)\(label)" + } + if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } + if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } + feature.attributes = attributes + return feature + } + + private func spriteName(for point: MapPoint) -> String { + switch point.pinStyle { + case .contactChat: "pin-chat" + case .contactRepeater: "pin-repeater" + case .contactRoom: "pin-room" + case .repeater: "pin-repeater" + case .repeaterRingBlue: "pin-repeater-ring-blue" + case .repeaterRingGreen: "pin-repeater-ring-green" + case .repeaterRingWhite: + if let hop = point.hopIndex { + "pin-repeater-ring-white-hop-\(min(hop, 20))" + } else { + "pin-repeater-ring-white" + } + case .pointA: "pin-point-a" + case .pointB: "pin-point-b" + case .crosshair: "pin-crosshair" + case .badge: "pin-badge" + } + } +} diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift new file mode 100644 index 000000000..496b29bba --- /dev/null +++ b/MC1/Views/Map/MC1MapView.swift @@ -0,0 +1,504 @@ +import MapLibre +import MapKit +import ObjectiveC +import OSLog +import SwiftUI + +private let logger = Logger(subsystem: "com.mc1", category: "MapPins") + +// MARK: - MapLibre Metal scale fix + +/// Workaround for a MapLibre bug where `MLNEffectiveScaleFactorForView` +/// computes `nativeBounds.width / bounds.width` — a ratio that breaks in +/// landscape because `nativeBounds` is fixed while `bounds` rotates. +/// We intercept both `setDrawableSize:` and `setContentScaleFactor:` on +/// MapLibre's internal Metal UIView so the wrong scale is never stored. +/// Upstream issue: https://github.com/maplibre/maplibre-native/issues/3214 +private enum MetalLayerScaleFix { + + static func apply(to mapView: MLNMapView) { + guard let metalView = findMetalView(in: mapView) else { return } + + let selector = NSSelectorFromString("setDrawableSize:") + guard metalView.responds(to: selector) else { return } + + guard let originalClass: AnyClass = object_getClass(metalView) else { return } + let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" + + let fixedClass: AnyClass + if let existing = objc_getClass(name) as? AnyClass { + fixedClass = existing + } else { + guard let subclass = objc_allocateClassPair(originalClass, name, 0) else { return } + addDrawableSizeOverride(to: subclass, originalClass: originalClass) + addContentScaleFactorOverride(to: subclass, originalClass: originalClass) + objc_registerClassPair(subclass) + fixedClass = subclass + } + + object_setClass(metalView, fixedClass) + } + + private static func findMetalView(in view: UIView) -> UIView? { + for subview in view.subviews where subview.layer is CAMetalLayer { + return subview + } + return nil + } + + private static func findMapView(from metalView: UIView) -> MLNMapView? { + var parent: UIView? = metalView.superview + while let v = parent, !(v is MLNMapView) { parent = v.superview } + return parent as? MLNMapView + } + + private static func addDrawableSizeOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setDrawableSize:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetDrawableSizeFn = @convention(c) (AnyObject, Selector, CGSize) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) + + let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in + guard let mapView = findMapView(from: metalView), + mapView.bounds.size.width > 0, + mapView.bounds.size.height > 0, + let screen = mapView.window?.screen else { + callOriginal(metalView, selector, proposedSize) + return + } + + let correctScale = screen.nativeScale + let correctSize = CGSize( + width: mapView.bounds.width * correctScale, + height: mapView.bounds.height * correctScale + ) + + // Avoid redundant drawable reallocation and layout loops. + if let layer = metalView.layer as? CAMetalLayer, + layer.drawableSize == correctSize { + return + } + + callOriginal(metalView, selector, correctSize) + } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } + + private static func addContentScaleFactorOverride( + to subclass: AnyClass, + originalClass: AnyClass + ) { + let selector = NSSelectorFromString("setContentScaleFactor:") + guard let original = class_getInstanceMethod(originalClass, selector) else { return } + let originalIMP = method_getImplementation(original) + typealias SetScaleFn = @convention(c) (AnyObject, Selector, CGFloat) -> Void + let callOriginal = unsafeBitCast(originalIMP, to: SetScaleFn.self) + + let block: @convention(block) (UIView, CGFloat) -> Void = { metalView, _ in + guard let mapView = findMapView(from: metalView), + let screen = mapView.window?.screen else { + return + } + + let correctScale = screen.nativeScale + if metalView.contentScaleFactor == correctScale { + return + } + + callOriginal(metalView, selector, correctScale) + } + + let imp = imp_implementationWithBlock(block) + class_addMethod(subclass, selector, imp, method_getTypeEncoding(original)) + } +} + +/// Applies the isa-swizzle once the view is attached to a window. +private final class ScaledMLNMapView: MLNMapView { + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + MetalLayerScaleFix.apply(to: self) + } +} + +struct MC1MapView: UIViewRepresentable { + // Data + let points: [MapPoint] + let lines: [MapLine] + let mapStyle: MapStyleSelection + let isDarkMode: Bool + var isOffline: Bool = false + + // Configuration + let showLabels: Bool + let showsUserLocation: Bool + let isInteractive: Bool + let showsScale: Bool + var isNorthLocked: Bool = false + + // Camera + @Binding var cameraRegion: MKCoordinateRegion? + let cameraRegionVersion: Int + var cameraEdgePadding: UIEdgeInsets = .zero + var cameraBottomSheetFraction: CGFloat? + + // Output callbacks + let onPointTap: ((MapPoint, CGPoint) -> Void)? + let onMapTap: ((CLLocationCoordinate2D) -> Void)? + let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + + // Optional features + var isStyleLoaded: Binding = .constant(true) + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = context.coordinator.mapView + mapView.delegate = context.coordinator + + mapView.showsUserLocation = showsUserLocation + mapView.compassViewPosition = .topRight + mapView.compassViewMargins = CGPoint(x: 8, y: 8) + mapView.attributionButtonPosition = .bottomLeft + mapView.attributionButtonMargins = CGPoint(x: 4, y: 30) + + if showsScale { + mapView.showsScale = true + } + + if !isInteractive { + mapView.isScrollEnabled = false + mapView.isZoomEnabled = false + mapView.isRotateEnabled = false + mapView.isPitchEnabled = false + mapView.compassView.isHidden = true + } + + // Disable quick-zoom (tap-then-hold-drag) gesture + mapView.gestureRecognizers? + .compactMap { $0 as? UILongPressGestureRecognizer } + .filter { $0.numberOfTapsRequired == 1 && $0.minimumPressDuration == 0 } + .forEach { $0.isEnabled = false } + + // Tap gesture for feature queries + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + tap.delegate = context.coordinator + mapView.addGestureRecognizer(tap) + + return mapView + } + + static func dismantleUIView(_ mapView: MLNMapView, coordinator: Coordinator) { + coordinator.pendingRegionTask?.cancel() + mapView.delegate = nil + } + + func updateUIView(_ mapView: MLNMapView, context: Context) { + let coordinator = context.coordinator + coordinator.isUpdatingFromSwiftUI = true + defer { coordinator.isUpdatingFromSwiftUI = false } + + // Refresh callbacks + coordinator.onPointTap = onPointTap + coordinator.onMapTap = onMapTap + coordinator.onCameraRegionChange = onCameraRegionChange + coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } + coordinator.currentPoints = points + coordinator.currentLines = lines + + // Style URL change — compare against our tracked value, not mapView.styleURL + // which MapLibre may transiently nil during layout/rotation. + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) + if coordinator.lastAppliedStyleURL != newStyleURL { + coordinator.lastAppliedStyleURL = newStyleURL + coordinator.isStyleLoaded = false + mapView.styleURL = newStyleURL + } + let mapStyleChanged = coordinator.currentMapStyle != mapStyle + coordinator.currentMapStyle = mapStyle + + // User location + if mapView.showsUserLocation != showsUserLocation { + mapView.showsUserLocation = showsUserLocation + } + + // North lock + if isInteractive { + mapView.isRotateEnabled = !isNorthLocked + if isNorthLocked && mapView.direction != 0 { + mapView.setDirection(0, animated: true) + } + } + + // Update data layers (only when style is loaded and not mid-gesture). + // Compare against lastApplied* so updates arriving during a gesture + // are applied once the gesture ends. + if coordinator.isStyleLoaded, !coordinator.isUserInteracting { + if mapStyleChanged { + coordinator.updateRasterLayerVisibility(mapView: mapView) + } + if coordinator.lastAppliedPoints != points { + coordinator.updatePointSource(mapView: mapView) + coordinator.lastAppliedPoints = points + } + if coordinator.lastAppliedLines != lines { + coordinator.updateLineSource(mapView: mapView) + coordinator.lastAppliedLines = lines + } + if coordinator.currentShowLabels != showLabels { + coordinator.currentShowLabels = showLabels + coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) + } + } + + // Camera region (version-number pattern) + updateCameraRegion(in: mapView, coordinator: coordinator) + } + + private func updateCameraRegion(in mapView: MLNMapView, coordinator: Coordinator) { + guard let region = cameraRegion else { return } + guard cameraRegionVersion != coordinator.lastAppliedRegionVersion else { return } + + let isInflated = mapView.window.map { mapView.bounds.height > $0.bounds.height * 1.5 } ?? false + let animated = coordinator.lastAppliedRegionVersion > 0 && !isInflated + coordinator.lastAppliedRegionVersion = cameraRegionVersion + + let bounds = MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - region.span.latitudeDelta / 2, + longitude: region.center.longitude - region.span.longitudeDelta / 2 + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + region.span.latitudeDelta / 2, + longitude: region.center.longitude + region.span.longitudeDelta / 2 + ) + ) + var padding = cameraEdgePadding + if let sheetFraction = cameraBottomSheetFraction { + let insets = mapView.safeAreaInsets + padding.top = max(padding.top, insets.top + 20) + padding.left = max(padding.left, insets.left + 20) + if sheetFraction > 0 { + let stableHeight = mapView.window?.bounds.height ?? mapView.bounds.height + padding.bottom = max(padding.bottom, stableHeight * sheetFraction) + } + } + + if let windowSize = mapView.window?.bounds.size, + mapView.bounds.height > windowSize.height * 1.5 { + let centerLat = (bounds.sw.latitude + bounds.ne.latitude) / 2 + let centerLon = (bounds.sw.longitude + bounds.ne.longitude) / 2 + let latSpanMeters = abs(bounds.ne.latitude - bounds.sw.latitude) * 111_000 + let lonSpanMeters = abs(bounds.ne.longitude - bounds.sw.longitude) * 111_000 + * cos(centerLat * .pi / 180) + + let usableWidth = max(1, Double(windowSize.width) - Double(padding.left + padding.right)) + let usableHeight = max(1, Double(windowSize.height) - Double(padding.top + padding.bottom)) + + let mppForLat = latSpanMeters / usableHeight + let mppForLon = lonSpanMeters / usableWidth + let requiredMPP = max(mppForLat, mppForLon) + + let currentMPP = mapView.metersPerPoint(atLatitude: centerLat) + let targetZoom = mapView.zoomLevel + log2(currentMPP / requiredMPP) + + let pixelOffset = (Double(padding.top) - Double(padding.bottom)) / 2 + let offsetDeg = pixelOffset * requiredMPP / 111_000 + let center = CLLocationCoordinate2D( + latitude: centerLat + offsetDeg, + longitude: centerLon + ) + + mapView.setCenter(center, zoomLevel: targetZoom, animated: false) + } else { + mapView.setVisibleCoordinateBounds(bounds, edgePadding: padding, animated: animated) + } + } +} + +// MARK: - Coordinator + +extension MC1MapView { + @MainActor + class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { + // Non-zero frame avoids MapLibre zero-size Metal init (issue #67). + let mapView: MLNMapView = ScaledMLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + + // Callbacks + var onPointTap: ((MapPoint, CGPoint) -> Void)? + var onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + var setIsStyleLoaded: ((Bool) -> Void)? + + // State + var isUserInteracting = false + var isUpdatingFromSwiftUI = false + var isStyleLoaded = false + var lastAppliedRegionVersion = 0 + var pendingRegionTask: Task? + var currentShowLabels = true + var lastAppliedStyleURL: URL? + var currentMapStyle: MapStyleSelection? + var currentPoints: [MapPoint] = [] + var currentLines: [MapLine] = [] + var lastAppliedPoints: [MapPoint] = [] + var lastAppliedLines: [MapLine] = [] + var clusterSource: MLNShapeSource? + var fixedSource: MLNShapeSource? + + // MARK: - Style loading + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + isStyleLoaded = true + setIsStyleLoaded?(true) + + // Clear stale source/state references from the previous style. + // Reset currentShowLabels to the new layer default (visible) so + // updateUIView detects the mismatch and reapplies the user's preference. + clusterSource = nil + fixedSource = nil + lastAppliedPoints = [] + lastAppliedLines = [] + currentShowLabels = true + + PinSpriteRenderer.renderAll(into: style) + setupRasterSources(style: style, mapView: mapView) + setupLineLayers(style: style) + + updatePointSource(mapView: mapView) + updateLineSource(mapView: mapView) + } + + func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + if let style = mapView.style, + let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return image + } + logger.error("didFailToLoadImage: \(imageName)") + return nil + } + + // MARK: - Region changes + + private static let userGestureReasons: MLNCameraChangeReason = [ + .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, + .gestureRotate, .gestureTilt, .gestureOneFingerZoom + ] + + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + isUserInteracting = true + } + + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + isUserInteracting = false + guard !isUpdatingFromSwiftUI else { return } + + let isUserGesture = !reason.isDisjoint(with: Self.userGestureReasons) + guard isUserGesture else { return } + + // Debounce: cancel previous pending write-back + pendingRegionTask?.cancel() + pendingRegionTask = Task { + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + let region = mapView.mlnRegion + self.onCameraRegionChange?(region) + } + } + + // MARK: - Gesture recognizer delegate + + nonisolated func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + true + } + + // MARK: - Tap handling + + @objc func handleTap(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { return } + let point = sender.location(in: mapView) + let clusterRect = CGRect(x: point.x - 22, y: point.y - 22, width: 44, height: 44) + logger.debug("handleTap at \(point.x, privacy: .public), \(point.y, privacy: .public)") + + // 1. Check cluster layers + let clusterFeatures = mapView.visibleFeatures( + in: clusterRect, + styleLayerIdentifiers: [MapLayerID.clusterCircles] + ) + if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, + let source = mapView.style?.source(withIdentifier: MapSourceID.points) as? MLNShapeSource { + let zoom = source.zoomLevel(forExpanding: cluster) + guard zoom >= 0 else { return } + mapView.setCenter(cluster.coordinate, zoomLevel: zoom + 2.0, animated: true) + return + } + + // 2. Check point and name label layers (both clustered and fixed) + let pointFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [ + MapLayerID.unclusteredIcons, MapLayerID.fixedIcons, + MapLayerID.nameLabels, MapLayerID.fixedNameLabels + ] + ) + logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") + if let feature = pointFeatures.first, + let idString = feature.attribute(forKey: "pointId") as? String, + let id = UUID(uuidString: idString), + let mapPoint = currentPoints.first(where: { $0.id == id }) { + logger.debug("Matched pin: \(mapPoint.label ?? "unnamed", privacy: .public)") + let pinScreenPos = mapView.convert(mapPoint.coordinate, toPointTo: mapView) + let calloutAnchor = CGPoint(x: pinScreenPos.x, y: pinScreenPos.y - PinSpriteRenderer.standardHeight) + onPointTap?(mapPoint, calloutAnchor) + return + } + + // 3. Check badge text layers — dismiss any open callout but don't select + let badgeFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] + ) + if badgeFeatures.first != nil { + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + return + } + + // 4. Map background tap + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + } + } +} + +// MARK: - MLNMapView region helper + +extension MLNMapView { + var mlnRegion: MKCoordinateRegion { + let bounds = visibleCoordinateBounds + let center = CLLocationCoordinate2D( + latitude: (bounds.sw.latitude + bounds.ne.latitude) / 2, + longitude: (bounds.sw.longitude + bounds.ne.longitude) / 2 + ) + let span = MKCoordinateSpan( + latitudeDelta: bounds.ne.latitude - bounds.sw.latitude, + longitudeDelta: bounds.ne.longitude - bounds.sw.longitude + ) + return MKCoordinateRegion(center: center, span: span) + } +} diff --git a/MC1/Views/Map/MKMapViewRepresentable.swift b/MC1/Views/Map/MKMapViewRepresentable.swift deleted file mode 100644 index deafcc82d..000000000 --- a/MC1/Views/Map/MKMapViewRepresentable.swift +++ /dev/null @@ -1,399 +0,0 @@ -import MapKit -import os -import SwiftUI -import MC1Services - -private let logger = Logger(subsystem: "com.mc1", category: "MapRepresentable") - -/// UIViewRepresentable wrapper for MKMapView with custom contact annotations -struct MKMapViewRepresentable: UIViewRepresentable { - let contacts: [ContactDTO] - let mapType: MKMapType - let showLabels: Bool - let showsUserLocation: Bool - - @Binding var selectedContact: ContactDTO? - @Binding var cameraRegion: MKCoordinateRegion? - - // Callbacks for callout actions - let onDetailTap: (ContactDTO) -> Void - let onMessageTap: (ContactDTO) -> Void - /// Called once with a closure that returns snapshot parameters from the actual MKMapView (bypasses async binding) - var onSnapshotParamsGetter: ((@escaping () -> (camera: MKMapCamera, size: CGSize)?) -> Void)? - - func makeUIView(context: Context) -> MKMapView { - let mapView = context.coordinator.mapView - - mapView.delegate = context.coordinator - mapView.showsUserLocation = showsUserLocation - - // Register annotation views - mapView.register( - ContactPinView.self, - forAnnotationViewWithReuseIdentifier: ContactPinView.reuseIdentifier - ) - mapView.register( - MKMarkerAnnotationView.self, - forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - - // Provide closure to get snapshot params directly from MKMapView (bypasses async binding lag) - onSnapshotParamsGetter? { [weak mapView] in - guard let mapView else { return nil } - // swiftlint:disable:next force_cast - return (camera: mapView.camera.copy() as! MKMapCamera, size: mapView.bounds.size) - } - - return mapView - } - - func updateUIView(_ mapView: MKMapView, context: Context) { - let coordinator = context.coordinator - - // Update binding setters each render cycle - coordinator.setSelectedContact = { selectedContact = $0 } - coordinator.setCameraRegion = { cameraRegion = $0 } - coordinator.onDetailTap = onDetailTap - coordinator.onMessageTap = onMessageTap - coordinator.showLabels = showLabels - - // Mark as programmatic update to prevent feedback loops - coordinator.isUpdatingFromSwiftUI = true - defer { coordinator.isUpdatingFromSwiftUI = false } - - // Update map type - mapView.mapType = mapType - - // Update user location visibility - mapView.showsUserLocation = showsUserLocation - - // Update annotations - updateAnnotations(in: mapView, coordinator: coordinator) - - // Update selection state - updateSelection(in: mapView, coordinator: coordinator) - - // Update region if changed programmatically - if let region = cameraRegion { - // Check if binding has caught up with pending user gesture - if let pendingGesture = coordinator.pendingUserGestureRegion { - if region.isApproximatelyEqual(to: pendingGesture) { - // Binding now reflects user gesture, clear pending state - logger.debug("Region: binding caught up, clearing pendingUserGestureRegion") - coordinator.pendingUserGestureRegion = nil - } else { - // Binding is stale (hasn't caught up with user gesture), skip applying - logger.debug("Region: binding stale (span=\(region.span.latitudeDelta, format: .fixed(precision: 4))), pending span=\(pendingGesture.span.latitudeDelta, format: .fixed(precision: 4))), skipping") - return - } - } - - let shouldUpdate = coordinator.lastAppliedRegion == nil || - !coordinator.lastAppliedRegion!.isApproximatelyEqual(to: region) - - if shouldUpdate { - logger.debug("Region: applying via setRegion (span=\(region.span.latitudeDelta, format: .fixed(precision: 4)))") - coordinator.hasPendingProgrammaticRegion = true - coordinator.hasAppliedInitialRegion = true - mapView.setRegion(region, animated: coordinator.lastAppliedRegion != nil) - coordinator.lastAppliedRegion = region - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - // MARK: - Annotation Management - - private func updateAnnotations(in mapView: MKMapView, coordinator: Coordinator) { - let currentAnnotations = mapView.annotations.compactMap { $0 as? ContactAnnotation } - let currentIDs = Set(currentAnnotations.map { $0.contact.id }) - let newIDs = Set(contacts.map { $0.id }) - - // Remove annotations that are no longer in the list - let toRemove = currentAnnotations.filter { !newIDs.contains($0.contact.id) } - mapView.removeAnnotations(toRemove) - - // Add new annotations - let existingIDs = currentIDs.subtracting(Set(toRemove.map { $0.contact.id })) - let toAdd = contacts.filter { !existingIDs.contains($0.id) } - .map { ContactAnnotation(contact: $0) } - mapView.addAnnotations(toAdd) - - // Only update name labels if showLabels or selection actually changed - // Iterating and calling view(for:) on every update interferes with MKMapView clustering - let selectedID = selectedContact?.id - let labelsChanged = showLabels != coordinator.lastShowLabels - let selectionChanged = selectedID != coordinator.lastSelectedContactID - - if labelsChanged || selectionChanged { - for annotation in mapView.annotations.compactMap({ $0 as? ContactAnnotation }) { - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = showLabels && selectedID != annotation.contact.id - } - } - coordinator.lastShowLabels = showLabels - coordinator.lastSelectedContactID = selectedID - } - } - - private func updateSelection(in mapView: MKMapView, coordinator: Coordinator) { - let currentlySelectedAnnotation = mapView.selectedAnnotations.first as? ContactAnnotation - - if let selectedContact { - // Find the annotation for this contact - guard let annotation = mapView.annotations - .compactMap({ $0 as? ContactAnnotation }) - .first(where: { $0.contact.id == selectedContact.id }) else { - return - } - - // Only select if not already selected - if currentlySelectedAnnotation?.contact.id != selectedContact.id { - mapView.selectAnnotation(annotation, animated: true) - } - } else if let current = currentlySelectedAnnotation { - // Deselect all - mapView.deselectAnnotation(current, animated: true) - } - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject, MKMapViewDelegate { - // Binding setters for deferred updates - var setSelectedContact: ((ContactDTO?) -> Void)? - var setCameraRegion: ((MKCoordinateRegion?) -> Void)? - - // Callbacks - var onDetailTap: ((ContactDTO) -> Void)? - var onMessageTap: ((ContactDTO) -> Void)? - - // Configuration - var showLabels: Bool = true - - // State management - var isUpdatingFromSwiftUI = false - var lastAppliedRegion: MKCoordinateRegion? - var hasPendingProgrammaticRegion = false - var hasAppliedInitialRegion = false - - /// Tracks pending user gesture region awaiting async binding sync. - /// When set, the binding is considered stale until it matches this value. - var pendingUserGestureRegion: MKCoordinateRegion? - - /// Timestamp of the last cluster tap handled by the gesture recognizer. - /// Used to prevent double-handling when both gesture and delegate fire. - var lastClusterTapTime: Date? - - /// Set before showAnnotations calls to ensure pendingUserGestureRegion is set - /// even if hasPendingProgrammaticRegion is true from a prior setRegion. - var hasPendingShowAnnotations = false - - // Previous state for change detection (avoid unnecessary view updates that interfere with clustering) - var lastShowLabels: Bool = true - var lastSelectedContactID: UUID? - - // Lazily created map view owned by coordinator - lazy var mapView: MKMapView = { - let map = MKMapView() - return map - }() - - // MARK: - Cluster Tap Handler - - @objc func clusterTapped(_ gesture: UITapGestureRecognizer) { - guard let clusterView = gesture.view as? MKAnnotationView, - let cluster = clusterView.annotation as? MKClusterAnnotation else { - return - } - // Mark that we handled this tap to prevent delegate double-handling - lastClusterTapTime = Date() - // Mark that we're about to call showAnnotations so regionDidChangeAnimated - // will set pendingUserGestureRegion to protect against stale binding values - hasPendingShowAnnotations = true - logger.debug("Cluster: gesture tapped, calling showAnnotations for \(cluster.memberAnnotations.count) members") - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - } - - // MARK: - MKMapViewDelegate - - func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? { - // Don't provide custom view for user location - if annotation is MKUserLocation { - return nil - } - - // Handle cluster annotations - if annotation is MKClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier, - for: annotation - ) as? MKMarkerAnnotationView ?? MKMarkerAnnotationView( - annotation: annotation, - reuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier - ) - view.markerTintColor = .systemBlue - view.glyphImage = UIImage(systemName: "person.2.fill") - view.displayPriority = .defaultHigh - view.canShowCallout = false - - // Remove existing tap gestures to avoid duplicates on reuse - view.gestureRecognizers?.filter { $0 is UITapGestureRecognizer }.forEach { - view.removeGestureRecognizer($0) - } - - // Add tap gesture for immediate response (bypasses delegate selection delay) - let tap = UITapGestureRecognizer(target: self, action: #selector(clusterTapped(_:))) - view.addGestureRecognizer(tap) - - return view - } - - // Handle contact annotations - guard let contactAnnotation = annotation as? ContactAnnotation else { - return nil - } - - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: ContactPinView.reuseIdentifier, - for: annotation - ) as? ContactPinView ?? ContactPinView( - annotation: annotation, - reuseIdentifier: ContactPinView.reuseIdentifier - ) - - view.annotation = annotation - view.showsNameLabel = showLabels - // Must set clusteringIdentifier here before returning view, not in init/configure - // MKMapView makes clustering decisions based on this value at return time - view.clusteringIdentifier = "contact" - view.onDetail = { [weak self] in - self?.onDetailTap?(contactAnnotation.contact) - } - view.onMessage = { [weak self] in - self?.onMessageTap?(contactAnnotation.contact) - } - - return view - } - - func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) { - guard !isUpdatingFromSwiftUI else { return } - - // Ignore user location selection - if annotation is MKUserLocation { - return - } - - // Handle cluster selection - zoom to show members - // Skip if gesture recognizer already handled this tap (within 500ms) - if let cluster = annotation as? MKClusterAnnotation { - if let tapTime = lastClusterTapTime, Date().timeIntervalSince(tapTime) < 0.5 { - // Gesture already handled this tap, just deselect without zooming again - logger.debug("Cluster: didSelect skipped (gesture handled \(Date().timeIntervalSince(tapTime), format: .fixed(precision: 3))s ago)") - mapView.deselectAnnotation(cluster, animated: false) - return - } - logger.debug("Cluster: didSelect calling showAnnotations (fallback path)") - mapView.deselectAnnotation(cluster, animated: false) - hasPendingShowAnnotations = true - mapView.showAnnotations(cluster.memberAnnotations, animated: true) - return - } - - guard let contactAnnotation = annotation as? ContactAnnotation else { return } - - logger.debug("Selection: didSelect for \(contactAnnotation.contact.displayName)") - - // Update name label visibility - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = false - } - - // Defer binding update to avoid SwiftUI state mutation during update - Task { @MainActor in - logger.debug("Selection: updating selectedContact binding") - self.setSelectedContact?(contactAnnotation.contact) - } - } - - func mapView(_ mapView: MKMapView, didDeselect annotation: any MKAnnotation) { - guard !isUpdatingFromSwiftUI else { return } - - // Update name label visibility - if let view = mapView.view(for: annotation) as? ContactPinView { - view.showsNameLabel = showLabels - } - - Task { @MainActor in - self.setSelectedContact?(nil) - } - } - - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - guard !isUpdatingFromSwiftUI else { - logger.debug("Region: regionDidChangeAnimated skipped (isUpdatingFromSwiftUI)") - return - } - - let newSpan = mapView.region.span.latitudeDelta - - // Handle showAnnotations region changes - must set pendingUserGestureRegion - // to protect against stale binding values, since the binding wasn't updated - if hasPendingShowAnnotations { - logger.debug("Region: regionDidChangeAnimated from showAnnotations (span=\(newSpan, format: .fixed(precision: 4)))") - hasPendingShowAnnotations = false - hasPendingProgrammaticRegion = false // Clear if also set - lastAppliedRegion = mapView.region - pendingUserGestureRegion = mapView.region - Task { @MainActor in - logger.debug("Region: updating cameraRegion binding (from showAnnotations)") - self.setCameraRegion?(mapView.region) - } - return - } - - // Don't overwrite binding during programmatic region changes from setRegion - if hasPendingProgrammaticRegion { - logger.debug("Region: regionDidChangeAnimated from programmatic change (span=\(newSpan, format: .fixed(precision: 4)))") - hasPendingProgrammaticRegion = false - lastAppliedRegion = mapView.region - return - } - - // Don't write back until we've applied at least one programmatic region - // This prevents the initial default region from overwriting the intended region - guard hasAppliedInitialRegion else { - logger.debug("Region: regionDidChangeAnimated before initial region (span=\(newSpan, format: .fixed(precision: 4)))") - lastAppliedRegion = mapView.region - return - } - - // Track user-initiated region changes - // Mark as pending so stale binding values won't revert this change - logger.debug("Region: regionDidChangeAnimated setting pendingUserGestureRegion (span=\(newSpan, format: .fixed(precision: 4)))") - lastAppliedRegion = mapView.region - pendingUserGestureRegion = mapView.region - - Task { @MainActor in - logger.debug("Region: updating cameraRegion binding") - self.setCameraRegion?(mapView.region) - } - } - } -} - -// MARK: - MKCoordinateRegion Comparison - -extension MKCoordinateRegion { - func isApproximatelyEqual(to other: MKCoordinateRegion, tolerance: Double = 0.0001) -> Bool { - abs(center.latitude - other.center.latitude) < tolerance && - abs(center.longitude - other.center.longitude) < tolerance && - abs(span.latitudeDelta - other.span.latitudeDelta) < tolerance && - abs(span.longitudeDelta - other.span.longitudeDelta) < tolerance - } -} diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift new file mode 100644 index 000000000..2608408c4 --- /dev/null +++ b/MC1/Views/Map/MapCanvasView.swift @@ -0,0 +1,136 @@ +import MapLibre +import SwiftUI +import MC1Services + +/// Canvas wrapping the map content with offline badge, floating controls, and layers menu overlay +struct MapCanvasView: View { + @Environment(\.appState) private var appState + @Bindable var viewModel: MapViewModel + @Binding var mapStyleSelection: MapStyleSelection + @Binding var showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + let onCenterOnUser: () -> Void + let onClearSelection: () -> Void + + var body: some View { + ZStack { + MapContentView( + viewModel: viewModel, + mapStyleSelection: mapStyleSelection, + showLabels: showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: onShowContactDetail, + onNavigateToChat: onNavigateToChat + ) + .ignoresSafeArea() + + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } + + // Floating controls + VStack { + Spacer() + MapCanvasControls( + isNorthLocked: $viewModel.isNorthLocked, + showingLayersMenu: $viewModel.showingLayersMenu, + showLabels: $showLabels, + contactsEmpty: viewModel.contactsWithLocation.isEmpty, + onLocationTap: { onCenterOnUser() }, + onClearSelection: onClearSelection, + onCenterAll: { viewModel.centerOnAllContacts() } + ) + } + + // Layers menu overlay + if viewModel.showingLayersMenu { + Button { + withAnimation { + viewModel.showingLayersMenu = false + } + } label: { + Color.black.opacity(0.3) + .ignoresSafeArea() + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) + + VStack { + Spacer() + HStack { + Spacer() + LayersMenu( + selection: $mapStyleSelection, + isPresented: $viewModel.showingLayersMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() + ) + .padding(.trailing, 72) + .padding(.bottom) + } + } + } + } + } + +} + +// MARK: - Map Controls + +private struct MapCanvasControls: View { + @Binding var isNorthLocked: Bool + @Binding var showingLayersMenu: Bool + @Binding var showLabels: Bool + let contactsEmpty: Bool + let onLocationTap: () -> Void + let onClearSelection: () -> Void + let onCenterAll: () -> Void + + var body: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: onLocationTap, + showingLayersMenu: $showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $isNorthLocked) + } + ) { + LabelsToggleButton(showLabels: $showLabels) + CenterAllButton( + isEmpty: contactsEmpty, + onClearSelection: onClearSelection, + onCenterAll: onCenterAll + ) + } + } + } +} + +// MARK: - Control Buttons + +private struct CenterAllButton: View { + let isEmpty: Bool + let onClearSelection: () -> Void + let onCenterAll: () -> Void + + var body: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + onClearSelection() + onCenterAll() + } + .font(.body.weight(.medium)) + .foregroundStyle(isEmpty ? .secondary : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .disabled(isEmpty) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift new file mode 100644 index 000000000..3d3ed1cf6 --- /dev/null +++ b/MC1/Views/Map/MapContentView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import MC1Services + +/// Map content displaying MC1MapView with contact points and popover callouts +struct MapContentView: View { + @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme + @Bindable var viewModel: MapViewModel + let mapStyleSelection: MapStyleSelection + let showLabels: Bool + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + + var body: some View { + MC1MapView( + points: viewModel.mapPoints, + lines: [], + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, + showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + isNorthLocked: viewModel.isNorthLocked, + cameraRegion: $viewModel.cameraRegion, + cameraRegionVersion: viewModel.cameraRegionVersion, + onPointTap: { point, screenPosition in + selectedCalloutContact = viewModel.contactsWithLocation.first { $0.id == point.id } + selectedPointScreenPosition = screenPosition + }, + onMapTap: { _ in + selectedCalloutContact = nil + selectedPointScreenPosition = nil + }, + onCameraRegionChange: { region in + viewModel.cameraRegion = region + if selectedCalloutContact != nil { + selectedCalloutContact = nil + selectedPointScreenPosition = nil + } + }, + isStyleLoaded: $isStyleLoaded + ) + .popover( + item: $selectedCalloutContact, + attachmentAnchor: .rect(.rect(CGRect( + origin: selectedPointScreenPosition ?? .zero, + size: CGSize(width: 1, height: 1) + ))), + arrowEdge: .bottom + ) { contact in + ContactCalloutContent( + contact: contact, + onDetail: { onShowContactDetail(contact) }, + onMessage: { onNavigateToChat(contact) } + ) + .presentationCompactAdaptation(.popover) + } + .overlay { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { + MapLoadingOverlay() + } + } + } + +} + +// MARK: - Loading Overlay + +private struct MapLoadingOverlay: View { + var body: some View { + ZStack { + Color.black.opacity(0.1) + ProgressView() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 8)) + } + } +} diff --git a/MC1/Views/Map/MapLine.swift b/MC1/Views/Map/MapLine.swift new file mode 100644 index 000000000..59ff1ddd5 --- /dev/null +++ b/MC1/Views/Map/MapLine.swift @@ -0,0 +1,27 @@ +import CoreLocation + +struct MapLine: Identifiable, Equatable { + let id: String + let coordinates: [CLLocationCoordinate2D] + let style: LineStyle + let opacity: Double + var pathIndex: Int? + + enum LineStyle: String, Hashable { + case los + case traceUntraced + case traceWeak + case traceMedium + case traceGood + } + + static func == (lhs: MapLine, rhs: MapLine) -> Bool { + lhs.id == rhs.id + && lhs.style == rhs.style + && lhs.opacity == rhs.opacity + && lhs.coordinates.count == rhs.coordinates.count + && zip(lhs.coordinates, rhs.coordinates).allSatisfy { + $0.latitude == $1.latitude && $0.longitude == $1.longitude + } + } +} diff --git a/MC1/Views/Map/MapPoint.swift b/MC1/Views/Map/MapPoint.swift new file mode 100644 index 000000000..041f7376a --- /dev/null +++ b/MC1/Views/Map/MapPoint.swift @@ -0,0 +1,37 @@ +import CoreLocation + +struct MapPoint: Identifiable, Equatable { + let id: UUID + let coordinate: CLLocationCoordinate2D + let pinStyle: PinStyle + let label: String? + let isClusterable: Bool + + enum PinStyle: String, Hashable { + case contactChat + case contactRepeater + case contactRoom + case repeater + case repeaterRingBlue + case repeaterRingGreen + case repeaterRingWhite + case pointA + case pointB + case crosshair + case badge + } + + let hopIndex: Int? + let badgeText: String? + + static func == (lhs: MapPoint, rhs: MapPoint) -> Bool { + lhs.id == rhs.id + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.pinStyle == rhs.pinStyle + && lhs.label == rhs.label + && lhs.isClusterable == rhs.isClusterable + && lhs.hopIndex == rhs.hopIndex + && lhs.badgeText == rhs.badgeText + } +} diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 65a1252aa..40cfdc50f 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -1,34 +1,44 @@ -import MapKit import SwiftUI /// Map style options for the Map tab enum MapStyleSelection: String, CaseIterable, Hashable { case standard case satellite - case hybrid + case topo - var mapStyle: MapStyle { + var label: String { switch self { - case .standard: .standard(elevation: .realistic) - case .satellite: .imagery - case .hybrid: .hybrid + case .standard: L10n.Map.Map.Style.standard + case .satellite: L10n.Map.Map.Style.satellite + case .topo: L10n.Map.Map.Style.topo } } - var label: String { + var requiresNetwork: Bool { switch self { - case .standard: L10n.Map.Map.Style.standard - case .satellite: L10n.Map.Map.Style.satellite - case .hybrid: L10n.Map.Map.Style.hybrid + case .standard: false + case .satellite: true + case .topo: false } } - /// MKMapType for UIKit MKMapView - var mkMapType: MKMapType { + var offlineMapLayer: OfflineMapLayer { switch self { - case .standard: .standard - case .satellite: .satellite - case .hybrid: .hybrid + case .standard: .base + case .satellite: .base + case .topo: .topo + } + } + + /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. + /// When offline, always returns Liberty — offline packs are downloaded against that style + /// and MapLibre serves cached tiles only for the exact style URL used during download. + func styleURL(isDarkMode: Bool, isOffline: Bool = false) -> URL { + let useDark = isDarkMode && !isOffline + let url = useDark ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + guard let result = URL(string: url) else { + fatalError("Invalid map tile URL constant: \(url)") } + return result } } diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift new file mode 100644 index 000000000..466a34760 --- /dev/null +++ b/MC1/Views/Map/MapTileURLs.swift @@ -0,0 +1,8 @@ +enum MapTileURLs { + static let openFreeMapLiberty = "https://tiles.openfreemap.org/styles/liberty" + static let openFreeMapDark = "https://tiles.openfreemap.org/styles/dark" + static let esriWorldImagery = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + static let openTopoMapA = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapB = "https://b.tile.opentopomap.org/{z}/{x}/{y}.png" + static let openTopoMapC = "https://c.tile.opentopomap.org/{z}/{x}/{y}.png" +} diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index c03c3ded8..73a64f4ec 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -1,251 +1,63 @@ -import os import SwiftUI import MapKit import MC1Services -private let logger = Logger(subsystem: "com.mc1", category: "MapView") - /// Map view displaying contacts with their locations struct MapView: View { - /// Estimated duration for sheet presentation animation. SwiftUI doesn't provide a completion callback, - /// so we use this delay before switching to the snapshot to hide the transition from the user. - private static let sheetPresentationDuration: Duration = .milliseconds(500) - @Environment(\.appState) private var appState + @AppStorage("mapStyleSelection") private var mapStyleSelection: MapStyleSelection = .standard + @AppStorage("mapShowLabels") private var showLabels = true @State private var viewModel = MapViewModel() + @State private var selectedCalloutContact: ContactDTO? + @State private var selectedPointScreenPosition: CGPoint? @State private var selectedContactForDetail: ContactDTO? - /// Static snapshot of the map shown while sheets are presented to prevent memory growth from SwiftUI keyboard layout cycles - @State private var mapSnapshot: UIImage? - /// Controls when snapshot is shown - delayed until after sheet presents to hide the transition - @State private var isSnapshotActive = false - /// Closure to get snapshot parameters directly from MKMapView (camera + bounds, avoids async binding lag) - @State private var getSnapshotParams: (() -> (camera: MKMapCamera, size: CGSize)?)? + @State private var isStyleLoaded = false var body: some View { NavigationStack { - mapCanvas - .toolbar { - ToolbarItem(placement: .topBarLeading) { - BLEStatusIndicatorView() - } - ToolbarItem(placement: .topBarTrailing) { - refreshButton - } - } - .task { - appState.locationService.requestPermissionIfNeeded() - appState.locationService.requestLocation() - viewModel.configure(appState: appState) - await viewModel.loadContactsWithLocation() - viewModel.centerOnAllContacts() - } - .sheet(item: $selectedContactForDetail, onDismiss: clearMapSnapshot) { contact in - ContactDetailSheet( - contact: contact, - onMessage: { navigateToChat(with: contact) } - ) - .presentationDetents([.large]) - } - .liquidGlassToolbarBackground() - } - } - - // MARK: - Map Canvas - - private var mapCanvas: some View { - ZStack { - mapContent - .ignoresSafeArea() - - // Floating controls - VStack { - Spacer() - mapControls - } - - // Layers menu overlay - if viewModel.showingLayersMenu { - Button { - withAnimation { - viewModel.showingLayersMenu = false - } - } label: { - Color.black.opacity(0.3) - .ignoresSafeArea() - } - .buttonStyle(.plain) - - VStack { - Spacer() - HStack { - Spacer() - LayersMenu( - selection: $viewModel.mapStyleSelection, - isPresented: $viewModel.showingLayersMenu - ) - .padding(.trailing, 72) - .padding(.bottom) - } - } - } - } - } - - // MARK: - Map Content - - @ViewBuilder - private var mapContent: some View { - if viewModel.contactsWithLocation.isEmpty && !viewModel.isLoading { - emptyState - } else { - // Keep MKMapView always in tree to prevent Metal deallocation crashes - // Hide it with opacity when showing snapshot instead of removing from hierarchy - let showingSnapshot = isSnapshotActive && mapSnapshot != nil - - ZStack { - MKMapViewRepresentable( - contacts: viewModel.contactsWithLocation, - mapType: viewModel.mapStyleSelection.mkMapType, - showLabels: viewModel.showLabels, - showsUserLocation: true, - selectedContact: $viewModel.selectedContact, - cameraRegion: $viewModel.cameraRegion, - onDetailTap: { contact in - showContactDetail(contact) - }, - onMessageTap: { contact in - navigateToChat(with: contact) - }, - onSnapshotParamsGetter: { getter in - Task { @MainActor in - await Task.yield() - getSnapshotParams = getter - } - } - ) - .opacity(showingSnapshot ? 0 : 1) - - if showingSnapshot, let snapshot = mapSnapshot { - // Show static snapshot while sheet is presented to prevent memory growth - // MKMapView clustering causes unbounded memory growth during keyboard layout cycles - // Must ignore safe area to match MKMapView's positioning (UIView fills entire area) - Image(uiImage: snapshot) - .resizable() - .ignoresSafeArea() - } - } - .overlay { - if viewModel.isLoading { - loadingOverlay + MapCanvasView( + viewModel: viewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: { showContactDetail($0) }, + onNavigateToChat: { navigateToChat(with: $0) }, + onCenterOnUser: { centerOnUserLocation() }, + onClearSelection: { clearSelection() } + ) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BLEStatusIndicatorView() } - } - } - } - - // MARK: - Empty State - - private var emptyState: some View { - ContentUnavailableView { - Label(L10n.Map.Map.EmptyState.title, systemImage: "map") - } description: { - Text(L10n.Map.Map.EmptyState.description) - } actions: { - Button(L10n.Map.Map.Common.refresh) { - Task { - await viewModel.loadContactsWithLocation() + ToolbarItem(placement: .topBarTrailing) { + MapRefreshButton(viewModel: viewModel) } } - .buttonStyle(.bordered) - } - } - - // MARK: - Loading Overlay - - private var loadingOverlay: some View { - ZStack { - Color.black.opacity(0.1) - ProgressView() - .padding() - .background(.regularMaterial, in: .rect(cornerRadius: 8)) - } - } - - // MARK: - Map Controls - - private var mapControls: some View { - HStack { - Spacer() - mapControlsStack - } - } - - private var mapControlsStack: some View { - MapControlsToolbar( - onLocationTap: { centerOnUserLocation() }, - showingLayersMenu: $viewModel.showingLayersMenu - ) { - labelsToggleButton - centerAllButton - } - } - - private var labelsToggleButton: some View { - Button { - withAnimation { - viewModel.showLabels.toggle() - } - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(viewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels) - } - - private var centerAllButton: some View { - Button { - clearSelection() - viewModel.centerOnAllContacts() - } label: { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(viewModel.contactsWithLocation.isEmpty ? .secondary : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .disabled(viewModel.contactsWithLocation.isEmpty) - .accessibilityLabel(L10n.Map.Map.Controls.centerAll) - } - - // MARK: - Refresh Button - - private var refreshButton: some View { - Button { - Task { + .task { + appState.locationService.requestPermissionIfNeeded() + appState.locationService.requestLocation() + viewModel.configure(appState: appState) await viewModel.loadContactsWithLocation() + viewModel.centerOnAllContacts() } - } label: { - if viewModel.isLoading { - ProgressView() - } else { - Image(systemName: "arrow.clockwise") + .sheet(item: $selectedContactForDetail) { contact in + ContactDetailSheet( + contact: contact, + onMessage: { navigateToChat(with: contact) } + ) + .presentationDetents([.large]) } + .liquidGlassToolbarBackground() } - .disabled(viewModel.isLoading) } // MARK: - Actions - private func selectContact(_ contact: ContactDTO) { - viewModel.centerOnContact(contact) - } - private func clearSelection() { - viewModel.clearSelection() + selectedCalloutContact = nil + selectedPointScreenPosition = nil } private func navigateToChat(with contact: ContactDTO) { @@ -254,293 +66,42 @@ struct MapView: View { } private func showContactDetail(_ contact: ContactDTO) { - // Clear selection to prevent MKSmallCalloutView constraint corruption - viewModel.selectedContact = nil - // Present sheet immediately so user sees it animating in + clearSelection() selectedContactForDetail = contact - - // Capture snapshot after sheet animation completes to hide the transition - Task { - try? await Task.sleep(for: Self.sheetPresentationDuration) - // Guard against race condition if sheet was dismissed during delay - guard selectedContactForDetail != nil else { return } - await captureMapSnapshot() - isSnapshotActive = true - } - } - - /// Captures a static snapshot of the current map view to display while sheets are presented - private func captureMapSnapshot() async { - // Get camera and bounds directly from MKMapView for pixel-perfect match - // Using camera instead of region avoids MKMapSnapshotter's automatic aspect ratio adjustment - guard let params = getSnapshotParams?() else { return } - - let options = MKMapSnapshotter.Options() - options.camera = params.camera - options.size = params.size - options.scale = UIScreen.main.scale - options.mapType = viewModel.mapStyleSelection.mkMapType - options.showsBuildings = true - - let snapshotter = MKMapSnapshotter(options: options) - do { - let snapshot = try await snapshotter.start() - mapSnapshot = snapshot.image - } catch { - logger.warning("Map snapshot capture failed: \(error.localizedDescription)") - mapSnapshot = nil - } - } - - private func clearMapSnapshot() { - isSnapshotActive = false - mapSnapshot = nil } private func centerOnUserLocation() { guard let location = appState.bestAvailableLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - viewModel.cameraRegion = MKCoordinateRegion(center: location.coordinate, span: span) + viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) } } -// MARK: - Contact Detail Sheet +// MARK: - Map Refresh Button -private struct ContactDetailSheet: View { - let contact: ContactDTO - let onMessage: () -> Void - @Environment(\.dismiss) private var dismiss - @Environment(\.appState) private var appState - - /// Sheet types for repeater flows - private enum ActiveSheet: Identifiable, Hashable { - case telemetryAuth - case telemetryStatus(RemoteNodeSessionDTO) - case adminAuth - case adminSettings(RemoteNodeSessionDTO) - case roomJoin - - var id: String { - switch self { - case .telemetryAuth: "telemetryAuth" - case .telemetryStatus(let s): "telemetryStatus-\(s.id)" - case .adminAuth: "adminAuth" - case .adminSettings(let s): "adminSettings-\(s.id)" - case .roomJoin: "roomJoin" - } - } - } - - @State private var activeSheet: ActiveSheet? - @State private var pendingSheet: ActiveSheet? +private struct MapRefreshButton: View { + var viewModel: MapViewModel var body: some View { - NavigationStack { - List { - // Basic info section - Section(L10n.Map.Map.Detail.Section.contactInfo) { - LabeledContent(L10n.Map.Map.Detail.name, value: contact.displayName) - - LabeledContent(L10n.Map.Map.Detail.type) { - HStack { - Image(systemName: typeIconName) - Text(typeDisplayName) - } - .foregroundStyle(typeColor) - } - - if contact.isFavorite { - LabeledContent(L10n.Map.Map.Detail.status) { - HStack { - Image(systemName: "star.fill") - Text(L10n.Map.Map.Detail.favorite) - } - .foregroundStyle(.orange) - } - } - - if contact.lastAdvertTimestamp > 0 { - LabeledContent(L10n.Map.Map.Detail.lastAdvert) { - ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) - } - } - } - - // Location section - Section(L10n.Map.Map.Detail.Section.location) { - LabeledContent(L10n.Map.Map.Detail.latitude) { - Text(contact.latitude, format: .number.precision(.fractionLength(6))) - } - - LabeledContent(L10n.Map.Map.Detail.longitude) { - Text(contact.longitude, format: .number.precision(.fractionLength(6))) - } - } - - // Path info section - Section(L10n.Map.Map.Detail.Section.networkPath) { - if contact.isFloodRouted { - LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) - } else { - let hopCount = contact.pathHopCount - LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) - } - } - - // Actions section - Section { - switch contact.type { - case .repeater: - Button { - activeSheet = .telemetryAuth - } label: { - Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - - Button { - activeSheet = .adminAuth - } label: { - Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") - } - - case .room: - Button { - activeSheet = .roomJoin - } label: { - Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") - } - - case .chat: - Button { - dismiss() - onMessage() - } label: { - Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") - } - .radioDisabled(for: appState.connectionState) - } - } - } - .navigationTitle(contact.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - dismiss() - } - } - } - .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in - switch sheet { - case .telemetryAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet( - contact: contact, - role: role, - customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle - ) { session in - pendingSheet = .telemetryStatus(session) - activeSheet = nil - } - .presentationSizing(.page) - } - - case .telemetryStatus(let session): - RepeaterStatusView(session: session) - - case .adminAuth: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - if session.isAdmin { - pendingSheet = .adminSettings(session) - } else { - pendingSheet = .telemetryStatus(session) - } - activeSheet = nil - } - .presentationSizing(.page) - } - - case .adminSettings(let session): - NavigationStack { - RepeaterSettingsView(session: session) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.Map.Map.Common.done) { - activeSheet = nil - } - } - } - } - .presentationSizing(.page) - - case .roomJoin: - if let role = RemoteNodeRole(contactType: contact.type) { - NodeAuthenticationSheet(contact: contact, role: role) { session in - activeSheet = nil - dismiss() - appState.navigation.navigateToRoom(with: session) - } - .presentationSizing(.page) - } - } + Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { + Task { + await viewModel.loadContactsWithLocation() } } - } - - // MARK: - Sheet Management - - private func presentPendingSheet() { - if let next = pendingSheet { - pendingSheet = nil - activeSheet = next - } - } - - // MARK: - Computed Properties - - private var typeIconName: String { - switch contact.type { - case .chat: - "person.fill" - case .repeater: - "antenna.radiowaves.left.and.right" - case .room: - "person.3.fill" - } - } - - private var typeDisplayName: String { - switch contact.type { - case .chat: - L10n.Map.Map.NodeKind.chatContact - case .repeater: - L10n.Map.Map.NodeKind.repeater - case .room: - L10n.Map.Map.NodeKind.room - } - } - - private var typeColor: Color { - switch contact.type { - case .chat: - .blue - case .repeater: - .green - case .room: - .purple + .labelStyle(.iconOnly) + .disabled(viewModel.isLoading) + .opacity(viewModel.isLoading ? 0 : 1) + .overlay { + if viewModel.isLoading { + ProgressView() + } } } } // MARK: - Preview -#Preview("Map with Contacts") { - MapView() - .environment(\.appState, AppState()) -} - -#Preview("Empty Map") { +#Preview { MapView() .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index 8cd716822..60fe2ba93 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -12,23 +12,23 @@ final class MapViewModel { /// All contacts with valid locations var contactsWithLocation: [ContactDTO] = [] + /// Map points derived from contacts — stored to avoid reallocation on every body eval. + private(set) var mapPoints: [MapPoint] = [] + /// Loading state var isLoading = false /// Error message if any var errorMessage: String? - /// Selected contact for detail display - var selectedContact: ContactDTO? - - /// Camera region for map centering (MKCoordinateRegion for UIKit MKMapView) + /// Camera region for map centering var cameraRegion: MKCoordinateRegion? - /// Current map style selection - var mapStyleSelection: MapStyleSelection = .standard + /// Version counter for the camera region, incremented to signal a new camera target + private(set) var cameraRegionVersion = 0 - /// Whether to show contact name labels - var showLabels = true + /// Whether the map bearing is locked to true north + var isNorthLocked = false /// Whether the layers menu is showing var showingLayersMenu = false @@ -66,6 +66,7 @@ final class MapViewModel { do { let allContacts = try await dataStore.fetchContacts(deviceID: deviceID) contactsWithLocation = allContacts.filter(\.hasLocation) + rebuildMapPoints() } catch { errorMessage = error.localizedDescription } @@ -73,21 +74,36 @@ final class MapViewModel { isLoading = false } + // MARK: - Map Points + + private func rebuildMapPoints() { + mapPoints = contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: contact.type.pinStyle, + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) + } + } + // MARK: - Map Interaction + func setCameraRegion(_ region: MKCoordinateRegion?) { + cameraRegion = region + cameraRegionVersion += 1 + } + /// Center map on a specific contact func centerOnContact(_ contact: ContactDTO) { guard contact.hasLocation else { return } - let coordinate = CLLocationCoordinate2D( - latitude: contact.latitude, - longitude: contact.longitude - ) - // 5000 meters corresponds to roughly 0.045 degrees latitude span let span = MKCoordinateSpan(latitudeDelta: 0.045, longitudeDelta: 0.045) - cameraRegion = MKCoordinateRegion(center: coordinate, span: span) - selectedContact = contact + setCameraRegion(MKCoordinateRegion(center: contact.coordinate, span: span)) } /// Center map to show all contacts @@ -97,47 +113,7 @@ final class MapViewModel { return } - // Calculate bounding region - var minLat = Double.greatestFiniteMagnitude - var maxLat = -Double.greatestFiniteMagnitude - var minLon = Double.greatestFiniteMagnitude - var maxLon = -Double.greatestFiniteMagnitude - - for contact in contactsWithLocation { - let lat = contact.latitude - let lon = contact.longitude - minLat = min(minLat, lat) - maxLat = max(maxLat, lat) - minLon = min(minLon, lon) - maxLon = max(maxLon, lon) - } - - let centerLat = (minLat + maxLat) / 2 - let centerLon = (minLon + maxLon) / 2 - // Clamp spans to valid MKCoordinateSpan bounds (lat: 0-180, lon: 0-360) - let latDelta = min(180, max(0.01, (maxLat - minLat) * 1.5)) - let lonDelta = min(360, max(0.01, (maxLon - minLon) * 1.5)) - - let center = CLLocationCoordinate2D(latitude: centerLat, longitude: centerLon) - let span = MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) - - cameraRegion = MKCoordinateRegion(center: center, span: span) - } - - /// Clear selection - func clearSelection() { - selectedContact = nil - } -} - -// MARK: - ContactDTO Location Extension - -extension ContactDTO { - /// The coordinate for MapKit - var coordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: latitude, - longitude: longitude - ) + let coordinates = contactsWithLocation.map(\.coordinate) + setCameraRegion(coordinates.boundingRegion()) } } diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift new file mode 100644 index 000000000..f54a1d19e --- /dev/null +++ b/MC1/Views/Map/OfflineBadge.swift @@ -0,0 +1,22 @@ +import Accessibility +import SwiftUI + +// MARK: - Offline Badge + +struct OfflineBadge: View { + var body: some View { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + .accessibilityAddTraits(.isStaticText) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) + .padding(.top) + .onAppear { + AccessibilityNotification.Announcement(L10n.Map.Map.OfflineBadge.label).post() + } + } +} diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift new file mode 100644 index 000000000..e78022811 --- /dev/null +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -0,0 +1,347 @@ +import MapLibre +import UIKit + +@MainActor +enum PinSpriteRenderer { + /// Height of a standard pin sprite in points (circle + triangle pointer). + /// Used by the map Coordinator to position callout anchors above the pin icon. + static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) + + static let labelSpritePrefix = "label-" + + private static var cachedImages: [String: UIImage]? + + /// Registers base pin sprites into the style. Hop-ring variants are rendered + /// lazily via `renderOnDemand(name:into:)` when MapLibre requests a missing image. + static func renderAll(into style: MLNStyle) { + var rendered: [String: UIImage] = [:] + for spec in allSpecs { + rendered[spec.name] = render(spec) + } + rendered["pin-badge"] = UIGraphicsImageRenderer( + size: CGSize(width: 1, height: 1), format: .preferred() + ).image { _ in } + rendered["pill-bg"] = renderPillBackground() + cachedImages = rendered + + for (name, image) in rendered { + style.setImage(image, forName: name) + } + } + + /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. + /// Returns the rendered image so the caller can pass it back to MapLibre as + /// the immediate fallback, avoiding a single-frame blink. + static func renderOnDemand(name: String, into style: MLNStyle) -> UIImage? { + if let cached = cachedImages?[name] { + style.setImage(cached, forName: name) + return cached + } + + let image: UIImage + if name.hasPrefix("pin-repeater-ring-white-hop-") { + guard let hopString = name.split(separator: "-").last, + let hop = Int(hopString), + (1...20).contains(hop), + let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { + return nil + } + image = render(ringWhiteSpec, hopIndex: hop) + } else if name.hasPrefix(labelSpritePrefix) { + let text = String(name.dropFirst(labelSpritePrefix.count)) + guard !text.isEmpty else { return nil } + image = renderLabelSprite(text: text) + } else { + return nil + } + + cachedImages?[name] = image + style.setImage(image, forName: name) + return image + } + + // MARK: - Sprite specifications + + private struct SpriteSpec { + let name: String + let circleColor: UIColor + let iconName: String? // SF Symbol name + let text: String? // e.g. "A", "B" for point pins + let ringColor: UIColor? // selection ring + let isCrosshair: Bool + } + + private static let allSpecs: [SpriteSpec] = [ + // Main map contacts + SpriteSpec(name: "pin-chat", circleColor: UIColor(red: 204 / 255, green: 122 / 255, blue: 92 / 255, alpha: 1), + iconName: "person.fill", text: nil, ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-repeater", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-room", circleColor: UIColor(red: 1, green: 136 / 255, blue: 0, alpha: 1), + iconName: "person.3.fill", text: nil, ringColor: nil, isCrosshair: false), + + // LOS/TracePath repeater states + SpriteSpec(name: "pin-repeater-ring-blue", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemBlue, isCrosshair: false), + SpriteSpec(name: "pin-repeater-ring-green", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .systemGreen, isCrosshair: false), + SpriteSpec(name: "pin-repeater-ring-white", circleColor: .systemCyan, + iconName: "antenna.radiowaves.left.and.right", text: nil, ringColor: .white, isCrosshair: false), + + // LOS point pins + SpriteSpec(name: "pin-point-a", circleColor: .systemBlue, + iconName: nil, text: "A", ringColor: nil, isCrosshair: false), + SpriteSpec(name: "pin-point-b", circleColor: .systemGreen, + iconName: nil, text: "B", ringColor: nil, isCrosshair: false), + + // LOS crosshair target + SpriteSpec(name: "pin-crosshair", circleColor: .systemPurple, + iconName: nil, text: "R", ringColor: nil, isCrosshair: true), + ] + + // MARK: - Rendering + + private static func render(_ spec: SpriteSpec, hopIndex: Int? = nil) -> UIImage { + if spec.isCrosshair { + return renderCrosshair(spec) + } + + let circleSize: CGFloat = 36 + let iconSize: CGFloat = 16 + let triangleSize: CGFloat = 10 + let ringPadding: CGFloat = spec.ringColor != nil ? 4 : 0 + let ringSize: CGFloat = spec.ringColor != nil ? 44 : 0 + let totalWidth = max(circleSize, ringSize) + let totalHeight = circleSize + triangleSize - 3 + ringPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let centerX = totalWidth / 2 + + // Selection ring + if let ringColor = spec.ringColor { + let ringRect = CGRect( + x: centerX - ringSize / 2, + y: ringPadding, + width: ringSize, + height: ringSize + ) + ringColor.setStroke() + cgContext.setLineWidth(3) + cgContext.strokeEllipse(in: ringRect.insetBy(dx: 1.5, dy: 1.5)) + } + + // Circle shadow + cgContext.saveGState() + cgContext.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + let circleRect = CGRect( + x: centerX - circleSize / 2, + y: ringPadding, + width: circleSize, + height: circleSize + ) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + cgContext.restoreGState() + + // Circle (again without shadow for crisp edge) + spec.circleColor.setFill() + cgContext.fillEllipse(in: circleRect) + + // Icon or text + if let iconName = spec.iconName { + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .regular) + if let icon = UIImage(systemName: iconName, withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) { + let iconRect = CGRect( + x: centerX - icon.size.width / 2, + y: circleRect.midY - icon.size.height / 2, + width: icon.size.width, + height: icon.size.height + ) + icon.draw(in: iconRect) + } + } else if let text = spec.text { + let font = UIFont.systemFont(ofSize: 14, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let size = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: centerX - size.width / 2, + y: circleRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + + // Triangle pointer + let triangleTop = circleRect.maxY - 3 + let path = UIBezierPath() + path.move(to: CGPoint(x: centerX - triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX + triangleSize / 2, y: triangleTop)) + path.addLine(to: CGPoint(x: centerX, y: triangleTop + triangleSize)) + path.close() + spec.circleColor.setFill() + path.fill() + + // Hop badge overlay (ring pins only) + if let hopIndex, spec.ringColor != nil { + let badgeSize: CGFloat = 18 + let badgeX = circleRect.maxX + 4 - badgeSize + let badgeY = circleRect.minY + let badgeRect = CGRect(x: badgeX, y: badgeY, width: badgeSize, height: badgeSize) + + UIColor.systemBlue.setFill() + cgContext.fillEllipse(in: badgeRect) + + let text = "\(hopIndex)" + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = (text as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + } + + // MARK: - Pill sprites + + /// Semi-transparent stretchable pill for stats badges. + /// Registered as a resizable image so MapLibre's `iconTextFit` can stretch + /// the flat center while preserving the rounded caps. + private static func renderPillBackground() -> UIImage { + let cornerRadius: CGFloat = 4 + let size: CGFloat = 2 * cornerRadius + 2 + let shadowPadding: CGFloat = 1 + let totalSize = size + shadowPadding * 2 + let capInset = cornerRadius + shadowPadding + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize), format: .preferred()) + let image = renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: size, height: size) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + // Shadow pass + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + // Light fill for readability in both light and dark mode + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + } + + return image.resizableImage( + withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), + resizingMode: .stretch + ) + } + + private static func renderLabelSprite(text: String) -> UIImage { + let font = UIFont.systemFont(ofSize: 12, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.black] + let textSize = (text as NSString).size(withAttributes: attrs) + + let horizontalPadding: CGFloat = 6 + let verticalPadding: CGFloat = 4 + let cornerRadius: CGFloat = 4 + let shadowPadding: CGFloat = 1 + + let pillWidth = textSize.width + horizontalPadding * 2 + let pillHeight = textSize.height + verticalPadding * 2 + let totalWidth = pillWidth + shadowPadding * 2 + let totalHeight = pillHeight + shadowPadding * 2 + + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: totalWidth, height: totalHeight), + format: .preferred() + ) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let pillRect = CGRect(x: shadowPadding, y: shadowPadding, width: pillWidth, height: pillHeight) + let pillPath = UIBezierPath(roundedRect: pillRect, cornerRadius: cornerRadius) + + cgContext.saveGState() + cgContext.setShadow( + offset: CGSize(width: 0, height: 0.5), + blur: 1, + color: UIColor.black.withAlphaComponent(0.15).cgColor + ) + UIColor.white.setFill() + pillPath.fill() + cgContext.restoreGState() + + UIColor.white.withAlphaComponent(0.85).setFill() + pillPath.fill() + + let textRect = CGRect( + x: shadowPadding + (pillWidth - textSize.width) / 2, + y: shadowPadding + (pillHeight - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } + } + + private static func renderCrosshair(_ spec: SpriteSpec) -> UIImage { + let size: CGFloat = 44 + let gapRadius: CGFloat = 4 + let outerRadius = size / 2 + let badgeHeight: CGFloat = 20 + let totalHeight = size + badgeHeight + 2 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight), format: .preferred()) + return renderer.image { ctx in + let cgContext = ctx.cgContext + let center = CGPoint(x: size / 2, y: size / 2) + + // Crosshair lines + cgContext.setStrokeColor(UIColor.systemPurple.cgColor) + cgContext.setLineWidth(2) + + // Vertical + cgContext.move(to: CGPoint(x: center.x, y: center.y - outerRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y - gapRadius)) + cgContext.move(to: CGPoint(x: center.x, y: center.y + gapRadius)) + cgContext.addLine(to: CGPoint(x: center.x, y: center.y + outerRadius)) + + // Horizontal + cgContext.move(to: CGPoint(x: center.x - outerRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x - gapRadius, y: center.y)) + cgContext.move(to: CGPoint(x: center.x + gapRadius, y: center.y)) + cgContext.addLine(to: CGPoint(x: center.x + outerRadius, y: center.y)) + cgContext.strokePath() + + // "R" badge + let badgeWidth: CGFloat = 20 + let badgeRect = CGRect(x: center.x - badgeWidth / 2, y: size + 2, width: badgeWidth, height: badgeHeight) + let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 9) + UIColor.systemPurple.setFill() + badgePath.fill() + + let font = UIFont.systemFont(ofSize: 11, weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white] + let textSize = ("R" as NSString).size(withAttributes: attrs) + let textRect = CGRect( + x: badgeRect.midX - textSize.width / 2, + y: badgeRect.midY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + ("R" as NSString).draw(in: textRect, withAttributes: attrs) + } + } +} diff --git a/MC1/Views/Settings/LocationPickerView.swift b/MC1/Views/Settings/LocationPickerView.swift index 0e3c2fb6b..523d0ec87 100644 --- a/MC1/Views/Settings/LocationPickerView.swift +++ b/MC1/Views/Settings/LocationPickerView.swift @@ -7,15 +7,19 @@ import MC1Services struct LocationPickerView: View { @Environment(\.dismiss) private var dismiss @Environment(\.appState) private var appState + @Environment(\.colorScheme) private var colorScheme // Configuration private let initialCoordinate: CLLocationCoordinate2D? private let onSave: (CLLocationCoordinate2D) async throws -> Void + // Stable marker identity + private let markerID = UUID() + // UI State - @State private var position: MapCameraPosition = .automatic + @State private var cameraRegion: MKCoordinateRegion? + @State private var cameraRegionVersion = 0 @State private var selectedCoordinate: CLLocationCoordinate2D? - @State private var visibleRegion: MKCoordinateRegion? @State private var isSaving = false @State private var showError: String? @@ -31,26 +35,21 @@ struct LocationPickerView: View { var body: some View { NavigationStack { ZStack { - MapReader { proxy in - Map(position: $position, interactionModes: [.pan, .zoom]) { - if let coord = selectedCoordinate { - Marker(L10n.Settings.LocationPicker.markerTitle, coordinate: coord) - .tint(.blue) - } - } - .onTapGesture { screenLocation in - if let coordinate = proxy.convert(screenLocation, from: .local) { - selectedCoordinate = coordinate - } - } - .onMapCameraChange { context in - visibleRegion = context.region - } - .mapControls { - MapUserLocationButton() - MapCompass() - } - } + MC1MapView( + points: markerPoints, + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + cameraRegion: $cameraRegion, + cameraRegionVersion: cameraRegionVersion, + onPointTap: nil, + onMapTap: { coord in selectedCoordinate = coord }, + onCameraRegionChange: { region in cameraRegion = region } + ) // Center crosshair for precise placement Image(systemName: "plus") @@ -113,30 +112,45 @@ struct LocationPickerView: View { loadCurrentLocation() } .onChange(of: appState.locationService.currentLocation) { _, newLocation in - // Only react if we haven't set a position yet (no saved location case) + // Only react if we haven't set a camera region yet (no saved location case) guard let newLocation, initialCoordinate == nil || (initialCoordinate?.latitude == 0 && initialCoordinate?.longitude == 0), - position == .automatic else { return } + cameraRegion == nil else { return } - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: newLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 } .errorAlert($showError) } } + private var markerPoints: [MapPoint] { + guard let coord = selectedCoordinate else { return [] } + return [MapPoint( + id: markerID, + coordinate: coord, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )] + } + private func loadCurrentLocation() { // Case 1: Existing saved location if let coord = initialCoordinate, coord.latitude != 0 || coord.longitude != 0 { selectedCoordinate = coord - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: coord, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 return } @@ -144,23 +158,19 @@ struct LocationPickerView: View { let locationService = appState.locationService if locationService.isAuthorized { if let userLocation = locationService.currentLocation { - position = .region(MKCoordinateRegion( + cameraRegion = MKCoordinateRegion( center: userLocation.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - )) + ) + cameraRegionVersion += 1 } else if !locationService.isRequestingLocation { locationService.requestLocation() } } - - // Case 3: No saved location, no authorization - .automatic handles it } private func dropPinAtCenter() { - // Get center from tracked visible region, falling back to position.region - if let region = visibleRegion { - selectedCoordinate = region.center - } else if let region = position.region { + if let region = cameraRegion { selectedCoordinate = region.center } } @@ -200,13 +210,15 @@ private struct ButtonContent: View { Button(L10n.Settings.LocationPicker.clearLocation, role: .destructive) { onClear() } - .modifier(LocationPickerGlassButtonModifier(isProminent: false)) + .liquidGlassSecondaryButtonStyle() + .controlSize(.regular) } Button(L10n.Settings.LocationPicker.dropPin) { onDropPin() } - .modifier(LocationPickerGlassButtonModifier(isProminent: true)) + .liquidGlassProminentButtonStyle() + .controlSize(.regular) } } } @@ -273,30 +285,6 @@ private struct CoordinateGlassModifier: ViewModifier { } } -private struct LocationPickerGlassButtonModifier: ViewModifier { - let isProminent: Bool - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - if isProminent { - content - .buttonStyle(.glassProminent) - .controlSize(.regular) - } else { - content - .buttonStyle(.glass) - .controlSize(.regular) - } - } else { - if isProminent { - content.buttonStyle(.borderedProminent) - } else { - content.buttonStyle(.bordered) - } - } - } -} - private struct CoordinateText: View { let label: String let value: Double diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift new file mode 100644 index 000000000..2de6e5584 --- /dev/null +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -0,0 +1,448 @@ +import MapKit +import MapLibre +import SwiftUI + +struct OfflineMapSettingsView: View { + @Environment(\.appState) private var appState + @State private var showingRegionPicker = false + @State private var showError: String? + + var body: some View { + Group { + if appState.offlineMapService.packs.isEmpty { + ContentUnavailableView { + Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") + } description: { + Text(L10n.Settings.OfflineMaps.emptyDescription) + } actions: { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "arrow.down.circle") { + showingRegionPicker = true + } + .buttonStyle(.bordered) + } + } else { + List { + PacksSection() + StorageSection() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true + } + } + } + } + } + .navigationTitle(L10n.Settings.OfflineMaps.title) + .sheet(isPresented: $showingRegionPicker) { + RegionPickerSheet() + } + .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in + if let newValue { + showError = newValue + appState.offlineMapService.clearLastPackError() + } + } + .errorAlert($showError) + } + +} + +// MARK: - Packs Section + +private struct PacksSection: View { + @Environment(\.appState) private var appState + + var body: some View { + Section { + ForEach(appState.offlineMapService.packs) { pack in + OfflinePackRow(pack: pack) + } + .onDelete { indexSet in + if let index = indexSet.first { + let pack = appState.offlineMapService.packs[index] + Task { await appState.offlineMapService.deletePack(pack) } + } + } + } + } +} + +// MARK: - Storage Section + +private struct StorageSection: View { + @Environment(\.appState) private var appState + + var body: some View { + Section { + LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { + Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) + } + } header: { + Text(L10n.Settings.OfflineMaps.storage) + } footer: { + Text(L10n.Settings.OfflineMaps.storageFooter) + } + } +} + +// MARK: - Offline Pack Row + +private struct OfflinePackRow: View { + @Environment(\.appState) private var appState + let pack: OfflinePack + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(pack.name) + Text("— \(pack.layer.label)") + .foregroundStyle(.secondary) + } + + HStack { + if pack.isComplete { + Text(L10n.Settings.OfflineMaps.complete) + .foregroundStyle(.secondary) + } else if pack.isPaused { + Text(L10n.Settings.OfflineMaps.paused) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.OfflineMaps.downloading) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing) { + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + if let speed = pack.downloadSpeed, speed > 0 { + Text("\(speed, format: .byteCount(style: .file))/s") + } + } + .foregroundStyle(.secondary) + } + .font(.caption) + + if !pack.isComplete { + HStack { + ProgressView(value: pack.completedFraction) + + Button( + pack.isPaused + ? L10n.Settings.OfflineMaps.resume + : L10n.Settings.OfflineMaps.pause, + systemImage: pack.isPaused ? "play.fill" : "pause.fill" + ) { + if pack.isPaused { + appState.offlineMapService.resumePack(pack) + } else { + appState.offlineMapService.pausePack(pack) + } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + } + } + } +} + +// MARK: - Region Picker Sheet + +private struct RegionPickerSheet: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @State private var regionName = "" + @State private var cameraRegion: MKCoordinateRegion? + @State private var isDownloading = false + @State private var showError: String? + @State private var mapSize: CGSize = .zero + @State private var includeTopo = false + @State private var isStyleLoaded = false + @State private var debouncedRegion: MKCoordinateRegion? + @State private var debounceTask: Task? + @State private var availableBytes: Int64? + + private static let selectionPadding: CGFloat = 40 + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: [], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + isNorthLocked: true, + cameraRegion: $cameraRegion, + cameraRegionVersion: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { region in + cameraRegion = region + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + debouncedRegion = region + } + }, + isStyleLoaded: $isStyleLoaded + ) + + // Selection rectangle overlay + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(Self.selectionPadding) + .allowsHitTesting(false) + } + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newValue in + mapSize = newValue + } + .navigationTitle(L10n.Settings.OfflineMaps.pickRegion) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.OfflineMaps.cancel) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Settings.OfflineMaps.download) { + downloadRegion() + } + .disabled( + regionName.isEmpty || isDownloading || exceedsAvailableSpace + || !appState.offlineMapService.isNetworkAvailable + || selectionBounds == nil + ) + } + } + .safeAreaInset(edge: .bottom) { + RegionPickerBottomCard( + regionName: $regionName, + includeTopo: $includeTopo, + estimatedDownloadBytes: estimatedDownloadBytes, + exceedsAvailableSpace: exceedsAvailableSpace, + isNetworkAvailable: appState.offlineMapService.isNetworkAvailable + ) + } + .errorAlert($showError) + .onAppear { refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.latitude) { _, _ in refreshAvailableBytes() } + .onChange(of: debouncedRegion?.center.longitude) { _, _ in refreshAvailableBytes() } + } + } + + // MARK: - Download Estimate + + private var selectedLayers: Set { + var layers: Set = [.base] + if includeTopo { layers.insert(.topo) } + return layers + } + + private var estimatedDownloadBytes: Int64? { + guard let bounds = selectionBounds else { return nil } + return selectedLayers.reduce(Int64(0)) { total, layer in + total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) + } + } + + private var exceedsAvailableSpace: Bool { + guard let estimated = estimatedDownloadBytes, + let available = availableBytes else { return false } + return estimated > available + } + + private func refreshAvailableBytes() { + let values = try? URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + availableBytes = values?.volumeAvailableCapacityForImportantUsage + } + + // MARK: - Bounds + + private var selectionBounds: MLNCoordinateBounds? { + guard let region = debouncedRegion, + mapSize.width > 0, mapSize.height > 0 else { return nil } + + let lonFraction = Self.selectionPadding / (mapSize.width / 2) + let latFraction = Self.selectionPadding / (mapSize.height / 2) + + let latInset = region.span.latitudeDelta * latFraction + let lonInset = region.span.longitudeDelta * lonFraction + + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D( + latitude: region.center.latitude - (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude - (region.span.longitudeDelta / 2 - lonInset) + ), + ne: CLLocationCoordinate2D( + latitude: region.center.latitude + (region.span.latitudeDelta / 2 - latInset), + longitude: region.center.longitude + (region.span.longitudeDelta / 2 - lonInset) + ) + ) + } + + // MARK: - Download + + private func downloadRegion() { + guard let bounds = selectionBounds else { return } + isDownloading = true + + let layers = selectedLayers + + Task { + defer { isDownloading = false } + do { + try await appState.offlineMapService.downloadRegion( + name: regionName, + bounds: bounds, + layers: layers + ) + dismiss() + } catch { + showError = error.localizedDescription + } + } + } +} + +// MARK: - Region Picker Bottom Card + +private struct RegionPickerBottomCard: View { + @Binding var regionName: String + @Binding var includeTopo: Bool + let estimatedDownloadBytes: Int64? + let exceedsAvailableSpace: Bool + let isNetworkAvailable: Bool + + /// Warn when estimated download exceeds 500 MB. + private static let largeDownloadThreshold: Int64 = 500_000_000 + + @State private var footerMinHeight: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) + .textFieldStyle(.plain) + + Divider() + + VStack(alignment: .leading) { + Text(L10n.Settings.OfflineMaps.layers) + .font(.caption) + .foregroundStyle(.secondary) + + Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + } + .toggleStyle(.switch) + .controlSize(.mini) + + Divider() + + footerContent + .frame(minHeight: footerMinHeight, alignment: .top) + .background { + // Measure the tallest possible footer state (estimate + warning) + // to reserve stable space regardless of current state or Dynamic Type size. + tallestFooterState + .hidden() + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + footerMinHeight = height + } + } + } + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 4) + } + + @ViewBuilder + private var footerContent: some View { + VStack(alignment: .leading, spacing: 12) { + if !isNetworkAvailable { + HStack { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + Text(L10n.Settings.OfflineMaps.noNetwork) + .foregroundStyle(.red) + } + .font(.caption) + } else if let bytes = estimatedDownloadBytes { + let isLarge = bytes > Self.largeDownloadThreshold + + HStack { + if exceedsAvailableSpace { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } else if isLarge { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + + Text(L10n.Settings.OfflineMaps.estimatedSize( + bytes.formatted(.byteCount(style: .file)) + )) + .foregroundStyle(exceedsAvailableSpace ? .red : isLarge ? .orange : .secondary) + } + .font(.caption) + + if exceedsAvailableSpace { + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + .foregroundStyle(.red) + } else if isLarge { + Text(L10n.Settings.OfflineMaps.largeTileWarning) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text(L10n.Settings.OfflineMaps.downloadHint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + /// The tallest possible single footer state: estimate line + the longest warning. + /// Rendered hidden to measure the height needed at the current Dynamic Type size. + private var tallestFooterState: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "xmark.circle.fill") + Text(L10n.Settings.OfflineMaps.estimatedSize("999 GB")) + } + .font(.caption) + + Text(L10n.Settings.OfflineMaps.exceedsStorage) + .font(.caption) + } + } +} + +#Preview { + NavigationStack { + OfflineMapSettingsView() + .environment(\.appState, AppState()) + } +} diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index f31c36658..174abd9ab 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -11,25 +11,23 @@ struct DiagnosticsSection: View { var body: some View { Section { - Button { - exportLogs() - } label: { - HStack { - TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "arrow.up.doc") - Spacer() - if isExporting { - ProgressView() - } + if let url = exportedFileURL { + ShareLink(item: url) { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "square.and.arrow.up") } - } - .disabled(isExporting) - .sheet(isPresented: Binding( - get: { exportedFileURL != nil }, - set: { if !$0 { exportedFileURL = nil } } - )) { - if let url = exportedFileURL { - ShareSheet(items: [url]) + } else { + Button { + exportLogs() + } label: { + HStack { + TintedLabel(L10n.Settings.Diagnostics.exportLogs, systemImage: "arrow.up.doc") + Spacer() + if isExporting { + ProgressView() + } + } } + .disabled(isExporting) } Button(role: .destructive) { @@ -55,6 +53,7 @@ struct DiagnosticsSection: View { private func exportLogs() { let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + exportedFileURL = nil isExporting = true Task { @MainActor in @@ -77,9 +76,7 @@ struct DiagnosticsSection: View { do { try await dataStore.clearDebugLogEntries() } catch { - await MainActor.run { - showError = error.localizedDescription - } + showError = error.localizedDescription } } } diff --git a/MC1/Views/Settings/SettingsView.swift b/MC1/Views/Settings/SettingsView.swift index 78899113d..4c4a76793 100644 --- a/MC1/Views/Settings/SettingsView.swift +++ b/MC1/Views/Settings/SettingsView.swift @@ -90,6 +90,12 @@ private struct SettingsListContent: View { detail: currentLanguageDisplayName ) } + + NavigationLink { + OfflineMapSettingsView() + } label: { + TintedLabel(L10n.Settings.OfflineMaps.title, systemImage: "map.fill") + } } header: { Text(L10n.Settings.AppSettings.header) } diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 58c5e5aa2..646c041cc 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -175,7 +175,7 @@ struct RxLogView: View { ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() } @State private var showClearConfirmation = false @@ -201,7 +201,7 @@ struct RxLogView: View { } label: { Label(L10n.Tools.Tools.RxLog.more, systemImage: "ellipsis.circle") } - .modifier(GlassButtonModifier()) + .liquidGlassSecondaryButtonStyle() .confirmationDialog(L10n.Tools.Tools.RxLog.deleteConfirmation, isPresented: $showClearConfirmation, titleVisibility: .visible) { Button(L10n.Tools.Tools.RxLog.delete, role: .destructive) { clearLog() diff --git a/project.yml b/project.yml index 38e67d9c8..ecd044233 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,9 @@ packages: Emojibase: url: https://github.com/matrix-org/emojibase-bindings from: 1.5.0 + MapLibre: + url: https://github.com/maplibre/maplibre-gl-native-distribution + from: 6.23.0 targets: MC1: @@ -35,6 +38,7 @@ targets: dependencies: - package: MC1Services - package: Emojibase + - package: MapLibre - target: MC1Widgets preBuildScripts: - name: SwiftGen