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