From d5f17670d6d4e83c762e10375c7c318529e812aa Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:24:12 -0700 Subject: [PATCH 01/55] feat(map): migrate from MapKit to MapLibre Native Replace MapKit with MapLibre Native for all map views (main map, Line of Sight, Trace Path, LocationPicker, offline maps). Introduces a unified MC1MapView (UIViewRepresentable) with data-driven GeoJSON layers, sprite- based pin rendering, and raster tile overlays for satellite/topo styles. Key changes: - Add MC1MapView with clustered/fixed point layers and line style layers - Add PinSpriteRenderer for offline-capable map pin sprites - Add OfflineMapService and settings UI for offline map pack management - Migrate LOS, TracePath, ContactDetail, and LocationPicker to MC1MapView - Remove all legacy MapKit representables and annotation views - Split ChatConversationView into ChatView and ChannelChatView - Add MapLibre Native SPM dependency (maplibre-gl-native-distribution 6.23+) --- .gitignore | 1 + MC1/Resources/Generated/L10n.swift | 14 +- .../Localization/de.lproj/Map.strings | 9 +- .../Localization/de.lproj/Settings.strings | 53 ++ .../Localization/en.lproj/Map.strings | 9 +- .../Localization/en.lproj/Settings.strings | 53 ++ .../Localization/es.lproj/Map.strings | 9 +- .../Localization/es.lproj/Settings.strings | 53 ++ .../Localization/fr.lproj/Map.strings | 9 +- .../Localization/fr.lproj/Settings.strings | 53 ++ .../Localization/nl.lproj/Map.strings | 9 +- .../Localization/nl.lproj/Settings.strings | 53 ++ .../Localization/pl.lproj/Map.strings | 9 +- .../Localization/pl.lproj/Settings.strings | 53 ++ .../Localization/ru.lproj/Map.strings | 9 +- .../Localization/ru.lproj/Settings.strings | 53 ++ .../Localization/uk.lproj/Map.strings | 9 +- .../Localization/uk.lproj/Settings.strings | 53 ++ .../Localization/zh-Hans.lproj/Map.strings | 9 +- .../zh-Hans.lproj/Settings.strings | 53 ++ MC1/Services/OfflineMapService.swift | 141 +++++ MC1/State/AppState.swift | 7 + MC1/Views/Components/MapControlsToolbar.swift | 2 +- MC1/Views/Components/NoDoubleTapMapView.swift | 31 -- MC1/Views/Contacts/ContactDetailView.swift | 43 +- .../TracePathMap/PathLineOverlay.swift | 89 ---- .../TracePathMap/PathLineRenderer.swift | 39 -- .../TracePathMap/StatsBadgeAnnotation.swift | 37 -- .../TracePathMap/StatsBadgeView.swift | 89 ---- .../TracePathMap/TracePathClusterView.swift | 86 --- .../TracePathMap/TracePathMKMapView.swift | 316 ----------- .../TracePathMap/TracePathMapView.swift | 59 ++- .../TracePathMap/TracePathMapViewModel.swift | 139 ++--- .../TracePathRepeaterPinView.swift | 264 ---------- MC1/Views/LineOfSight/LineOfSightView.swift | 212 ++++---- .../LineOfSight/LineOfSightViewModel.swift | 38 +- .../LineOfSight/Map/LOSAnnotations.swift | 50 -- MC1/Views/LineOfSight/Map/LOSMKMapView.swift | 497 ------------------ .../LineOfSight/Map/LOSPathOverlay.swift | 18 - .../LineOfSight/Map/LOSPointPinView.swift | 88 ---- .../LineOfSight/Map/LOSRepeaterPinView.swift | 255 --------- .../Map/LOSRepeaterTargetPinView.swift | 116 ---- MC1/Views/Map/ContactAnnotation.swift | 41 -- MC1/Views/Map/ContactCalloutContent.swift | 4 +- MC1/Views/Map/ContactNameLabel.swift | 27 - MC1/Views/Map/ContactPinView.swift | 298 ----------- MC1/Views/Map/LayersMenu.swift | 7 +- MC1/Views/Map/MC1MapView+Layers.swift | 323 ++++++++++++ MC1/Views/Map/MC1MapView.swift | 338 ++++++++++++ MC1/Views/Map/MKMapViewRepresentable.swift | 399 -------------- MC1/Views/Map/MapLine.swift | 26 + MC1/Views/Map/MapPoint.swift | 37 ++ MC1/Views/Map/MapStyleSelection.swift | 33 +- MC1/Views/Map/MapTileURLs.swift | 8 + MC1/Views/Map/MapView.swift | 194 +++---- MC1/Views/Map/MapViewModel.swift | 16 +- MC1/Views/Map/PinSpriteRenderer.swift | 267 ++++++++++ MC1/Views/Settings/LocationPickerView.swift | 80 +-- .../Settings/OfflineMapSettingsView.swift | 236 +++++++++ MC1/Views/Settings/SettingsView.swift | 6 + project.yml | 4 + 61 files changed, 2412 insertions(+), 3121 deletions(-) create mode 100644 MC1/Services/OfflineMapService.swift delete mode 100644 MC1/Views/Components/NoDoubleTapMapView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/StatsBadgeAnnotation.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/StatsBadgeView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathClusterView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathMKMapView.swift delete mode 100644 MC1/Views/Contacts/TracePathMap/TracePathRepeaterPinView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSAnnotations.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSMKMapView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSPathOverlay.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSPointPinView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSRepeaterPinView.swift delete mode 100644 MC1/Views/LineOfSight/Map/LOSRepeaterTargetPinView.swift delete mode 100644 MC1/Views/Map/ContactAnnotation.swift delete mode 100644 MC1/Views/Map/ContactNameLabel.swift delete mode 100644 MC1/Views/Map/ContactPinView.swift create mode 100644 MC1/Views/Map/MC1MapView+Layers.swift create mode 100644 MC1/Views/Map/MC1MapView.swift delete mode 100644 MC1/Views/Map/MKMapViewRepresentable.swift create mode 100644 MC1/Views/Map/MapLine.swift create mode 100644 MC1/Views/Map/MapPoint.swift create mode 100644 MC1/Views/Map/MapTileURLs.swift create mode 100644 MC1/Views/Map/PinSpriteRenderer.swift create mode 100644 MC1/Views/Settings/OfflineMapSettingsView.swift diff --git a/.gitignore b/.gitignore index dd2ad8bae..78e46200e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ playground.xcworkspace build/ .build/ +.spm-cache/ # CocoaPods # diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f8856f482..4946ab88a 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2102,13 +2102,17 @@ 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: 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: "Topo") } } } @@ -3807,12 +3811,6 @@ public enum L10n { public static let title = L10n.tr("Settings", "regenerateIdentity.sheet.title", fallback: "Regenerate Key") } } - public enum ReplyWithQuote { - /// Replying includes a preview of the original message. - public static let footer = L10n.tr("Settings", "replyWithQuote.footer", fallback: "Replying includes a preview of the original message.") - /// Reply with Quote - public static let toggle = L10n.tr("Settings", "replyWithQuote.toggle", fallback: "Reply with Quote") - } public enum Telemetry { /// Toggle label for allowing telemetry requests public static let allowRequests = L10n.tr("Settings", "telemetry.allowRequests", fallback: "Allow Telemetry Requests") diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index b50ba436e..ddfd12410 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..2b2b43791 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Heruntergeladen"; + +/* 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" = "Abgeschlossen"; + +/* 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"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index b4575e2a6..a3045a552 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..703b44840 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1269,3 +1269,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Downloaded"; + +/* 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" = "Complete"; + +/* 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"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 608148012..4d936f1c1 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..63a779fb5 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Descargados"; + +/* 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" = "Completado"; + +/* 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"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index e7ac361ef..451abcea6 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..83a51e9ae 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Téléchargées"; + +/* 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" = "Terminé"; + +/* 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"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index c14cbe175..c58b31a35 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..8928a6862 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Gedownload"; + +/* 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" = "Voltooid"; + +/* 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"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 8424914f3..9e85cbaab 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Topo"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..b8ccf9559 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Pobrane"; + +/* 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" = "Ukończono"; + +/* 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ń"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 725c4dc60..d98f2da5a 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Топо"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..287686a28 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Скачанные"; + +/* 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" = "Удалить"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 4741c2db9..fc1112954 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "Топо"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..0e9b569fd 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1264,3 +1264,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "Завантажені"; + +/* 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" = "Видалити"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index a2129c897..3c3ed3368 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -47,8 +47,8 @@ /* 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" = "地形"; // MARK: - Contact Detail Sheet @@ -155,3 +155,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..8bf7848fa 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1237,3 +1237,56 @@ // 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 downloaded maps list */ +"offlineMaps.downloaded" = "已下载"; + +/* 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" = "删除"; diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift new file mode 100644 index 000000000..8148eb124 --- /dev/null +++ b/MC1/Services/OfflineMapService.swift @@ -0,0 +1,141 @@ +import MapLibre +import Network +import os + +struct OfflinePackMetadata: Codable { + let name: String + let createdAt: Date +} + +@MainActor @Observable +final class OfflineMapService { + static let logger = Logger(subsystem: "com.pocketmesh", category: "OfflineMapService") + + private(set) var packs: [OfflinePack] = [] + private(set) var isNetworkAvailable = true + + private let monitor = NWPathMonitor() + private var observationTasks: [Task] = [] + + init() { + let networkStream = AsyncStream { continuation in + 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?.loadPacks() + } + }) + 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)") + } + } + }) + + loadPacks() + } + + isolated deinit { + monitor.cancel() + for task in observationTasks { + task.cancel() + } + } + + func loadPacks() { + packs = (MLNOfflineStorage.shared.packs ?? []).map { OfflinePack(pack: $0) } + } + + func downloadRegion( + name: String, + bounds: MLNCoordinateBounds, + minZoom: Double = 10, + maxZoom: Double = 15 + ) async throws { + // swiftlint:disable:next force_unwrapping + let styleURL = URL(string: MapTileURLs.openFreeMapLiberty)! + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + + let context = try JSONEncoder().encode(OfflinePackMetadata(name: name, createdAt: .now)) + + 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() + } + + func deletePack(_ pack: OfflinePack) async { + 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() + } + } + loadPacks() + } + + func resumeAllPacks() { + for pack in MLNOfflineStorage.shared.packs ?? [] { + if pack.state == .inactive { + pack.resume() + } + } + } +} + +struct OfflinePack: Identifiable { + let id: ObjectIdentifier + let mlnPack: MLNOfflinePack + let name: String + let createdAt: Date? + let progress: MLNOfflinePackProgress + let state: MLNOfflinePackState + + var completedFraction: Double { + guard progress.countOfResourcesExpected > 0 else { return 0 } + return Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) + } + + var completedBytes: UInt64 { progress.countOfBytesCompleted } + var isComplete: Bool { state == .complete } + + init(pack: MLNOfflinePack) { + self.id = ObjectIdentifier(pack) + self.mlnPack = pack + self.progress = pack.progress + self.state = pack.state + + let context = pack.context + if let metadata = try? JSONDecoder().decode(OfflinePackMetadata.self, from: context) { + self.name = metadata.name + self.createdAt = metadata.createdAt + } else { + self.name = L10n.Settings.OfflineMaps.unknownRegion + self.createdAt = nil + } + } +} diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index dccc78799..7431e4d01 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -23,6 +23,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() + // MARK: - Connection (via ConnectionManager) /// The connection manager for device lifecycle @@ -628,6 +633,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/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index ae416dce1..791cc49ca 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -7,7 +7,7 @@ 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. 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/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index f4b19ab1c..a3553e2e5 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -698,6 +698,8 @@ private struct ContactInfoSection: View { } private struct ContactLocationSection: View { + @Environment(\.colorScheme) private var colorScheme + let currentContact: ContactDTO private var contactCoordinate: CLLocationCoordinate2D { @@ -710,12 +712,33 @@ private struct ContactLocationSection: View { 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: contactCoordinate, + pinStyle: pinStyle(for: currentContact), + label: currentContact.displayName, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: false, + isInteractive: false, + showsScale: false, + cameraRegion: .constant(MKCoordinateRegion( + center: contactCoordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + )), + cameraRegionVersion: 1, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: nil, + isStyleLoaded: .constant(true) + ) .frame(height: 200) .clipShape(.rect(cornerRadius: 12)) .listRowInsets(EdgeInsets()) @@ -751,6 +774,14 @@ private struct ContactLocationSection: View { mapItem.name = currentContact.displayName mapItem.openInMaps() } + + private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { + switch contact.type { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } } private struct ContactNetworkPathSection: View { diff --git a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift b/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift deleted file mode 100644 index b414ff6c1..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineOverlay.swift +++ /dev/null @@ -1,89 +0,0 @@ -import MapKit - -/// Custom polyline overlay that carries signal quality data for styling -/// Note: All properties are immutable - create new overlay instances when signal quality changes -final class PathLineOverlay: MKPolyline { - - /// Signal quality determines line color after trace - enum SignalQuality { - case untraced // Dashed gray (before trace) - case good // Solid green (SNR >= 5) - case medium // Solid yellow (SNR -5 to 5) - case weak // Solid red (SNR < -5) - - init(snr: Double) { - if snr >= 5 { - self = .good - } else if snr >= -5 { - self = .medium - } else { - self = .weak - } - } - } - - /// Signal quality - immutable after creation - private(set) var signalQuality: SignalQuality = .untraced - - /// Distance in meters between the two endpoints - immutable after creation - private(set) var distanceMeters: Double = 0 - - /// SNR value in dB - immutable after creation - private(set) var snr: Double = 0 - - /// Index of this segment in the path (0 = user to first hop) - immutable after creation - private(set) var segmentIndex: Int = 0 - - /// Start coordinate for this segment - private(set) var startCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// End coordinate for this segment - private(set) var endCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D() - - /// Create overlay between two coordinates - static func line( - from start: CLLocationCoordinate2D, - to end: CLLocationCoordinate2D, - segmentIndex: Int, - signalQuality: SignalQuality = .untraced, - snr: Double = 0 - ) -> PathLineOverlay { - var coords = [start, end] - let overlay = PathLineOverlay(coordinates: &coords, count: 2) - overlay.segmentIndex = segmentIndex - overlay.signalQuality = signalQuality - overlay.snr = snr - overlay.startCoordinate = start - overlay.endCoordinate = end - - // Calculate distance - let startLocation = CLLocation(latitude: start.latitude, longitude: start.longitude) - let endLocation = CLLocation(latitude: end.latitude, longitude: end.longitude) - overlay.distanceMeters = startLocation.distance(from: endLocation) - - return overlay - } - - /// Create a new overlay with updated signal quality (immutable pattern) - func withSignalQuality(_ quality: SignalQuality, snr: Double) -> PathLineOverlay { - PathLineOverlay.line( - from: startCoordinate, - to: endCoordinate, - segmentIndex: segmentIndex, - signalQuality: quality, - snr: snr - ) - } - - /// Midpoint coordinate for placing stats badge - var midpoint: CLLocationCoordinate2D { - guard pointCount >= 2 else { return coordinate } - let points = self.points() - let start = points[0] - let end = points[1] - return CLLocationCoordinate2D( - latitude: (start.coordinate.latitude + end.coordinate.latitude) / 2, - longitude: (start.coordinate.longitude + end.coordinate.longitude) / 2 - ) - } -} diff --git a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift b/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift deleted file mode 100644 index 9c233e7aa..000000000 --- a/MC1/Views/Contacts/TracePathMap/PathLineRenderer.swift +++ /dev/null @@ -1,39 +0,0 @@ -import MapKit -import UIKit - -/// Renderer for PathLineOverlay that draws dashed or solid colored lines -/// Note: Since PathLineOverlay is immutable, create new overlays when signal quality changes -/// rather than calling updateAppearance on existing renderers -final class PathLineRenderer: MKPolylineRenderer { - - override init(overlay: any MKOverlay) { - super.init(overlay: overlay) - configureAppearance() - } - - private func configureAppearance() { - guard let pathOverlay = overlay as? PathLineOverlay else { return } - - switch pathOverlay.signalQuality { - case .untraced: - strokeColor = UIColor.systemGray - lineWidth = 2 - lineDashPattern = [8, 6] - - case .good: - strokeColor = UIColor.systemGreen - lineWidth = 4 // Thicker for accessibility (color-blind users) - lineDashPattern = nil - - case .medium: - strokeColor = UIColor.systemYellow - lineWidth = 3 - lineDashPattern = [12, 4] // Different pattern for accessibility - - case .weak: - strokeColor = UIColor.systemRed - lineWidth = 3 - lineDashPattern = [4, 4] // Different pattern for accessibility - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/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/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/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 0b8dabc68..9d313272e 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -8,6 +8,7 @@ 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? @State private var mapViewModel = TracePathMapViewModel() @@ -96,24 +97,56 @@ struct TracePathMapView: View { // MARK: - Map Content + private var mapPoints: [MapPoint] { + var points: [MapPoint] = [] + let pathState = mapViewModel.pathState + + for repeater in mapViewModel.repeatersWithLocation { + let info = pathState[repeater.id] + let inPath = info?.inPath ?? false + let style: MapPoint.PinStyle = inPath ? .repeaterRingWhite : .repeater + points.append(MapPoint( + id: repeater.id, + coordinate: repeater.coordinate, + pinStyle: style, + label: mapViewModel.showLabels ? repeater.displayName : nil, + isClusterable: !inPath, + hopIndex: info?.hopIndex, + badgeText: nil + )) + } + + points.append(contentsOf: mapViewModel.badgePoints) + return points + } + private var mapContent: some View { - TracePathMKMapView( - repeaters: mapViewModel.repeatersWithLocation, - lineOverlays: mapViewModel.lineOverlays, - badgeAnnotations: mapViewModel.badgeAnnotations, - mapType: mapViewModel.mapType, + MC1MapView( + points: mapPoints, + lines: mapViewModel.mapLines, + mapStyle: mapViewModel.mapStyleSelection, + isDarkMode: colorScheme == .dark, showLabels: mapViewModel.showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, 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 + 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 + }, + isStyleLoaded: .constant(true) ) .ignoresSafeArea() } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 459e5f3a1..0a11b330a 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -22,19 +22,10 @@ final class TracePathMapViewModel { /// 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] = [] // MARK: - Dependencies @@ -203,96 +194,119 @@ final class TracePathMapViewModel { // MARK: - Overlay Management - /// Rebuild line overlays and badge annotations based on current path + /// Rebuild map lines based on current path func rebuildOverlays() { clearOverlays() 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 + )) } 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) + var updatedLines: [MapLine] = [] + for (index, line) in mapLines.enumerated() { let hopIndex = index + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let quality = PathLineOverlay.SignalQuality(snr: hop.snr) - - // Create new overlay with signal quality - let updatedOverlay = overlay.withSignalQuality(quality, snr: hop.snr) - updatedOverlays.append(updatedOverlay) - - // Add badge annotation at midpoint - let badge = StatsBadgeAnnotation( - coordinate: updatedOverlay.midpoint, - distanceMeters: updatedOverlay.distanceMeters, - snr: hop.snr, - segmentIndex: index - ) - badgeAnnotations.append(badge) + let quality = signalQuality(snr: hop.snr) + let style = lineStyle(for: quality) + + updatedLines.append(MapLine( + id: line.id, + coordinates: line.coordinates, + style: style, + opacity: 1.0 + )) + + // 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 miles = distance / 1609.34 + let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) + let distFormatted = miles.formatted(.number.precision(.fractionLength(1))) + + // swiftlint:disable:next force_unwrapping + badgePoints.append(MapPoint( + id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", index))")!, + coordinate: mid, + pinStyle: .badge, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: "\(distFormatted) mi · \(snrFormatted) dB" + )) + } } else { - updatedOverlays.append(overlay) + updatedLines.append(line) } } - lineOverlays = updatedOverlays - logger.debug("Updated overlays with results, added \(self.badgeAnnotations.count) badges") + mapLines = updatedLines + } + + // MARK: - Signal Quality + + private enum SignalQuality { + case untraced, weak, medium, good + } + + private func signalQuality(snr: Double) -> SignalQuality { + if snr <= 0 { return .weak } + if snr < 5 { return .medium } + return .good + } + + private func lineStyle(for quality: SignalQuality) -> MapLine.LineStyle { + switch quality { + case .untraced: .traceUntraced + case .weak: .traceWeak + case .medium: .traceMedium + case .good: .traceGood + } } /// Clear all overlays func clearOverlays() { - lineOverlays.removeAll() - badgeAnnotations.removeAll() + mapLines.removeAll() + badgePoints.removeAll() } // MARK: - Camera @@ -305,11 +319,8 @@ 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/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index a33bfeb16..6049ac868 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -6,7 +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 @@ -20,35 +19,6 @@ 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 /// Full-screen map view for analyzing line-of-sight between two points @@ -59,12 +29,10 @@ 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 + @State private var mapStyleSelection: MapStyleSelection = .topo @State private var sheetBottomInset: CGFloat = 220 @State private var isResultsExpanded = false @State private var isRFSettingsExpanded = false @@ -141,29 +109,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 { @@ -255,6 +204,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) @@ -281,11 +237,6 @@ struct LineOfSightView: View { } } - private var collapsedSheetFraction: Double { - guard showAnalysisSheet else { return 0 } - return 0.30 - } - // MARK: - Analysis Sheet private var analysisSheet: some View { @@ -1045,23 +996,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 +1032,7 @@ struct LineOfSightView: View { switch status { case .result: if showSheet { - withAnimation { - sheetDetent = analysisSheetDetentExpanded - } + sheetDetent = analysisSheetDetentExpanded } case .relayResult: break @@ -1110,7 +1042,7 @@ struct LineOfSightView: View { if viewModel.shouldAutoZoomOnNextResult { viewModel.shouldAutoZoomOnNextResult = false - viewModel.zoomToShowBothPoints(bottomInsetFraction: collapsedSheetFraction) + viewModel.zoomToShowBothPoints() } } @@ -1124,29 +1056,40 @@ 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: mapPoints, + lines: mapLines, + mapStyle: mapStyleSelection, + isDarkMode: colorScheme == .dark, showLabels: showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, 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 + }, + isStyleLoaded: .constant(true) ) .ignoresSafeArea() @@ -1212,7 +1155,7 @@ private struct LOSMapCanvasView: View { HStack { Spacer() VStack(spacing: 0) { - ForEach(LOSMapStyleSelection.allCases, id: \.self) { style in + ForEach(MapStyleSelection.allCases, id: \.self) { style in Button { mapStyleSelection = style withAnimation { @@ -1232,7 +1175,7 @@ private struct LOSMapCanvasView: View { .padding(.vertical, 12) } - if style != LOSMapStyleSelection.allCases.last { + if style != MapStyleSelection.allCases.last { Divider() } } @@ -1247,6 +1190,91 @@ private struct LOSMapCanvasView: View { } } } + + // MARK: - Map Data + + private var mapPoints: [MapPoint] { + var points: [MapPoint] = [] + + let selectionState = viewModel.selectionState + for repeater in viewModel.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 = viewModel.pointA, pointA.contact == nil { + points.append(MapPoint( + id: viewModel.pointAMapID, + coordinate: pointA.coordinate, + pinStyle: .pointA, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let pointB = viewModel.pointB, pointB.contact == nil { + points.append(MapPoint( + id: viewModel.pointBMapID, + coordinate: pointB.coordinate, + pinStyle: .pointB, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + if let target = viewModel.repeaterPoint { + points.append(MapPoint( + id: viewModel.repeaterTargetMapID, + coordinate: target.coordinate, + pinStyle: .crosshair, + label: nil, + isClusterable: false, + hopIndex: nil, + badgeText: nil + )) + } + + return points + } + + private var mapLines: [MapLine] { + guard let a = viewModel.pointA?.coordinate, + let b = viewModel.pointB?.coordinate else { return [] } + + let activeOpacity = 0.7 + let dimOpacity = 0.3 + + if let r = viewModel.repeaterPoint?.coordinate { + let opacityAR = viewModel.relocatingPoint == .pointA ? dimOpacity : activeOpacity + let opacityRB = viewModel.relocatingPoint == .pointB ? dimOpacity : activeOpacity + return [ + MapLine(id: "los-ar", coordinates: [a, r], style: .los, + opacity: viewModel.relocatingPoint == .repeater ? dimOpacity : opacityAR), + MapLine(id: "los-rb", coordinates: [r, b], style: .los, + opacity: viewModel.relocatingPoint == .repeater ? dimOpacity : opacityRB) + ] + } else { + let opacity = viewModel.relocatingPoint != nil ? dimOpacity : activeOpacity + return [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] + } + } } // MARK: - Frequency Input Row diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 4143807e7..dbe1fab15 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -114,6 +114,12 @@ 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? @@ -286,18 +292,14 @@ final class LineOfSightViewModel { 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 + paddingMultiplier: Double = 1.5 ) { guard !coordinates.isEmpty else { return } let lats = coordinates.map(\.latitude) @@ -305,27 +307,15 @@ final class LineOfSightViewModel { 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, + latitude: ((lats.min()! + lats.max()!) / 2).clamped(to: -90...90), longitude: (lons.min()! + lons.max()!) / 2 ), - span: MKCoordinateSpan(latitudeDelta: clampedLatDelta, longitudeDelta: clampedLonDelta) + span: MKCoordinateSpan( + latitudeDelta: min(latDelta, 180), + longitudeDelta: min(lonDelta, 360) + ) ) cameraRegionVersion += 1 } 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/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..e44eaac68 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -9,7 +9,9 @@ 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) 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..f9bb353d9 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -2,12 +2,15 @@ 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 body: some View { VStack(spacing: 0) { ForEach(MapStyleSelection.allCases, id: \.self) { style in + let isDisabled = style.requiresNetwork && !appState.offlineMapService.isNetworkAvailable + Button { selection = style withAnimation { @@ -16,7 +19,7 @@ struct LayersMenu: View { } label: { HStack { Text(style.label) - .foregroundStyle(.primary) + .foregroundStyle(isDisabled ? .secondary : .primary) Spacer() if selection == style { Image(systemName: "checkmark") @@ -26,6 +29,7 @@ struct LayersMenu: View { .padding(.horizontal, 16) .padding(.vertical, 12) } + .disabled(isDisabled) if style != MapStyleSelection.allCases.last { Divider() @@ -44,4 +48,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..115f6cad6 --- /dev/null +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -0,0 +1,323 @@ +import MapLibre +import UIKit + +/// Font stack available on the OpenFreeMap glyph server. +/// MapLibre's default ("Open Sans Regular") returns 404, causing silent symbol dropout. +private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: ["Noto Sans Regular"]) + +extension MC1MapView.Coordinator { + + // MARK: - Point layers + + /// Point sources and layers use deferred creation: they are created in + /// `updatePointSource` on first data arrival, not here. This avoids a + /// MapLibre bug where sources initialized without features ignore later + /// `.shape` updates. + func setupPointLayers(style: MLNStyle) {} + + // MARK: - Update point source data + + func updatePointSource(mapView: MLNMapView) { + guard let style = mapView.style else { return } + + let clusterablePoints = currentPoints.filter(\.isClusterable) + let fixedPoints = currentPoints.filter { !$0.isClusterable } + + // 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: "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: "fixed-points", features: features, options: nil) + style.addSource(source) + self.fixedSource = source + addFixedPointLayers(source: source, style: style) + } + } + + func updateLabelVisibility(mapView: MLNMapView) { + for layerId in ["name-labels", "fixed-name-labels"] { + 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: "cluster-circles", 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: "cluster-labels", 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: "unclustered-icons", 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: "name-labels", source: source) + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND nameLabel != nil") + nameLabelLayer.text = NSExpression(forKeyPath: "nameLabel") + nameLabelLayer.textFontSize = NSExpression(forConstantValue: 10) + nameLabelLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) + nameLabelLayer.textColor = NSExpression(forConstantValue: UIColor.label) + nameLabelLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + nameLabelLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) + nameLabelLayer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) + nameLabelLayer.textAnchor = NSExpression(forConstantValue: "bottom") + nameLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + nameLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + nameLabelLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + nameLabelLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + nameLabelLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") + nameLabelLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + nameLabelLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) + style.addLayer(nameLabelLayer) + + // Stats badge text (trace path midpoints) with pill background + let badgeLayer = MLNSymbolStyleLayer(identifier: "badge-text", source: source) + badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") + badgeLayer.text = NSExpression(forKeyPath: "badgeText") + badgeLayer.textFontSize = NSExpression(forConstantValue: 11) + badgeLayer.textFontNames = mapFontNames + badgeLayer.textColor = NSExpression(forConstantValue: UIColor.label) + badgeLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + badgeLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) + badgeLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + badgeLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + badgeLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") + badgeLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + badgeLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + style.addLayer(badgeLayer) + } + + // MARK: - Fixed point layers + + private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { + let fixedIconLayer = MLNSymbolStyleLayer(identifier: "fixed-icons", 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: "fixed-name-labels", source: source) + fixedNameLayer.predicate = NSPredicate(format: "nameLabel != nil") + fixedNameLayer.text = NSExpression(forKeyPath: "nameLabel") + fixedNameLayer.textFontSize = NSExpression(forConstantValue: 10) + fixedNameLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) + fixedNameLayer.textColor = NSExpression(forConstantValue: UIColor.label) + fixedNameLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + fixedNameLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) + fixedNameLayer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) + fixedNameLayer.textAnchor = NSExpression(forConstantValue: "bottom") + fixedNameLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + fixedNameLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + fixedNameLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) + fixedNameLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + fixedNameLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") + fixedNameLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + fixedNameLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) + style.addLayer(fixedNameLayer) + + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: "fixed-badge-text", source: source) + fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") + fixedBadgeLayer.text = NSExpression(forKeyPath: "badgeText") + fixedBadgeLayer.textFontSize = NSExpression(forConstantValue: 11) + fixedBadgeLayer.textFontNames = mapFontNames + fixedBadgeLayer.textColor = NSExpression(forConstantValue: UIColor.label) + fixedBadgeLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + fixedBadgeLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) + fixedBadgeLayer.textAllowsOverlap = NSExpression(forConstantValue: true) + fixedBadgeLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) + fixedBadgeLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") + fixedBadgeLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + fixedBadgeLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + style.addLayer(fixedBadgeLayer) + } + + // MARK: - Line layers + + func setupLineLayers(style: MLNStyle) { + let source = MLNShapeSource(identifier: "lines", features: [], options: nil) + style.addSource(source) + + let losLayer = MLNLineStyleLayer(identifier: "line-los", source: source) + losLayer.predicate = NSPredicate(format: "lineStyle == 'los'") + 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 untracedLayer = MLNLineStyleLayer(identifier: "line-trace-untraced", source: source) + untracedLayer.predicate = NSPredicate(format: "lineStyle == 'traceUntraced'") + untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) + untracedLayer.lineWidth = NSExpression(forConstantValue: 2) + untracedLayer.lineDashPattern = NSExpression(forConstantValue: [8, 6]) + style.addLayer(untracedLayer) + + let weakLayer = MLNLineStyleLayer(identifier: "line-trace-weak", source: source) + weakLayer.predicate = NSPredicate(format: "lineStyle == 'traceWeak'") + weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) + weakLayer.lineWidth = NSExpression(forConstantValue: 3) + weakLayer.lineDashPattern = NSExpression(forConstantValue: [4, 4]) + style.addLayer(weakLayer) + + let mediumLayer = MLNLineStyleLayer(identifier: "line-trace-medium", source: source) + mediumLayer.predicate = NSPredicate(format: "lineStyle == 'traceMedium'") + mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) + mediumLayer.lineWidth = NSExpression(forConstantValue: 3) + mediumLayer.lineDashPattern = NSExpression(forConstantValue: [12, 4]) + style.addLayer(mediumLayer) + + let goodLayer = MLNLineStyleLayer(identifier: "line-trace-good", source: source) + goodLayer.predicate = NSPredicate(format: "lineStyle == 'traceGood'") + 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: "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) { + let satSource = MLNRasterTileSource( + identifier: "satellite-tiles", + tileURLTemplates: [MapTileURLs.esriWorldImagery], + options: [ + .tileSize: 256, + .maximumZoomLevel: 19, + .attributionHTMLString: "Esri", + ] + ) + style.addSource(satSource) + let satLayer = MLNRasterStyleLayer(identifier: "satellite-layer", source: satSource) + satLayer.isVisible = false + style.addLayer(satLayer) + + let topoSource = MLNRasterTileSource( + identifier: "topo-tiles", + tileURLTemplates: [MapTileURLs.openTopoMapA], + options: [ + .tileSize: 256, + .maximumZoomLevel: 17, + .attributionHTMLString: "OpenTopoMap", + ] + ) + style.addSource(topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: "topo-layer", 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: "satellite-layer")?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: "topo-layer")?.isVisible = currentMapStyle == .topo + } + + // 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["nameLabel"] = 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..6ba33a20d --- /dev/null +++ b/MC1/Views/Map/MC1MapView.swift @@ -0,0 +1,338 @@ +import MapLibre +import MapKit +import OSLog +import SwiftUI + +private let logger = Logger(subsystem: "com.mc1", category: "MapPins") + +struct MC1MapView: UIViewRepresentable { + // Data + let points: [MapPoint] + let lines: [MapLine] + let mapStyle: MapStyleSelection + let isDarkMode: Bool + + // Configuration + let showLabels: Bool + let showsUserLocation: Bool + let isInteractive: Bool + let showsScale: Bool + + // 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 + @Binding var isStyleLoaded: Bool + + 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 + } + + // 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 = $0 } + coordinator.showLabels = showLabels + let pointsChanged = coordinator.currentPoints != points + let linesChanged = coordinator.currentLines != lines + coordinator.currentPoints = points + coordinator.currentLines = lines + + // Style URL change + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode) + if mapView.styleURL != newStyleURL { + coordinator.isStyleLoaded = false + coordinator.currentMapStyle = mapStyle + mapView.styleURL = newStyleURL + } + let mapStyleChanged = coordinator.currentMapStyle != mapStyle + coordinator.currentMapStyle = mapStyle + + // User location + mapView.showsUserLocation = showsUserLocation + + // Update data layers (only when style is loaded and data changed) + if coordinator.isStyleLoaded { + if mapStyleChanged { + coordinator.updateRasterLayerVisibility(mapView: mapView) + } + if pointsChanged { + coordinator.updatePointSource(mapView: mapView) + } + if linesChanged { + coordinator.updateLineSource(mapView: mapView) + } + coordinator.updateLabelVisibility(mapView: mapView) + } + + // 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 { + lazy var mapView: MLNMapView = { + let view = MLNMapView(frame: .zero) + return view + }() + + // Callbacks + var onPointTap: ((MapPoint, CGPoint) -> Void)? + var onMapTap: ((CLLocationCoordinate2D) -> Void)? + var onCameraRegionChange: ((MKCoordinateRegion) -> Void)? + var setIsStyleLoaded: ((Bool) -> Void)? + + // State + var isUpdatingFromSwiftUI = false + var isStyleLoaded = false + var lastAppliedRegionVersion = 0 + var pendingRegionTask: Task? + var showLabels = true + var currentMapStyle: MapStyleSelection? + var currentPoints: [MapPoint] = [] + var currentLines: [MapLine] = [] + var clusterSource: MLNShapeSource? + var fixedSource: MLNShapeSource? + + // MARK: - Style loading + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + isStyleLoaded = true + setIsStyleLoaded?(true) + + // Clear stale source references from the previous style. + clusterSource = nil + fixedSource = nil + + PinSpriteRenderer.renderAll(into: style) + setupRasterSources(style: style) + setupPointLayers(style: style) + setupLineLayers(style: style) + + updatePointSource(mapView: mapView) + updateLineSource(mapView: mapView) + } + + func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + logger.error("didFailToLoadImage: \(imageName)") + return nil + } + + // MARK: - Region changes + + private static let userGestureReasons: MLNCameraChangeReason = [ + .gesturePan, .gestureZoomIn, .gestureZoomOut, + .gestureRotate, .gestureTilt, .gestureOneFingerZoom + ] + + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + 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 { @MainActor in + 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: ["cluster-circles"] + ) + if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, + let source = mapView.style?.source(withIdentifier: "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 layers (both clustered and fixed) + let pointFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: ["unclustered-icons", "fixed-icons"] + ) + 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 + let badgeFeatures = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: ["badge-text", "fixed-badge-text"] + ) + if badgeFeatures.first != nil { + return // absorb tap on badges + } + + // 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/MapLine.swift b/MC1/Views/Map/MapLine.swift new file mode 100644 index 000000000..e31149ec2 --- /dev/null +++ b/MC1/Views/Map/MapLine.swift @@ -0,0 +1,26 @@ +import CoreLocation + +struct MapLine: Identifiable, Equatable { + let id: String + let coordinates: [CLLocationCoordinate2D] + let style: LineStyle + let opacity: Double + + 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..4c387c969 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -1,34 +1,37 @@ -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 { + func styleURL(isDarkMode: Bool) -> URL { switch self { - case .standard: .standard - case .satellite: .satellite - case .hybrid: .hybrid + case .standard: + let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + return URL(string: url)! + case .satellite, .topo: + // Satellite/topo use raster overlays on top of the base vector style. + // The base style URL is still needed; raster sources are added later. + let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + return URL(string: url)! } } } 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 c31d58d1b..4081d92f5 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -1,25 +1,16 @@ -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 + @Environment(\.colorScheme) private var colorScheme @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 { @@ -39,7 +30,7 @@ struct MapView: View { await viewModel.loadContactsWithLocation() viewModel.centerOnAllContacts() } - .sheet(item: $selectedContactForDetail, onDismiss: clearMapSnapshot) { contact in + .sheet(item: $selectedContactForDetail) { contact in ContactDetailSheet( contact: contact, onMessage: { navigateToChat(with: contact) } @@ -57,6 +48,11 @@ struct MapView: View { mapContent .ignoresSafeArea() + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } + // Floating controls VStack { Spacer() @@ -98,50 +94,80 @@ struct MapView: 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 - } - } + MC1MapView( + points: mapPoints, + lines: [], + mapStyle: viewModel.mapStyleSelection, + isDarkMode: colorScheme == .dark, + showLabels: viewModel.showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + 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 + 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: { showContactDetail(contact) }, + onMessage: { navigateToChat(with: contact) } ) - .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() - } + .presentationCompactAdaptation(.popover) } .overlay { - if viewModel.isLoading { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { loadingOverlay } } } } + private var mapPoints: [MapPoint] { + viewModel.contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: pinStyle(for: contact), + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) + } + } + + private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { + switch contact.type { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } + // MARK: - Empty State private var emptyState: some View { @@ -240,12 +266,9 @@ struct MapView: View { // 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,53 +277,36 @@ 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 + selectedCalloutContact = nil + selectedPointScreenPosition = nil selectedContactForDetail = contact - - // Capture snapshot after sheet animation completes to hide the transition - Task { - try? await Task.sleep(for: Self.sheetPresentationDuration) - // Guard against race condition if sheet was dismissed during delay - guard selectedContactForDetail != nil else { return } - await captureMapSnapshot() - isSnapshotActive = true - } - } - - /// Captures a static snapshot of the current map view to display while sheets are presented - private func captureMapSnapshot() async { - // Get camera and bounds directly from MKMapView for pixel-perfect match - // Using camera instead of region avoids MKMapSnapshotter's automatic aspect ratio adjustment - guard let params = getSnapshotParams?() else { return } - - let options = MKMapSnapshotter.Options() - options.camera = params.camera - options.size = params.size - options.scale = UIScreen.main.scale - options.mapType = viewModel.mapStyleSelection.mkMapType - options.showsBuildings = true - - let snapshotter = MKMapSnapshotter(options: options) - do { - let snapshot = try await snapshotter.start() - mapSnapshot = snapshot.image - } catch { - logger.warning("Map snapshot capture failed: \(error.localizedDescription)") - mapSnapshot = nil - } - } - - private func clearMapSnapshot() { - isSnapshotActive = false - mapSnapshot = nil } private func centerOnUserLocation() { guard let location = appState.locationService.currentLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) viewModel.cameraRegion = MKCoordinateRegion(center: location.coordinate, span: span) + viewModel.cameraRegionVersion += 1 + } +} + +// MARK: - Offline Badge + +private struct OfflineBadge: View { + var body: some View { + VStack { + HStack { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + Spacer() + } + .padding(.leading) + Spacer() + } } } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index 8cd716822..544e867c8 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -18,12 +18,12 @@ final class MapViewModel { /// 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? + /// Version counter for the camera region, incremented to signal a new camera target + var cameraRegionVersion = 1 + /// Current map style selection var mapStyleSelection: MapStyleSelection = .standard @@ -87,7 +87,7 @@ final class MapViewModel { // 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 + cameraRegionVersion += 1 } /// Center map to show all contacts @@ -122,11 +122,7 @@ final class MapViewModel { let span = MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) cameraRegion = MKCoordinateRegion(center: center, span: span) - } - - /// Clear selection - func clearSelection() { - selectedContact = nil + cameraRegionVersion += 1 } } diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift new file mode 100644 index 000000000..956bee31c --- /dev/null +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -0,0 +1,267 @@ +import MapLibre +import UIKit + +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 func renderAll(into style: MLNStyle) { + for spec in allSpecs { + let image = render(spec) + style.setImage(image, forName: spec.name) + } + // Transparent 1px sprite for badge points (only badge-text layer renders) + let transparent = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + style.setImage(transparent, forName: "pin-badge") + + // Hop badge variants for ring-white pins (trace path) + if let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) { + for hop in 1...20 { + let image = render(ringWhiteSpec, hopIndex: hop) + style.setImage(image, forName: "pin-repeater-ring-white-hop-\(hop)") + } + } + + // Stretchable pill background for label and badge layers + style.setImage(renderPillBackground(), forName: "pill-bg") + } + + // 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)) + 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 name labels and 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)) + 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() + + // Single fill at reduced opacity to approximate translucent blur + UIColor.secondarySystemBackground.withAlphaComponent(0.75).setFill() + pillPath.fill() + } + + return image.resizableImage( + withCapInsets: UIEdgeInsets(top: capInset, left: capInset, bottom: capInset, right: capInset), + resizingMode: .stretch + ) + } + + 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)) + 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..8361f96dd 100644 --- a/MC1/Views/Settings/LocationPickerView.swift +++ b/MC1/Views/Settings/LocationPickerView.swift @@ -7,13 +7,18 @@ 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 @@ -31,26 +36,22 @@ 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 visibleRegion = region }, + isStyleLoaded: .constant(true) + ) // Center crosshair for precise placement Image(systemName: "plus") @@ -113,30 +114,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,24 +160,20 @@ 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 { - selectedCoordinate = region.center } } diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift new file mode 100644 index 000000000..04ca41dba --- /dev/null +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -0,0 +1,236 @@ +import MapKit +import MapLibre +import SwiftUI + +struct OfflineMapSettingsView: View { + @Environment(\.appState) private var appState + @State private var showingRegionPicker = false + @State private var packToDelete: OfflinePack? + + var body: some View { + List { + 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 { + PacksSection(packToDelete: $packToDelete) + StorageSection() + } + } + .navigationTitle(L10n.Settings.OfflineMaps.title) + .toolbar { + if !appState.offlineMapService.packs.isEmpty { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true + } + } + } + } + .sheet(isPresented: $showingRegionPicker) { + RegionPickerSheet() + } + .alert( + L10n.Settings.OfflineMaps.deleteTitle, + isPresented: .init( + get: { packToDelete != nil }, + set: { if !$0 { packToDelete = nil } } + ) + ) { + Button(L10n.Settings.OfflineMaps.delete, role: .destructive) { + if let pack = packToDelete { + Task { await appState.offlineMapService.deletePack(pack) } + } + } + Button(L10n.Settings.OfflineMaps.cancel, role: .cancel) {} + } message: { + Text(L10n.Settings.OfflineMaps.deleteMessage) + } + } + +} + +// MARK: - Packs Section + +private struct PacksSection: View { + @Environment(\.appState) private var appState + @Binding var packToDelete: OfflinePack? + + var body: some View { + Section { + ForEach(appState.offlineMapService.packs) { pack in + OfflinePackRow(pack: pack) + } + .onDelete { indexSet in + if let index = indexSet.first { + packToDelete = appState.offlineMapService.packs[index] + } + } + } header: { + Text(L10n.Settings.OfflineMaps.downloaded) + } + } +} + +// MARK: - Storage Section + +private struct StorageSection: View { + @Environment(\.appState) private var appState + + var body: some View { + Section { + let totalBytes = appState.offlineMapService.packs.reduce(UInt64(0)) { $0 + $1.completedBytes } + LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { + Text(Int64(totalBytes), format: .byteCount(style: .file)) + } + } header: { + Text(L10n.Settings.OfflineMaps.storage) + } + } +} + +// MARK: - Offline Pack Row + +private struct OfflinePackRow: View { + let pack: OfflinePack + + var body: some View { + VStack(alignment: .leading) { + Text(pack.name) + + HStack { + if pack.isComplete { + Text(L10n.Settings.OfflineMaps.complete) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.OfflineMaps.downloading) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + .foregroundStyle(.secondary) + } + .font(.caption) + + if !pack.isComplete { + ProgressView(value: pack.completedFraction) + } + } + } +} + +// 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 isStyleLoaded = false + @State private var isDownloading = false + + var body: some View { + NavigationStack { + ZStack { + MC1MapView( + points: [], + lines: [], + mapStyle: .standard, + isDarkMode: colorScheme == .dark, + showLabels: false, + showsUserLocation: true, + isInteractive: true, + showsScale: false, + cameraRegion: $cameraRegion, + cameraRegionVersion: 0, + onPointTap: nil, + onMapTap: nil, + onCameraRegionChange: { region in + cameraRegion = region + }, + isStyleLoaded: $isStyleLoaded + ) + .ignoresSafeArea(edges: .bottom) + + // Selection rectangle overlay + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(40) + .allowsHitTesting(false) + } + .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) + } + } + .safeAreaInset(edge: .bottom) { + TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) + .textFieldStyle(.roundedBorder) + .padding() + .background(.regularMaterial) + } + } + } + + // MARK: - Download + + private func downloadRegion() { + guard let region = cameraRegion else { return } + isDownloading = true + + // Approximate inset to match the 40pt padding on the selection rectangle + let paddingFraction = 0.15 + let latInset = region.span.latitudeDelta * paddingFraction + let lonInset = region.span.longitudeDelta * paddingFraction + let bounds = 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) + ) + ) + + Task { + defer { isDownloading = false } + do { + try await appState.offlineMapService.downloadRegion(name: regionName, bounds: bounds) + dismiss() + } catch { + OfflineMapService.logger.error("Failed to download region: \(error.localizedDescription)") + dismiss() + } + } + } +} + +#Preview { + NavigationStack { + OfflineMapSettingsView() + .environment(\.appState, AppState()) + } +} 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/project.yml b/project.yml index 38e67d9c8..ecd044233 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,9 @@ packages: Emojibase: url: https://github.com/matrix-org/emojibase-bindings from: 1.5.0 + MapLibre: + url: https://github.com/maplibre/maplibre-gl-native-distribution + from: 6.23.0 targets: MC1: @@ -35,6 +38,7 @@ targets: dependencies: - package: MC1Services - package: Emojibase + - package: MapLibre - target: MC1Widgets preBuildScripts: - name: SwiftGen From eb2bb378e2ee21d5359948d7ab4410aa9b2f1549 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:40:57 -0700 Subject: [PATCH 02/55] fix(l10n): use Measurement formatting for trace path distances instead of hardcoded miles --- MC1/Views/Contacts/TracePathMap/TracePathMapView.swift | 4 ++-- MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 9d313272e..8c758e64c 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -161,8 +161,8 @@ struct TracePathMapView: View { if let distance = traceViewModel.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)) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 0a11b330a..de79f3b67 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -259,9 +259,9 @@ final class TracePathMapViewModel { ) 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 miles = distance / 1609.34 + let distFormatted = Measurement(value: distance, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) - let distFormatted = miles.formatted(.number.precision(.fractionLength(1))) // swiftlint:disable:next force_unwrapping badgePoints.append(MapPoint( @@ -271,7 +271,7 @@ final class TracePathMapViewModel { label: nil, isClusterable: false, hopIndex: nil, - badgeText: "\(distFormatted) mi · \(snrFormatted) dB" + badgeText: "\(distFormatted) · \(snrFormatted) dB" )) } } else { From 2215544b6c9fbdc52a11b89e06a6fc75f99d578c Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:41:00 -0700 Subject: [PATCH 03/55] fix(ui): use alert(presenting:) to safely unwrap optional in delete confirmation --- MC1/Views/Settings/OfflineMapSettingsView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 04ca41dba..6ae044d5e 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -43,15 +43,14 @@ struct OfflineMapSettingsView: View { isPresented: .init( get: { packToDelete != nil }, set: { if !$0 { packToDelete = nil } } - ) - ) { + ), + presenting: packToDelete + ) { pack in Button(L10n.Settings.OfflineMaps.delete, role: .destructive) { - if let pack = packToDelete { - Task { await appState.offlineMapService.deletePack(pack) } - } + Task { await appState.offlineMapService.deletePack(pack) } } Button(L10n.Settings.OfflineMaps.cancel, role: .cancel) {} - } message: { + } message: { _ in Text(L10n.Settings.OfflineMaps.deleteMessage) } } From a5c570f8264848b337f0d91277eb956694a1e0c3 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:41:04 -0700 Subject: [PATCH 04/55] fix(style): use Color() shorthand instead of Color(uiColor:) for system color --- MC1/Views/Contacts/ContactDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index a3553e2e5..b603ed5e6 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -755,7 +755,7 @@ private struct ContactLocationSection: View { } .listRowBackground( UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemGroupedBackground)) ) // Open in Maps From 724aafdaaf9851b2bc0bde34d15668a495eb0366 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:32:38 -0700 Subject: [PATCH 05/55] refactor(ui): apply SwiftUI Pro quick fixes across branch --- MC1/Views/Contacts/TracePathListView.swift | 1 - MC1/Views/LineOfSight/LineOfSightView.swift | 22 ++++++++++--------- .../LineOfSight/LineOfSightViewModel.swift | 4 ++-- MC1/Views/Map/LayersMenu.swift | 2 +- MC1/Views/Map/MC1MapView.swift | 2 +- .../Sections/DiagnosticsSection.swift | 4 +--- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/MC1/Views/Contacts/TracePathListView.swift b/MC1/Views/Contacts/TracePathListView.swift index 7c2a4c0ab..9fc895934 100644 --- a/MC1/Views/Contacts/TracePathListView.swift +++ b/MC1/Views/Contacts/TracePathListView.swift @@ -274,7 +274,6 @@ struct TracePathListView: View { Button(L10n.Contacts.Contacts.Trace.clearPath, systemImage: "trash", role: .destructive) { showingClearConfirmation = true } - .foregroundStyle(.red) } } footer: { if !viewModel.outboundPath.isEmpty { diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 6049ac868..63f2bd851 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -3,6 +3,12 @@ import MapKit import MC1Services import SwiftUI +private extension CLLocationCoordinate2D { + var formattedString: String { + "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" + } +} + private let analysisSheetDetentCollapsed: PresentationDetent = .fraction(0.25) private let analysisSheetDetentHalf: PresentationDetent = .fraction(0.5) private let analysisSheetDetentExpanded: PresentationDetent = .large @@ -245,7 +251,7 @@ struct LineOfSightView: View { analysisSheetContent } .scrollDismissesKeyboard(.immediately) - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) } } @@ -478,12 +484,10 @@ struct LineOfSightView: View { 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 + UIPasteboard.general.string = coord.formattedString } - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { + ShareLink(item: coord.formattedString) { Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") } } @@ -656,12 +660,10 @@ struct LineOfSightView: View { 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 + UIPasteboard.general.string = coord.formattedString } - let coordText = "\(coord.latitude.formatted(.number.precision(.fractionLength(6)))), \(coord.longitude.formatted(.number.precision(.fractionLength(6))))" - ShareLink(item: coordText) { + ShareLink(item: coord.formattedString) { Label(L10n.Tools.Tools.LineOfSight.share, systemImage: "square.and.arrow.up") } } @@ -1145,7 +1147,7 @@ private struct LOSMapCanvasView: View { showingMapStyleMenu = false } } label: { - Color.black.opacity(0.3) + Color.primary.opacity(0.3) .ignoresSafeArea() } .buttonStyle(.plain) diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index dbe1fab15..11d484682 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -416,7 +416,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointAElevationTask = Task { @MainActor in + pointAElevationTask = Task { await fetchElevationForPointA() } } @@ -444,7 +444,7 @@ final class LineOfSightViewModel { ) // Fetch elevation asynchronously - pointBElevationTask = Task { @MainActor in + pointBElevationTask = Task { await fetchElevationForPointB() } } diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index f9bb353d9..826a6c9e7 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -23,7 +23,7 @@ struct LayersMenu: View { Spacer() if selection == style { Image(systemName: "checkmark") - .foregroundStyle(.blue) + .foregroundStyle(.tint) } } .padding(.horizontal, 16) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 6ba33a20d..be54678f8 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -249,7 +249,7 @@ extension MC1MapView { // Debounce: cancel previous pending write-back pendingRegionTask?.cancel() - pendingRegionTask = Task { @MainActor in + pendingRegionTask = Task { try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } let region = mapView.mlnRegion diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index f31c36658..5e020c942 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -77,9 +77,7 @@ struct DiagnosticsSection: View { do { try await dataStore.clearDebugLogEntries() } catch { - await MainActor.run { - showError = error.localizedDescription - } + showError = error.localizedDescription } } } From 3678e1c567ea65dcc3fa741fed8530ad177ad2b6 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:35:48 -0700 Subject: [PATCH 06/55] refactor(ui): replace _VariadicView private SPI with Group(subviews:) --- MC1/Views/Components/MapControlsToolbar.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index 791cc49ca..544a4197d 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -81,19 +81,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 + } } } } From 781eed7b40dc1e8c8ce9751c6eea46370e7e23a2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:39:47 -0700 Subject: [PATCH 07/55] fix(a11y): use Dynamic Type fonts and accessible Button form in map controls --- MC1/Resources/Generated/L10n.swift | 2 ++ .../Localization/de.lproj/Contacts.strings | 3 ++ .../Localization/en.lproj/Contacts.strings | 3 ++ .../Localization/es.lproj/Contacts.strings | 3 ++ .../Localization/fr.lproj/Contacts.strings | 3 ++ .../Localization/nl.lproj/Contacts.strings | 3 ++ .../Localization/pl.lproj/Contacts.strings | 3 ++ .../Localization/ru.lproj/Contacts.strings | 3 ++ .../Localization/uk.lproj/Contacts.strings | 3 ++ .../zh-Hans.lproj/Contacts.strings | 3 ++ MC1/Views/Components/MapControlsToolbar.swift | 30 ++++++++----------- .../TracePathMap/TracePathMapView.swift | 27 ++++++++--------- MC1/Views/LineOfSight/LineOfSightView.swift | 28 ++++++++--------- MC1/Views/Map/MapView.swift | 28 ++++++++--------- 14 files changed, 78 insertions(+), 64 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 4946ab88a..46e5fd1c0 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1772,6 +1772,8 @@ 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: TracePathMapView.swift - Purpose: Hide labels accessibility diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 98d0214fd..24fdf3388 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"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 5e7dc7704..0996595bf 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"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 26764c831..defbd6393 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"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index bbbbfc454..1235448c2 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"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 827b2281f..bc5b01ce9 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"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index e88734cdb..b7562c990 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ę"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index eed626315..d9d6a1533 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" = "Сохранить путь"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index 1ac2da435..b7f885ce8 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" = "Зберегти шлях"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index b7d327e70..99fe56773 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" = "保存路径"; diff --git a/MC1/Views/Components/MapControlsToolbar.swift b/MC1/Views/Components/MapControlsToolbar.swift index 544a4197d..db81f3b13 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -43,34 +43,30 @@ 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) } } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 8c758e64c..6f350d959 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -282,30 +282,27 @@ struct TracePathMapView: View { showingLayersMenu: $mapViewModel.showingLayersMenu ) { // Labels toggle - Button { + Button(mapViewModel.showLabels ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels, systemImage: "character.textbox") { 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) } + .font(.body.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) + .labelStyle(.iconOnly) // Center on path if mapViewModel.hasPath { - Button { + Button(L10n.Contacts.Contacts.Trace.Map.centerOnPath, systemImage: "arrow.up.left.and.arrow.down.right") { 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) } + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) + .labelStyle(.iconOnly) } } } diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 63f2bd851..e05e4d7e6 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -1113,29 +1113,25 @@ private struct LOSMapCanvasView: View { }, showingLayersMenu: $showingMapStyleMenu ) { - Button { + Button(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { showLabels.toggle() - } label: { - Image(systemName: "character.textbox") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) } + .font(.body.weight(.medium)) + .foregroundStyle(showLabels ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) .buttonStyle(.plain) - .accessibilityLabel(showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels) + .labelStyle(.iconOnly) - 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) } } } diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 4081d92f5..57821e26c 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -216,35 +216,31 @@ struct MapView: View { } private var labelsToggleButton: some View { - Button { + Button(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { 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) } + .font(.body.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) + .labelStyle(.iconOnly) } private var centerAllButton: some View { - Button { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { 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) } + .font(.body.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) + .labelStyle(.iconOnly) } // MARK: - Refresh Button From b431af2ac760156e6f67b80309fb12a506ca69c9 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:41:21 -0700 Subject: [PATCH 08/55] fix(map): reuse LayersMenu in line-of-sight view, fixing missing network-availability check --- MC1/Views/LineOfSight/LineOfSightView.swift | 40 ++++----------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index e05e4d7e6..a4670cf98 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -1139,12 +1139,9 @@ private struct LOSMapCanvasView: View { if showingMapStyleMenu { Button { - withAnimation { - showingMapStyleMenu = false - } + withAnimation { showingMapStyleMenu = false } } label: { - Color.primary.opacity(0.3) - .ignoresSafeArea() + Color.primary.opacity(0.3).ignoresSafeArea() } .buttonStyle(.plain) @@ -1152,35 +1149,10 @@ private struct LOSMapCanvasView: View { Spacer() HStack { Spacer() - VStack(spacing: 0) { - ForEach(MapStyleSelection.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 != MapStyleSelection.allCases.last { - Divider() - } - } - } - .frame(width: 140) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .shadow(radius: 8) + LayersMenu( + selection: $mapStyleSelection, + isPresented: $showingMapStyleMenu + ) .padding(.trailing) } } From 06f938041cb394b996966e9b548b4b2fecf1e0ce Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:43:55 -0700 Subject: [PATCH 09/55] refactor(ui): replace UIActivityViewController wrapper with ShareLink --- MC1/Views/Components/ShareSheet.swift | 12 ------- .../Sections/DiagnosticsSection.swift | 33 +++++++++---------- 2 files changed, 16 insertions(+), 29 deletions(-) delete mode 100644 MC1/Views/Components/ShareSheet.swift 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/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index 5e020c942..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 From 4d2761c6651fd64d9f1e519bfbdf67d7cd978fd6 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:47:43 -0700 Subject: [PATCH 10/55] refactor(structure): extract types to own files per project convention --- MC1/Views/Components/GlassButtonStyles.swift | 23 ++ MC1/Views/Contacts/TraceResultHopRow.swift | 97 +++++++ MC1/Views/Contacts/TraceResultsSheet.swift | 95 ------- .../LineOfSight/LineOfSightLayoutMode.swift | 5 + MC1/Views/LineOfSight/LineOfSightView.swift | 34 --- .../LineOfSight/LineOfSightViewModel.swift | 6 + MC1/Views/Map/ContactDetailSheet.swift | 227 ++++++++++++++++ MC1/Views/Map/MapView.swift | 249 ------------------ MC1/Views/Map/OfflineBadge.swift | 21 ++ 9 files changed, 379 insertions(+), 378 deletions(-) create mode 100644 MC1/Views/Components/GlassButtonStyles.swift create mode 100644 MC1/Views/Contacts/TraceResultHopRow.swift create mode 100644 MC1/Views/LineOfSight/LineOfSightLayoutMode.swift create mode 100644 MC1/Views/Map/ContactDetailSheet.swift create mode 100644 MC1/Views/Map/OfflineBadge.swift diff --git a/MC1/Views/Components/GlassButtonStyles.swift b/MC1/Views/Components/GlassButtonStyles.swift new file mode 100644 index 000000000..d9b6d597f --- /dev/null +++ b/MC1/Views/Components/GlassButtonStyles.swift @@ -0,0 +1,23 @@ +import SwiftUI + +// MARK: - Glass Button Style Helpers + +extension View { + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(iOS 26, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + @ViewBuilder + func glassProminentButtonStyle() -> some View { + if #available(iOS 26, *) { + self.buttonStyle(.glassProminent) + } else { + self.buttonStyle(.borderedProminent) + } + } +} diff --git a/MC1/Views/Contacts/TraceResultHopRow.swift b/MC1/Views/Contacts/TraceResultHopRow.swift new file mode 100644 index 000000000..de419ee43 --- /dev/null +++ b/MC1/Views/Contacts/TraceResultHopRow.swift @@ -0,0 +1,97 @@ +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 signalLevel: Double { + TraceHop.signalLevel(for: displaySNR) + } + + private var signalColor: Color { + TraceHop.signalColor(for: displaySNR) + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + // Node identifier + if hop.isStartNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + Text(L10n.Contacts.Contacts.Results.Hop.started) + .font(.caption) + .foregroundStyle(.secondary) + } else if hop.isEndNode { + Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) + .foregroundStyle(.green) + Text(L10n.Contacts.Contacts.Results.Hop.received) + .font(.caption) + .foregroundStyle(.secondary) + } else if let hashDisplay = hop.hashDisplayString { + HStack { + Text(hashDisplay) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + if let name = hop.resolvedName { + Text(name) + } + } + Text(L10n.Contacts.Contacts.Results.Hop.repeated) + .font(.caption) + .foregroundStyle(.secondary) + } + + // SNR display - batch mode shows avg with range, single shows plain SNR + if !hop.isStartNode { + if let stats = batchStats { + let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( + stats.avg.formatted(snrFormat), + stats.min.formatted(snrFormat), + stats.max.formatted(snrFormat) + )) + } else { + Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + + // Signal strength indicator (not for start node - it didn't receive) + if !hop.isStartNode { + Image(systemName: "cellularbars", variableValue: signalLevel) + .foregroundStyle(signalColor) + .font(.title2) + } + } + .padding(.vertical, 4) + } +} diff --git a/MC1/Views/Contacts/TraceResultsSheet.swift b/MC1/Views/Contacts/TraceResultsSheet.swift index b0ce8bbfd..56129ba29 100644 --- a/MC1/Views/Contacts/TraceResultsSheet.swift +++ b/MC1/Views/Contacts/TraceResultsSheet.swift @@ -349,98 +349,3 @@ struct TraceResultsSheet: View { } } } - -// MARK: - Result Hop Row - -/// Row for displaying a hop in the trace results -struct TraceResultHopRow: View { - let hop: TraceHop - let hopIndex: Int - var batchStats: (avg: Double, min: Double, max: Double)? - var latestSNR: Double? - var isBatchInProgress: Bool = false - - /// SNR value to use for signal bars (latest during progress, average when complete) - private var displaySNR: Double { - if isBatchInProgress { - return latestSNR ?? hop.snr - } else if let stats = batchStats { - return stats.avg - } else { - return hop.snr - } - } - - private var signalLevel: Double { - TraceHop.signalLevel(for: displaySNR) - } - - private var signalColor: Color { - TraceHop.signalColor(for: displaySNR) - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - // Node identifier - if hop.isStartNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - Text(L10n.Contacts.Contacts.Results.Hop.started) - .font(.caption) - .foregroundStyle(.secondary) - } else if hop.isEndNode { - Text(hop.resolvedName ?? L10n.Contacts.Contacts.Results.Hop.myDevice) - .foregroundStyle(.green) - Text(L10n.Contacts.Contacts.Results.Hop.received) - .font(.caption) - .foregroundStyle(.secondary) - } else if let hashDisplay = hop.hashDisplayString { - HStack { - Text(hashDisplay) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - if let name = hop.resolvedName { - Text(name) - } - } - Text(L10n.Contacts.Contacts.Results.Hop.repeated) - .font(.caption) - .foregroundStyle(.secondary) - } - - // SNR display - batch mode shows avg with range, single shows plain SNR - if !hop.isStartNode { - if let stats = batchStats { - let snrFormat = FloatingPointFormatStyle.number.precision(.fractionLength(1)) - Text(L10n.Contacts.Contacts.Results.Hop.avgSNR( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Contacts.Contacts.Results.Hop.avgSNRLabel( - stats.avg.formatted(snrFormat), - stats.min.formatted(snrFormat), - stats.max.formatted(snrFormat) - )) - } else { - Text(L10n.Contacts.Contacts.Results.Hop.snr(hop.snr.formatted(.number.precision(.fractionLength(2))))) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Spacer() - - // Signal strength indicator (not for start node - it didn't receive) - if !hop.isStartNode { - Image(systemName: "cellularbars", variableValue: signalLevel) - .foregroundStyle(signalColor) - .font(.title2) - } - } - .padding(.vertical, 4) - } -} diff --git a/MC1/Views/LineOfSight/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 a4670cf98..ecc38f554 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -13,18 +13,6 @@ private let analysisSheetDetentCollapsed: PresentationDetent = .fraction(0.25) private let analysisSheetDetentHalf: PresentationDetent = .fraction(0.5) private let analysisSheetDetentExpanded: PresentationDetent = .large -enum LineOfSightLayoutMode { - case map - case panel - case mapWithSheet -} - -// MARK: - PointID Identifiable Conformance - -extension PointID: Identifiable { - var id: Self { self } -} - // MARK: - Line of Sight View /// Full-screen map view for analyzing line-of-sight between two points @@ -1313,28 +1301,6 @@ private struct FrequencyInputRow: View { } } -// MARK: - Glass Button Style Helpers - -extension View { - @ViewBuilder - func glassButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } - } - - @ViewBuilder - func glassProminentButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) - } - } -} - // MARK: - Preview #Preview("Empty") { diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 11d484682..0f2805434 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. diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift new file mode 100644 index 000000000..c27dae7d2 --- /dev/null +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -0,0 +1,227 @@ +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: typeIconName) + Text(typeDisplayName) + } + .foregroundStyle(typeColor) + } + + if contact.isFavorite { + LabeledContent(L10n.Map.Map.Detail.status) { + HStack { + Image(systemName: "star.fill") + Text(L10n.Map.Map.Detail.favorite) + } + .foregroundStyle(.orange) + } + } + + if contact.lastAdvertTimestamp > 0 { + LabeledContent(L10n.Map.Map.Detail.lastAdvert) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) + } + } + } + + // Location section + Section(L10n.Map.Map.Detail.Section.location) { + LabeledContent(L10n.Map.Map.Detail.latitude) { + Text(contact.latitude, format: .number.precision(.fractionLength(6))) + } + + LabeledContent(L10n.Map.Map.Detail.longitude) { + Text(contact.longitude, format: .number.precision(.fractionLength(6))) + } + } + + // Path info section + Section(L10n.Map.Map.Detail.Section.networkPath) { + if contact.isFloodRouted { + LabeledContent(L10n.Map.Map.Detail.routing, value: L10n.Map.Map.Detail.routingFlood) + } else { + let hopCount = contact.pathHopCount + LabeledContent(L10n.Map.Map.Detail.pathLength, value: hopCount == 1 ? L10n.Map.Map.Detail.hopSingular : L10n.Map.Map.Detail.hops(hopCount)) + } + } + + // Actions section + Section { + switch contact.type { + case .repeater: + Button { + activeSheet = .telemetryAuth + } label: { + Label(L10n.Map.Map.Detail.Action.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + + Button { + activeSheet = .adminAuth + } label: { + Label(L10n.Map.Map.Detail.Action.adminAccess, systemImage: "gearshape.2") + } + + case .room: + Button { + activeSheet = .roomJoin + } label: { + Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") + } + + case .chat: + Button { + dismiss() + onMessage() + } label: { + Label(L10n.Map.Map.Detail.Action.sendMessage, systemImage: "message.fill") + } + .radioDisabled(for: appState.connectionState) + } + } + } + .navigationTitle(contact.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + dismiss() + } + } + } + .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in + switch sheet { + case .telemetryAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet( + contact: contact, + role: role, + customTitle: L10n.Map.Map.Detail.Action.telemetryAccessTitle + ) { session in + pendingSheet = .telemetryStatus(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .telemetryStatus(let session): + RepeaterStatusView(session: session) + + case .adminAuth: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + pendingSheet = .adminSettings(session) + activeSheet = nil + } + .presentationSizing(.page) + } + + case .adminSettings(let session): + NavigationStack { + RepeaterSettingsView(session: session) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Map.Map.Common.done) { + activeSheet = nil + } + } + } + } + .presentationSizing(.page) + + case .roomJoin: + if let role = RemoteNodeRole(contactType: contact.type) { + NodeAuthenticationSheet(contact: contact, role: role) { session in + activeSheet = nil + dismiss() + appState.navigation.navigateToRoom(with: session) + } + .presentationSizing(.page) + } + } + } + } + } + + // 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 + } + } +} diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 57821e26c..bce9efaa1 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -286,255 +286,6 @@ struct MapView: View { } } -// MARK: - Offline Badge - -private struct OfflineBadge: View { - var body: some View { - VStack { - HStack { - Text(L10n.Map.Map.OfflineBadge.label) - .font(.caption) - .bold() - .padding(.horizontal) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: .capsule) - Spacer() - } - .padding(.leading) - Spacer() - } - } -} - -// MARK: - Contact Detail Sheet - -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? - - 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) - } - } - } - } - } - - // 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 - } - } -} - // MARK: - Preview #Preview("Map with Contacts") { diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift new file mode 100644 index 000000000..ea89ed492 --- /dev/null +++ b/MC1/Views/Map/OfflineBadge.swift @@ -0,0 +1,21 @@ +import SwiftUI + +// MARK: - Offline Badge + +struct OfflineBadge: View { + var body: some View { + VStack { + HStack { + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + Spacer() + } + .padding(.leading) + Spacer() + } + } +} From c9337960e59d41077aa53f27a9e9c37f88b5808b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:58:27 -0700 Subject: [PATCH 11/55] refactor(los): extract computed view properties into View structs Extract 10 computed view properties from LineOfSightView.swift into separate View structs, reducing it from 1335 to 711 lines (~47%). Also collapse analysisSheetContent/analysisSheetVStack into a single property. Pure structural refactor with no behavior changes. --- .../LineOfSight/AddRepeaterRowView.swift | 34 + MC1/Views/LineOfSight/AnalysisErrorView.swift | 30 + MC1/Views/LineOfSight/LineOfSightView.swift | 684 +----------------- .../LineOfSight/PointHeightEditorView.swift | 70 ++ .../LineOfSight/PointRowButtonsView.swift | 95 +++ MC1/Views/LineOfSight/PointRowView.swift | 83 +++ .../PointsSummarySectionView.swift | 116 +++ .../LineOfSight/RFSettingsSectionView.swift | 38 + .../RepeaterHeightEditorView.swift | 60 ++ MC1/Views/LineOfSight/RepeaterRowView.swift | 131 ++++ .../TerrainProfileSectionView.swift | 58 ++ 11 files changed, 745 insertions(+), 654 deletions(-) create mode 100644 MC1/Views/LineOfSight/AddRepeaterRowView.swift create mode 100644 MC1/Views/LineOfSight/AnalysisErrorView.swift create mode 100644 MC1/Views/LineOfSight/PointHeightEditorView.swift create mode 100644 MC1/Views/LineOfSight/PointRowButtonsView.swift create mode 100644 MC1/Views/LineOfSight/PointRowView.swift create mode 100644 MC1/Views/LineOfSight/PointsSummarySectionView.swift create mode 100644 MC1/Views/LineOfSight/RFSettingsSectionView.swift create mode 100644 MC1/Views/LineOfSight/RepeaterHeightEditorView.swift create mode 100644 MC1/Views/LineOfSight/RepeaterRowView.swift create mode 100644 MC1/Views/LineOfSight/TerrainProfileSectionView.swift diff --git a/MC1/Views/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/LineOfSight/AddRepeaterRowView.swift new file mode 100644 index 000000000..deb175dc1 --- /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) + } + .glassButtonStyle() + } +} 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/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index ecc38f554..45fcf953e 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -3,7 +3,7 @@ import MapKit import MC1Services import SwiftUI -private extension CLLocationCoordinate2D { +extension CLLocationCoordinate2D { var formattedString: String { "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" } @@ -33,7 +33,6 @@ struct LineOfSightView: View { @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 @@ -244,18 +243,18 @@ struct LineOfSightView: View { } 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 @@ -265,8 +264,12 @@ struct LineOfSightView: View { resultSummarySection(result) if shouldShowExpandedAnalysis { - terrainProfileSection - rfSettingsSection + TerrainProfileSectionView( + viewModel: viewModel, + showDragHint: $showDragHint, + repeaterMarkerCenter: $repeaterMarkerCenter + ) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } @@ -277,541 +280,30 @@ 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) + RFSettingsSectionView(viewModel: viewModel, isRFSettingsExpanded: $isRFSettingsExpanded) } } - } - } - - @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 - 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) - } - .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 - 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) - } - .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() - } - } - - 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 @@ -853,122 +345,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? { @@ -1240,7 +616,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 = "" diff --git a/MC1/Views/LineOfSight/PointHeightEditorView.swift b/MC1/Views/LineOfSight/PointHeightEditorView.swift new file mode 100644 index 000000000..3a15283ba --- /dev/null +++ b/MC1/Views/LineOfSight/PointHeightEditorView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct PointHeightEditorView: View { + var viewModel: LineOfSightViewModel + let point: SelectedPoint + let pointID: PointID + + var body: 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() + } + } + } + } +} diff --git a/MC1/Views/LineOfSight/PointRowButtonsView.swift b/MC1/Views/LineOfSight/PointRowButtonsView.swift new file mode 100644 index 000000000..45c73e9e4 --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -0,0 +1,95 @@ +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 + + @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 + + private var point: SelectedPoint? { + pointID == .pointA ? viewModel.pointA : viewModel.pointB + } + + var body: some View { + // 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 + 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) + } + .glassButtonStyle() + .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) + } + .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) + } +} diff --git a/MC1/Views/LineOfSight/PointRowView.swift b/MC1/Views/LineOfSight/PointRowView.swift new file mode 100644 index 000000000..e8d0653ba --- /dev/null +++ b/MC1/Views/LineOfSight/PointRowView.swift @@ -0,0 +1,83 @@ +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("\(Int(elevation) + point.additionalHeight)m") + .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..f4dcf1d13 --- /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 + } + .glassButtonStyle() + .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..acc1d5a42 --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct RepeaterHeightEditorView: View { + var viewModel: LineOfSightViewModel + let repeaterPoint: RepeaterPoint + + var body: 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() + } + } + + GridRow { + Text(L10n.Tools.Tools.LineOfSight.additionalHeight) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Stepper( + value: Binding( + get: { repeaterPoint.additionalHeight }, + set: { + viewModel.updateRepeaterHeight(meters: $0) + viewModel.analyzeWithRepeater() + } + ), + 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() + } + } + } + } +} diff --git a/MC1/Views/LineOfSight/RepeaterRowView.swift b/MC1/Views/LineOfSight/RepeaterRowView.swift new file mode 100644 index 000000000..b68c7296c --- /dev/null +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -0,0 +1,131 @@ +import CoreLocation +import MapKit +import SwiftUI + +struct RepeaterRowView: View { + var viewModel: LineOfSightViewModel + @Binding var copyHapticTrigger: Int + @Binding var editingPoint: PointID? + let onRelocate: () -> Void + + @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 + + 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("\(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 + 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) + } + .glassButtonStyle() + .sensoryFeedback(.success, trigger: copyHapticTrigger) + .controlSize(.small) + + // Relocate button (toggles on/off) + Button { + if viewModel.relocatingPoint == .repeater { + viewModel.relocatingPoint = nil + } else { + viewModel.relocatingPoint = .repeater + onRelocate() + } + } 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() + 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) + } + } + } + } +} From 3d5a58cae2d9c516736c8e75b6caf1ab35639310 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:03:31 -0700 Subject: [PATCH 12/55] refactor(trace): extract computed view properties into View structs --- MC1/Views/Contacts/TracePathListView.swift | 249 +----------------- .../AvailableRepeatersSectionView.swift | 112 ++++++++ .../TracePathMap/PathActionsSectionView.swift | 67 +++++ .../TracePathMap/RunTraceSectionView.swift | 75 ++++++ .../TracePathFloatingButtonsView.swift | 76 ++++++ .../TracePathMapToolbarView.swift | 68 +++++ .../TracePathMap/TracePathMapView.swift | 137 +--------- 7 files changed, 419 insertions(+), 365 deletions(-) create mode 100644 MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/PathActionsSectionView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/RunTraceSectionView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/TracePathFloatingButtonsView.swift create mode 100644 MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift diff --git a/MC1/Views/Contacts/TracePathListView.swift b/MC1/Views/Contacts/TracePathListView.swift index 9fc895934..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,132 +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 - } - } - } 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..2ed830812 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift @@ -0,0 +1,112 @@ +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 { + 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 + } + } + } + } +} 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/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/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/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift new file mode 100644 index 000000000..bb2f990f4 --- /dev/null +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -0,0 +1,68 @@ +import MapKit +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 + + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { + if let location = appState.locationService.currentLocation { + mapViewModel.cameraRegion = MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) + ) + mapViewModel.cameraRegionVersion += 1 + } else { + appState.locationService.requestLocation() + } + }, + showingLayersMenu: $mapViewModel.showingLayersMenu + ) { + // Labels toggle + Button(mapViewModel.showLabels ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels, systemImage: "character.textbox") { + mapViewModel.showLabels.toggle() + } + .font(.body.weight(.medium)) + .foregroundStyle(mapViewModel.showLabels ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + + // 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: $mapViewModel.mapStyleSelection, + isPresented: $mapViewModel.showingLayersMenu + ) + .padding(.trailing, 16) + .padding(.bottom, 160) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) + } +} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 6f350d959..09acfb921 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -38,10 +38,15 @@ struct TracePathMapView: View { } // Floating buttons - floatingButtons + TracePathFloatingButtonsView( + mapViewModel: mapViewModel, + showingClearConfirmation: $showingClearConfirmation, + presentedResult: $presentedResult, + buttonNamespace: buttonNamespace + ) // Map controls toolbar - mapToolbar + TracePathMapToolbarView(mapViewModel: mapViewModel) } .onAppear { mapViewModel.configure( @@ -192,132 +197,4 @@ struct TracePathMapView: View { .background(.regularMaterial) } - // MARK: - Floating Buttons - - private var floatingButtons: some View { - VStack { - Spacer() - - LiquidGlassContainer(spacing: 12) { - HStack(spacing: 12) { - if mapViewModel.hasPath { - // Clear button - Button { - showingClearConfirmation = true - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.clear) - } - .liquidGlassButtonStyle() - .liquidGlassID("clear", in: buttonNamespace) - .confirmationDialog( - L10n.Contacts.Contacts.Trace.clearPath, - isPresented: $showingClearConfirmation, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Trace.clearPath, role: .destructive) { - mapViewModel.clearPath() - } - } message: { - Text(L10n.Contacts.Contacts.Trace.clearPathMessage) - } - - // Run Trace button - Button { - Task { - await mapViewModel.runTrace() - } - } label: { - if mapViewModel.isRunning { - HStack { - ProgressView() - .controlSize(.small) - Text(L10n.Contacts.Contacts.Trace.List.runningTrace) - } - } else { - Text(L10n.Contacts.Contacts.Trace.List.runTrace) - } - } - .liquidGlassProminentButtonStyle() - .liquidGlassID("trace", in: buttonNamespace) - .disabled(!mapViewModel.canRunTrace) - - // View Results button - if let result = mapViewModel.result, result.success { - Button { - presentedResult = result - } label: { - Text(L10n.Contacts.Contacts.Trace.Map.viewResults) - } - .liquidGlassButtonStyle() - .liquidGlassID("viewResults", in: buttonNamespace) - } - } - } - } - .animation(.spring(response: 0.3), value: mapViewModel.hasPath) - .animation(.spring(response: 0.3), value: mapViewModel.result?.id) - .padding(.bottom, 24) - } - } - - // MARK: - Map Toolbar - - private var mapToolbar: some View { - VStack { - Spacer() - HStack { - Spacer() - MapControlsToolbar( - onLocationTap: { - if let location = appState.locationService.currentLocation { - mapViewModel.cameraRegion = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - ) - mapViewModel.cameraRegionVersion += 1 - } else { - appState.locationService.requestLocation() - } - }, - showingLayersMenu: $mapViewModel.showingLayersMenu - ) { - // Labels toggle - Button(mapViewModel.showLabels ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels, systemImage: "character.textbox") { - mapViewModel.showLabels.toggle() - } - .font(.body.weight(.medium)) - .foregroundStyle(mapViewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) - - // 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: $mapViewModel.mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu - ) - .padding(.trailing, 16) - .padding(.bottom, 160) - .transition(.scale.combined(with: .opacity)) - } - } - .animation(.spring(response: 0.3), value: mapViewModel.showingLayersMenu) - } } From 2e4da08c8ea142e61da711245fd458f4e55a1a92 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:09:33 -0700 Subject: [PATCH 13/55] refactor(views): extract remaining computed view properties into View structs --- .../ChatConversationMessagesContent.swift | 204 ++---------- .../Components/ChatMessagesTableView.swift | 117 +++++++ .../Chats/Components/MessageBubbleView.swift | 93 ++++++ MC1/Views/Contacts/BatchRTTRow.swift | 28 ++ MC1/Views/Contacts/ComparisonRowView.swift | 58 ++++ .../Contacts/DistanceInfoSheetView.swift | 54 ++++ MC1/Views/Contacts/SavePathRowView.swift | 58 ++++ MC1/Views/Contacts/TotalDistanceRow.swift | 57 ++++ .../Contacts/TraceResultsSectionView.swift | 78 +++++ MC1/Views/Contacts/TraceResultsSheet.swift | 301 +----------------- MC1/Views/Map/MapCanvasView.swift | 113 +++++++ MC1/Views/Map/MapContentView.swift | 99 ++++++ MC1/Views/Map/MapView.swift | 223 ++----------- 13 files changed, 822 insertions(+), 661 deletions(-) create mode 100644 MC1/Views/Chats/Components/ChatMessagesTableView.swift create mode 100644 MC1/Views/Chats/Components/MessageBubbleView.swift create mode 100644 MC1/Views/Contacts/BatchRTTRow.swift create mode 100644 MC1/Views/Contacts/ComparisonRowView.swift create mode 100644 MC1/Views/Contacts/DistanceInfoSheetView.swift create mode 100644 MC1/Views/Contacts/SavePathRowView.swift create mode 100644 MC1/Views/Contacts/TotalDistanceRow.swift create mode 100644 MC1/Views/Contacts/TraceResultsSectionView.swift create mode 100644 MC1/Views/Map/MapCanvasView.swift create mode 100644 MC1/Views/Map/MapContentView.swift 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/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..24ac0ba0f --- /dev/null +++ b/MC1/Views/Contacts/ComparisonRowView.swift @@ -0,0 +1,58 @@ +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) + 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/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/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/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 56129ba29..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,296 +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 - } - } - } - } - } } diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift new file mode 100644 index 000000000..f9fc9ca73 --- /dev/null +++ b/MC1/Views/Map/MapCanvasView.swift @@ -0,0 +1,113 @@ +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 + let mapPoints: [MapPoint] + let colorScheme: ColorScheme + @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, + colorScheme: colorScheme, + mapPoints: mapPoints, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: onShowContactDetail, + onNavigateToChat: onNavigateToChat + ) + .ignoresSafeArea() + + // Offline badge + if !appState.offlineMapService.isNetworkAvailable { + OfflineBadge() + } + + // 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 Controls + + private var mapControls: some View { + HStack { + Spacer() + MapControlsToolbar( + onLocationTap: { onCenterOnUser() }, + showingLayersMenu: $viewModel.showingLayersMenu + ) { + labelsToggleButton + centerAllButton + } + } + } + + private var labelsToggleButton: some View { + Button(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { + withAnimation { + viewModel.showLabels.toggle() + } + } + .font(.body.weight(.medium)) + .foregroundStyle(viewModel.showLabels ? .blue : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .labelStyle(.iconOnly) + } + + private var centerAllButton: some View { + Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { + onClearSelection() + viewModel.centerOnAllContacts() + } + .font(.body.weight(.medium)) + .foregroundStyle(viewModel.contactsWithLocation.isEmpty ? .secondary : .primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + .buttonStyle(.plain) + .disabled(viewModel.contactsWithLocation.isEmpty) + .labelStyle(.iconOnly) + } +} diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift new file mode 100644 index 000000000..fa0d8ddcd --- /dev/null +++ b/MC1/Views/Map/MapContentView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import MapKit +import MC1Services + +/// Map content displaying MC1MapView with contact points and popover callouts +struct MapContentView: View { + @Bindable var viewModel: MapViewModel + let colorScheme: ColorScheme + let mapPoints: [MapPoint] + @Binding var selectedCalloutContact: ContactDTO? + @Binding var selectedPointScreenPosition: CGPoint? + @Binding var isStyleLoaded: Bool + let onShowContactDetail: (ContactDTO) -> Void + let onNavigateToChat: (ContactDTO) -> Void + + var body: some View { + if viewModel.contactsWithLocation.isEmpty && !viewModel.isLoading { + emptyState + } else { + MC1MapView( + points: mapPoints, + lines: [], + mapStyle: viewModel.mapStyleSelection, + isDarkMode: colorScheme == .dark, + showLabels: viewModel.showLabels, + showsUserLocation: true, + isInteractive: true, + showsScale: true, + 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 + 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 { + loadingOverlay + } + } + } + } + + // 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() + } + } + .buttonStyle(.bordered) + } + } + + // MARK: - Loading Overlay + + private var loadingOverlay: some View { + ZStack { + Color.black.opacity(0.1) + ProgressView() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 8)) + } + } +} diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index bce9efaa1..54051bc5e 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -14,135 +14,41 @@ struct MapView: View { 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() + MapCanvasView( + viewModel: viewModel, + mapPoints: mapPoints, + colorScheme: colorScheme, + selectedCalloutContact: $selectedCalloutContact, + selectedPointScreenPosition: $selectedPointScreenPosition, + isStyleLoaded: $isStyleLoaded, + onShowContactDetail: { showContactDetail($0) }, + onNavigateToChat: { navigateToChat(with: $0) }, + onCenterOnUser: { centerOnUserLocation() }, + onClearSelection: { clearSelection() } + ) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BLEStatusIndicatorView() } - .sheet(item: $selectedContactForDetail) { contact in - ContactDetailSheet( - contact: contact, - onMessage: { navigateToChat(with: contact) } - ) - .presentationDetents([.large]) + ToolbarItem(placement: .topBarTrailing) { + refreshButton } - .liquidGlassToolbarBackground() - } - } - - // MARK: - Map Canvas - - private var mapCanvas: some View { - ZStack { - mapContent - .ignoresSafeArea() - - // Offline badge - if !appState.offlineMapService.isNetworkAvailable { - OfflineBadge() - } - - // 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) - } - } + .task { + appState.locationService.requestPermissionIfNeeded() + appState.locationService.requestLocation() + viewModel.configure(appState: appState) + await viewModel.loadContactsWithLocation() + viewModel.centerOnAllContacts() } - } - } - - // MARK: - Map Content - - @ViewBuilder - private var mapContent: some View { - if viewModel.contactsWithLocation.isEmpty && !viewModel.isLoading { - emptyState - } else { - MC1MapView( - points: mapPoints, - lines: [], - mapStyle: viewModel.mapStyleSelection, - isDarkMode: colorScheme == .dark, - showLabels: viewModel.showLabels, - showsUserLocation: true, - isInteractive: true, - showsScale: true, - 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 - 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( + .sheet(item: $selectedContactForDetail) { contact in + ContactDetailSheet( contact: contact, - onDetail: { showContactDetail(contact) }, onMessage: { navigateToChat(with: contact) } ) - .presentationCompactAdaptation(.popover) - } - .overlay { - if !isStyleLoaded { - ProgressView() - .scaleEffect(1.5) - } else if viewModel.isLoading { - loadingOverlay - } + .presentationDetents([.large]) } + .liquidGlassToolbarBackground() } } @@ -168,81 +74,6 @@ struct MapView: View { } } - // 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() - } - } - .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(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { - withAnimation { - viewModel.showLabels.toggle() - } - } - .font(.body.weight(.medium)) - .foregroundStyle(viewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) - } - - private var centerAllButton: some View { - Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { - clearSelection() - viewModel.centerOnAllContacts() - } - .font(.body.weight(.medium)) - .foregroundStyle(viewModel.contactsWithLocation.isEmpty ? .secondary : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - .buttonStyle(.plain) - .disabled(viewModel.contactsWithLocation.isEmpty) - .labelStyle(.iconOnly) - } - // MARK: - Refresh Button private var refreshButton: some View { From 472bdaadce28c2c98119f7947ca582c4ab71a4a5 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:41:06 -0700 Subject: [PATCH 14/55] fix(map): correct Metal drawable scale in landscape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Work around MapLibre bug where MLNEffectiveScaleFactorForView uses nativeBounds.width / bounds.width, which gives wrong scale in landscape because nativeBounds is fixed while bounds rotates with the device - Isa-swizzle the internal Metal UIView's setDrawableSize: to compute bounds × nativeScale instead of the buggy ratio - Use non-zero initial frame to avoid zero-size Metal init (issue #67) - Fix mapStyleChanged always false when style URL changes - Remove empty setupPointLayers stub, pass mapView param to setupRasterSources --- MC1/Views/Map/MC1MapView+Layers.swift | 14 ++-- MC1/Views/Map/MC1MapView.swift | 104 ++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 115f6cad6..e4748f3e5 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -7,16 +7,12 @@ private nonisolated(unsafe) let mapFontNames = NSExpression(forConstantValue: [" extension MC1MapView.Coordinator { - // MARK: - Point layers - - /// Point sources and layers use deferred creation: they are created in - /// `updatePointSource` on first data arrival, not here. This avoids a - /// MapLibre bug where sources initialized without features ignore later - /// `.shape` updates. - func setupPointLayers(style: MLNStyle) {} - // 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 } @@ -246,7 +242,7 @@ extension MC1MapView.Coordinator { // MARK: - Raster tile sources - func setupRasterSources(style: MLNStyle) { + func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { let satSource = MLNRasterTileSource( identifier: "satellite-tiles", tileURLTemplates: [MapTileURLs.esriWorldImagery], diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index be54678f8..3b1af3930 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -1,10 +1,104 @@ 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 `setDrawableSize:` on the internal Metal **UIView** (not +/// the CAMetalLayer) so that `layoutChanged()`'s read-back of +/// `resource.mtlView.drawableSize` also returns the corrected value. +/// 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 } + + let originalClass: AnyClass = object_getClass(metalView)! + 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) + objc_registerClassPair(subclass) + fixedClass = subclass + } + + object_setClass(metalView, fixedClass) + } + + // MARK: - Private + + 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 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 + var parent: UIView? = metalView.superview + while let v = parent, !(v is MLNMapView) { parent = v.superview } + + guard let mapView = parent, + 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)) + } +} + +/// Applies the Metal layer scale fix 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] @@ -94,7 +188,6 @@ struct MC1MapView: UIViewRepresentable { let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode) if mapView.styleURL != newStyleURL { coordinator.isStyleLoaded = false - coordinator.currentMapStyle = mapStyle mapView.styleURL = newStyleURL } let mapStyleChanged = coordinator.currentMapStyle != mapStyle @@ -187,10 +280,8 @@ struct MC1MapView: UIViewRepresentable { extension MC1MapView { @MainActor class Coordinator: NSObject, @preconcurrency MLNMapViewDelegate, UIGestureRecognizerDelegate { - lazy var mapView: MLNMapView = { - let view = MLNMapView(frame: .zero) - return view - }() + // 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)? @@ -221,8 +312,7 @@ extension MC1MapView { fixedSource = nil PinSpriteRenderer.renderAll(into: style) - setupRasterSources(style: style) - setupPointLayers(style: style) + setupRasterSources(style: style, mapView: mapView) setupLineLayers(style: style) updatePointSource(mapView: mapView) From b2be8652b1158df60757a4b25c969647b406b501 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:41:12 -0700 Subject: [PATCH 15/55] fix(map): render pin sprites at retina scale - Use UIGraphicsImageRendererFormat.preferred() for all four renderers so sprites are rendered at the device's native screen scale instead of defaulting to 1x --- MC1/Views/Map/PinSpriteRenderer.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 956bee31c..56db0bcb9 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -12,7 +12,7 @@ enum PinSpriteRenderer { style.setImage(image, forName: spec.name) } // Transparent 1px sprite for badge points (only badge-text layer renders) - let transparent = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + let transparent = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1), format: .preferred()).image { _ in } style.setImage(transparent, forName: "pin-badge") // Hop badge variants for ring-white pins (trace path) @@ -81,7 +81,7 @@ enum PinSpriteRenderer { let totalWidth = max(circleSize, ringSize) let totalHeight = circleSize + triangleSize - 3 + ringPadding - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight)) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalWidth, height: totalHeight), format: .preferred()) return renderer.image { ctx in let cgContext = ctx.cgContext let centerX = totalWidth / 2 @@ -188,7 +188,7 @@ enum PinSpriteRenderer { let totalSize = size + shadowPadding * 2 let capInset = cornerRadius + shadowPadding - let renderer = UIGraphicsImageRenderer(size: CGSize(width: totalSize, height: totalSize)) + 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) @@ -223,7 +223,7 @@ enum PinSpriteRenderer { let badgeHeight: CGFloat = 20 let totalHeight = size + badgeHeight + 2 - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: totalHeight)) + 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) From a30ed908ab7a73a769c48990c126ccaddf647a3b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:55:07 -0700 Subject: [PATCH 16/55] refactor: simplify maplibre-migration branch code - Debounce OfflineMapService pack reloads on per-tile progress notifications - Guard TracePathMapViewModel overlay rebuilds against GPS no-ops - Cache PinSpriteRenderer sprites across style reloads - Consolidate glass button style helpers into View+LiquidGlass, delete GlassButtonStyles.swift - Extract bounding-box calculation into setCameraRegion(fitting:) in TracePathMapViewModel - Add MapLayerID/MapSourceID constants, remove stringly-typed layer identifiers - Extract ContactType display properties to ContactType+Display.swift - Move CLLocationCoordinate2D.formattedString to Extensions/ - Remove unused openTopoMapB/C URL constants --- .../CLLocationCoordinate2D+Formatting.swift | 7 ++ MC1/Extensions/ContactType+Display.swift | 20 +++++ MC1/Extensions/View+LiquidGlass.swift | 10 +++ MC1/Services/OfflineMapService.swift | 14 +++- MC1/Views/Components/GlassButtonStyles.swift | 23 ------ .../TracePathMap/TracePathMapViewModel.swift | 76 +++++-------------- .../LineOfSight/AddRepeaterRowView.swift | 2 +- MC1/Views/LineOfSight/LineOfSightView.swift | 8 +- .../LineOfSight/PointRowButtonsView.swift | 8 +- .../PointsSummarySectionView.swift | 2 +- MC1/Views/LineOfSight/RepeaterRowView.swift | 8 +- MC1/Views/Map/ContactCalloutContent.swift | 26 +------ MC1/Views/Map/ContactDetailSheet.swift | 25 +----- MC1/Views/Map/MC1MapView+Layers.swift | 76 +++++++++++++------ MC1/Views/Map/MC1MapView.swift | 8 +- MC1/Views/Map/MapTileURLs.swift | 2 - MC1/Views/Map/PinSpriteRenderer.swift | 38 ++++++---- MC1/Views/Settings/LocationPickerView.swift | 30 +------- 18 files changed, 168 insertions(+), 215 deletions(-) create mode 100644 MC1/Extensions/CLLocationCoordinate2D+Formatting.swift create mode 100644 MC1/Extensions/ContactType+Display.swift delete mode 100644 MC1/Views/Components/GlassButtonStyles.swift 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/ContactType+Display.swift b/MC1/Extensions/ContactType+Display.swift new file mode 100644 index 000000000..a175fcb40 --- /dev/null +++ b/MC1/Extensions/ContactType+Display.swift @@ -0,0 +1,20 @@ +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 + } + } +} 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/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 8148eb124..e42a514ea 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -16,6 +16,7 @@ final class OfflineMapService { private let monitor = NWPathMonitor() private var observationTasks: [Task] = [] + private var pendingLoadTask: Task? init() { let networkStream = AsyncStream { continuation in @@ -31,7 +32,7 @@ final class OfflineMapService { observationTasks.append(Task { [weak self] in for await _ in NotificationCenter.default.notifications(named: .MLNOfflinePackProgressChanged) { - self?.loadPacks() + self?.scheduleLoadPacks() } }) observationTasks.append(Task { [weak self] in @@ -47,6 +48,7 @@ final class OfflineMapService { isolated deinit { monitor.cancel() + pendingLoadTask?.cancel() for task in observationTasks { task.cancel() } @@ -56,6 +58,16 @@ final class OfflineMapService { packs = (MLNOfflineStorage.shared.packs ?? []).map { OfflinePack(pack: $0) } } + /// 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() + } + } + func downloadRegion( name: String, bounds: MLNCoordinateBounds, diff --git a/MC1/Views/Components/GlassButtonStyles.swift b/MC1/Views/Components/GlassButtonStyles.swift deleted file mode 100644 index d9b6d597f..000000000 --- a/MC1/Views/Components/GlassButtonStyles.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -// MARK: - Glass Button Style Helpers - -extension View { - @ViewBuilder - func glassButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) - } - } - - @ViewBuilder - func glassProminentButtonStyle() -> some View { - if #available(iOS 26, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) - } - } -} diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index de79f3b67..b1c267f07 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -31,6 +31,7 @@ final class TracePathMapViewModel { private weak var traceViewModel: TracePathViewModel? private var userLocation: CLLocation? + private var lastRebuildLocation: CLLocation? // MARK: - Path State @@ -110,6 +111,11 @@ 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() } @@ -323,34 +329,7 @@ final class TracePathMapViewModel { coordinates.append(contentsOf: line.coordinates) } - guard !coordinates.isEmpty else { return } - - // Calculate bounding region - var minLat = coordinates[0].latitude - var maxLat = coordinates[0].latitude - var minLon = coordinates[0].longitude - var maxLon = coordinates[0].longitude - - for coord in coordinates { - minLat = min(minLat, coord.latitude) - maxLat = max(maxLat, coord.latitude) - minLon = min(minLon, coord.longitude) - maxLon = max(maxLon, coord.longitude) - } - - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - - // Clamp spans to valid MKCoordinateSpan bounds (lat: 0-180, lon: 0-360) - let span = MKCoordinateSpan( - latitudeDelta: min(180, (maxLat - minLat) * 1.5 + 0.01), - longitudeDelta: min(360, (maxLon - minLon) * 1.5 + 0.01) - ) - - cameraRegion = MKCoordinateRegion(center: center, span: span) - cameraRegionVersion += 1 + setCameraRegion(fitting: coordinates) } /// Center map to show all repeaters @@ -361,29 +340,10 @@ final class TracePathMapViewModel { return } - var minLat = Double.greatestFiniteMagnitude - var maxLat = -Double.greatestFiniteMagnitude - var minLon = Double.greatestFiniteMagnitude - var maxLon = -Double.greatestFiniteMagnitude - - for repeater in repeaters { - minLat = min(minLat, repeater.latitude) - maxLat = max(maxLat, repeater.latitude) - minLon = min(minLon, repeater.longitude) - maxLon = max(maxLon, repeater.longitude) + let coordinates = repeaters.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } - - 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) - cameraRegionVersion += 1 + setCameraRegion(fitting: coordinates) hasInitiallyCenteredOnRepeaters = true } @@ -406,12 +366,10 @@ final class TracePathMapViewModel { var coordinates: [CLLocationCoordinate2D] = [] - // Include user location if available if let userLocation { coordinates.append(userLocation.coordinate) } - // Get coordinates from path repeaters for hop in traceViewModel.outboundPath { guard let repeater = findRepeater(for: hop), repeater.hasLocation else { @@ -432,7 +390,14 @@ final class TracePathMapViewModel { return } - // Calculate bounding region + setCameraRegion(fitting: coordinates) + hasInitiallyCenteredOnRepeaters = true + } + + /// Compute a bounding region for the given coordinates and update the camera. + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard !coordinates.isEmpty else { return } + var minLat = coordinates[0].latitude var maxLat = coordinates[0].latitude var minLon = coordinates[0].longitude @@ -451,12 +416,11 @@ final class TracePathMapViewModel { ) let span = MKCoordinateSpan( - latitudeDelta: min(180, (maxLat - minLat) * 1.5 + 0.01), - longitudeDelta: min(360, (maxLon - minLon) * 1.5 + 0.01) + latitudeDelta: min(180, max(0.01, (maxLat - minLat) * 1.5)), + longitudeDelta: min(360, max(0.01, (maxLon - minLon) * 1.5)) ) cameraRegion = MKCoordinateRegion(center: center, span: span) cameraRegionVersion += 1 - hasInitiallyCenteredOnRepeaters = true } } diff --git a/MC1/Views/LineOfSight/AddRepeaterRowView.swift b/MC1/Views/LineOfSight/AddRepeaterRowView.swift index deb175dc1..3bdcbabb8 100644 --- a/MC1/Views/LineOfSight/AddRepeaterRowView.swift +++ b/MC1/Views/LineOfSight/AddRepeaterRowView.swift @@ -29,6 +29,6 @@ struct AddRepeaterRowView: View { } .padding(.vertical, 8) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() } } diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 45fcf953e..031660d5b 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -3,12 +3,6 @@ import MapKit import MC1Services import SwiftUI -extension CLLocationCoordinate2D { - var formattedString: String { - "\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))" - } -} - private let analysisSheetDetentCollapsed: PresentationDetent = .fraction(0.25) private let analysisSheetDetentHalf: PresentationDetent = .fraction(0.5) private let analysisSheetDetentExpanded: PresentationDetent = .large @@ -333,7 +327,7 @@ struct LineOfSightView: View { .frame(maxWidth: .infinity) } } - .glassProminentButtonStyle() + .liquidGlassProminentButtonStyle() .controlSize(.large) .disabled(viewModel.isAnalyzing || hasAnalysisResult) } diff --git a/MC1/Views/LineOfSight/PointRowButtonsView.swift b/MC1/Views/LineOfSight/PointRowButtonsView.swift index 45c73e9e4..4cc077bed 100644 --- a/MC1/Views/LineOfSight/PointRowButtonsView.swift +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -41,7 +41,7 @@ struct PointRowButtonsView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .sensoryFeedback(.success, trigger: copyHapticTrigger) .controlSize(.small) @@ -58,7 +58,7 @@ struct PointRowButtonsView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != pointID) @@ -80,7 +80,7 @@ struct PointRowButtonsView: View { } .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) // Clear button @@ -89,7 +89,7 @@ struct PointRowButtonsView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) } } diff --git a/MC1/Views/LineOfSight/PointsSummarySectionView.swift b/MC1/Views/LineOfSight/PointsSummarySectionView.swift index f4dcf1d13..e1646e794 100644 --- a/MC1/Views/LineOfSight/PointsSummarySectionView.swift +++ b/MC1/Views/LineOfSight/PointsSummarySectionView.swift @@ -21,7 +21,7 @@ struct PointsSummarySectionView: View { Button(L10n.Tools.Tools.LineOfSight.cancel) { viewModel.relocatingPoint = nil } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) } } diff --git a/MC1/Views/LineOfSight/RepeaterRowView.swift b/MC1/Views/LineOfSight/RepeaterRowView.swift index b68c7296c..01986ded1 100644 --- a/MC1/Views/LineOfSight/RepeaterRowView.swift +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -65,7 +65,7 @@ struct RepeaterRowView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .sensoryFeedback(.success, trigger: copyHapticTrigger) .controlSize(.small) @@ -82,7 +82,7 @@ struct RepeaterRowView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) .disabled(viewModel.relocatingPoint != nil && viewModel.relocatingPoint != .repeater) @@ -104,7 +104,7 @@ struct RepeaterRowView: View { } .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) // Clear button @@ -115,7 +115,7 @@ struct RepeaterRowView: View { .labelStyle(.iconOnly) .frame(width: iconButtonSize, height: iconButtonSize) } - .glassButtonStyle() + .liquidGlassSecondaryButtonStyle() .controlSize(.small) } diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index e44eaac68..ae79492e1 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -13,8 +13,8 @@ struct ContactCalloutContent: View { .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) @@ -42,28 +42,6 @@ struct ContactCalloutContent: View { // 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 index c27dae7d2..e5ed35992 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -40,10 +40,10 @@ struct ContactDetailSheet: View { LabeledContent(L10n.Map.Map.Detail.type) { HStack { - Image(systemName: typeIconName) + Image(systemName: contact.type.iconSystemName) Text(typeDisplayName) } - .foregroundStyle(typeColor) + .foregroundStyle(contact.type.displayColor) } if contact.isFavorite { @@ -192,17 +192,6 @@ struct ContactDetailSheet: View { // 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: @@ -214,14 +203,4 @@ struct ContactDetailSheet: View { } } - private var typeColor: Color { - switch contact.type { - case .chat: - .blue - case .repeater: - .green - case .room: - .purple - } - } } diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index e4748f3e5..1ac255298 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -5,6 +5,34 @@ import UIKit /// MapLibre's default ("Open Sans Regular") returns 404, causing silent symbol dropout. 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 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 @@ -27,7 +55,7 @@ extension MC1MapView.Coordinator { } else if !clusterablePoints.isEmpty { let features = clusterablePoints.map { pointFeature(for: $0) } let source = MLNShapeSource( - identifier: "points", + identifier: MapSourceID.points, features: features, options: [ .clustered: true, @@ -47,7 +75,7 @@ extension MC1MapView.Coordinator { ) } else if !fixedPoints.isEmpty { let features = fixedPoints.map { pointFeature(for: $0) } - let source = MLNShapeSource(identifier: "fixed-points", features: features, options: nil) + let source = MLNShapeSource(identifier: MapSourceID.fixedPoints, features: features, options: nil) style.addSource(source) self.fixedSource = source addFixedPointLayers(source: source, style: style) @@ -55,7 +83,7 @@ extension MC1MapView.Coordinator { } func updateLabelVisibility(mapView: MLNMapView) { - for layerId in ["name-labels", "fixed-name-labels"] { + for layerId in [MapLayerID.nameLabels, MapLayerID.fixedNameLabels] { guard let layer = mapView.style?.layer(withIdentifier: layerId) as? MLNSymbolStyleLayer else { continue } layer.isVisible = showLabels } @@ -65,7 +93,7 @@ extension MC1MapView.Coordinator { private func addClusteredPointLayers(source: MLNShapeSource, style: MLNStyle) { // Cluster circles - let circleLayer = MLNCircleStyleLayer(identifier: "cluster-circles", source: source) + 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( @@ -80,7 +108,7 @@ extension MC1MapView.Coordinator { style.addLayer(circleLayer) // Cluster count labels - let clusterLabelLayer = MLNSymbolStyleLayer(identifier: "cluster-labels", source: source) + 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) @@ -91,7 +119,7 @@ extension MC1MapView.Coordinator { style.addLayer(clusterLabelLayer) // Unclustered pin icons - let iconLayer = MLNSymbolStyleLayer(identifier: "unclustered-icons", source: source) + let iconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.unclusteredIcons, source: source) iconLayer.predicate = NSPredicate(format: "cluster != YES") iconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") iconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") @@ -101,7 +129,7 @@ extension MC1MapView.Coordinator { style.addLayer(iconLayer) // Name labels (above pins) with pill background - let nameLabelLayer = MLNSymbolStyleLayer(identifier: "name-labels", source: source) + let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND nameLabel != nil") nameLabelLayer.text = NSExpression(forKeyPath: "nameLabel") nameLabelLayer.textFontSize = NSExpression(forConstantValue: 10) @@ -121,7 +149,7 @@ extension MC1MapView.Coordinator { style.addLayer(nameLabelLayer) // Stats badge text (trace path midpoints) with pill background - let badgeLayer = MLNSymbolStyleLayer(identifier: "badge-text", source: source) + let badgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.badgeText, source: source) badgeLayer.predicate = NSPredicate(format: "cluster != YES AND badgeText != nil") badgeLayer.text = NSExpression(forKeyPath: "badgeText") badgeLayer.textFontSize = NSExpression(forConstantValue: 11) @@ -140,7 +168,7 @@ extension MC1MapView.Coordinator { // MARK: - Fixed point layers private func addFixedPointLayers(source: MLNShapeSource, style: MLNStyle) { - let fixedIconLayer = MLNSymbolStyleLayer(identifier: "fixed-icons", source: source) + let fixedIconLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedIcons, source: source) fixedIconLayer.iconImageName = NSExpression(forKeyPath: "spriteName") fixedIconLayer.iconAnchor = NSExpression(forConstantValue: "bottom") fixedIconLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) @@ -148,7 +176,7 @@ extension MC1MapView.Coordinator { fixedIconLayer.text = nil style.addLayer(fixedIconLayer) - let fixedNameLayer = MLNSymbolStyleLayer(identifier: "fixed-name-labels", source: source) + let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) fixedNameLayer.predicate = NSPredicate(format: "nameLabel != nil") fixedNameLayer.text = NSExpression(forKeyPath: "nameLabel") fixedNameLayer.textFontSize = NSExpression(forConstantValue: 10) @@ -167,7 +195,7 @@ extension MC1MapView.Coordinator { fixedNameLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) style.addLayer(fixedNameLayer) - let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: "fixed-badge-text", source: source) + let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") fixedBadgeLayer.text = NSExpression(forKeyPath: "badgeText") fixedBadgeLayer.textFontSize = NSExpression(forConstantValue: 11) @@ -186,10 +214,10 @@ extension MC1MapView.Coordinator { // MARK: - Line layers func setupLineLayers(style: MLNStyle) { - let source = MLNShapeSource(identifier: "lines", features: [], options: nil) + let source = MLNShapeSource(identifier: MapSourceID.lines, features: [], options: nil) style.addSource(source) - let losLayer = MLNLineStyleLayer(identifier: "line-los", source: source) + let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) losLayer.predicate = NSPredicate(format: "lineStyle == 'los'") losLayer.lineColor = NSExpression(forConstantValue: UIColor.systemBlue) losLayer.lineWidth = NSExpression(forConstantValue: 3) @@ -197,28 +225,28 @@ extension MC1MapView.Coordinator { losLayer.lineOpacity = NSExpression(forKeyPath: "segmentOpacity") style.addLayer(losLayer) - let untracedLayer = MLNLineStyleLayer(identifier: "line-trace-untraced", source: source) + let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) untracedLayer.predicate = NSPredicate(format: "lineStyle == 'traceUntraced'") untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) untracedLayer.lineWidth = NSExpression(forConstantValue: 2) untracedLayer.lineDashPattern = NSExpression(forConstantValue: [8, 6]) style.addLayer(untracedLayer) - let weakLayer = MLNLineStyleLayer(identifier: "line-trace-weak", source: source) + let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) weakLayer.predicate = NSPredicate(format: "lineStyle == 'traceWeak'") weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) weakLayer.lineWidth = NSExpression(forConstantValue: 3) weakLayer.lineDashPattern = NSExpression(forConstantValue: [4, 4]) style.addLayer(weakLayer) - let mediumLayer = MLNLineStyleLayer(identifier: "line-trace-medium", source: source) + let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) mediumLayer.predicate = NSPredicate(format: "lineStyle == 'traceMedium'") mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) mediumLayer.lineWidth = NSExpression(forConstantValue: 3) mediumLayer.lineDashPattern = NSExpression(forConstantValue: [12, 4]) style.addLayer(mediumLayer) - let goodLayer = MLNLineStyleLayer(identifier: "line-trace-good", source: source) + let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) goodLayer.predicate = NSPredicate(format: "lineStyle == 'traceGood'") goodLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGreen) goodLayer.lineWidth = NSExpression(forConstantValue: 4) @@ -226,7 +254,7 @@ extension MC1MapView.Coordinator { } func updateLineSource(mapView: MLNMapView) { - guard let source = mapView.style?.source(withIdentifier: "lines") as? MLNShapeSource else { return } + guard let source = mapView.style?.source(withIdentifier: MapSourceID.lines) as? MLNShapeSource else { return } let features = currentLines.map { line -> MLNPolylineFeature in var coords = line.coordinates @@ -244,7 +272,7 @@ extension MC1MapView.Coordinator { func setupRasterSources(style: MLNStyle, mapView: MLNMapView) { let satSource = MLNRasterTileSource( - identifier: "satellite-tiles", + identifier: MapSourceID.satelliteTiles, tileURLTemplates: [MapTileURLs.esriWorldImagery], options: [ .tileSize: 256, @@ -253,12 +281,12 @@ extension MC1MapView.Coordinator { ] ) style.addSource(satSource) - let satLayer = MLNRasterStyleLayer(identifier: "satellite-layer", source: satSource) + let satLayer = MLNRasterStyleLayer(identifier: MapLayerID.satelliteLayer, source: satSource) satLayer.isVisible = false style.addLayer(satLayer) let topoSource = MLNRasterTileSource( - identifier: "topo-tiles", + identifier: MapSourceID.topoTiles, tileURLTemplates: [MapTileURLs.openTopoMapA], options: [ .tileSize: 256, @@ -267,7 +295,7 @@ extension MC1MapView.Coordinator { ] ) style.addSource(topoSource) - let topoLayer = MLNRasterStyleLayer(identifier: "topo-layer", source: topoSource) + let topoLayer = MLNRasterStyleLayer(identifier: MapLayerID.topoLayer, source: topoSource) topoLayer.isVisible = false style.addLayer(topoLayer) @@ -276,8 +304,8 @@ extension MC1MapView.Coordinator { func updateRasterLayerVisibility(mapView: MLNMapView) { guard let style = mapView.style else { return } - style.layer(withIdentifier: "satellite-layer")?.isVisible = currentMapStyle == .satellite - style.layer(withIdentifier: "topo-layer")?.isVisible = currentMapStyle == .topo + style.layer(withIdentifier: MapLayerID.satelliteLayer)?.isVisible = currentMapStyle == .satellite + style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo } // MARK: - Private helpers diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 3b1af3930..2b196ece7 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -367,10 +367,10 @@ extension MC1MapView { // 1. Check cluster layers let clusterFeatures = mapView.visibleFeatures( in: clusterRect, - styleLayerIdentifiers: ["cluster-circles"] + styleLayerIdentifiers: [MapLayerID.clusterCircles] ) if let cluster = clusterFeatures.first(where: { $0 is MLNPointFeatureCluster }) as? MLNPointFeatureCluster, - let source = mapView.style?.source(withIdentifier: "points") as? MLNShapeSource { + 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) @@ -380,7 +380,7 @@ extension MC1MapView { // 2. Check point layers (both clustered and fixed) let pointFeatures = mapView.visibleFeatures( at: point, - styleLayerIdentifiers: ["unclustered-icons", "fixed-icons"] + styleLayerIdentifiers: [MapLayerID.unclusteredIcons, MapLayerID.fixedIcons] ) logger.debug("pointFeatures: \(pointFeatures.count, privacy: .public), clusterFeatures: \(clusterFeatures.count, privacy: .public)") if let feature = pointFeatures.first, @@ -397,7 +397,7 @@ extension MC1MapView { // 3. Check badge text layers let badgeFeatures = mapView.visibleFeatures( at: point, - styleLayerIdentifiers: ["badge-text", "fixed-badge-text"] + styleLayerIdentifiers: [MapLayerID.badgeText, MapLayerID.fixedBadgeText] ) if badgeFeatures.first != nil { return // absorb tap on badges diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift index 466a34760..b9d53f170 100644 --- a/MC1/Views/Map/MapTileURLs.swift +++ b/MC1/Views/Map/MapTileURLs.swift @@ -3,6 +3,4 @@ enum MapTileURLs { 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/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 56db0bcb9..762abb204 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -6,25 +6,33 @@ enum PinSpriteRenderer { /// Used by the map Coordinator to position callout anchors above the pin icon. static let standardHeight: CGFloat = 43 // 36 (circle) + 10 (triangle) - 3 (overlap) + private nonisolated(unsafe) static var cachedImages: [String: UIImage]? + static func renderAll(into style: MLNStyle) { - for spec in allSpecs { - let image = render(spec) - style.setImage(image, forName: spec.name) - } - // Transparent 1px sprite for badge points (only badge-text layer renders) - let transparent = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1), format: .preferred()).image { _ in } - style.setImage(transparent, forName: "pin-badge") - - // Hop badge variants for ring-white pins (trace path) - if let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) { - for hop in 1...20 { - let image = render(ringWhiteSpec, hopIndex: hop) - style.setImage(image, forName: "pin-repeater-ring-white-hop-\(hop)") + let images: [String: UIImage] + if let cached = cachedImages { + images = cached + } else { + 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 } + if let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) { + for hop in 1...20 { + rendered["pin-repeater-ring-white-hop-\(hop)"] = render(ringWhiteSpec, hopIndex: hop) + } + } + rendered["pill-bg"] = renderPillBackground() + cachedImages = rendered + images = rendered } - // Stretchable pill background for label and badge layers - style.setImage(renderPillBackground(), forName: "pill-bg") + for (name, image) in images { + style.setImage(image, forName: name) + } } // MARK: - Sprite specifications diff --git a/MC1/Views/Settings/LocationPickerView.swift b/MC1/Views/Settings/LocationPickerView.swift index 8361f96dd..9840424cf 100644 --- a/MC1/Views/Settings/LocationPickerView.swift +++ b/MC1/Views/Settings/LocationPickerView.swift @@ -212,13 +212,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) } } } @@ -285,30 +287,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 From 695c441242f4d68272933a748e845bcbfe7e5323 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:56:09 -0700 Subject: [PATCH 17/55] fix(map): fix landscape scale flicker via dual UIView swizzle - Intercept setContentScaleFactor: on MapLibre's Metal UIView alongside setDrawableSize: to prevent wrong contentsScale from being stored - Extract findMapView(from:) helper to deduplicate parent-walking logic - Track lastAppliedStyleURL instead of reading mapView.styleURL which MapLibre transiently nils during rotation - Guard data layer updates against mid-gesture state (isUserInteracting) - Guard label visibility updates against no-op changes - Guard against duplicate source/layer setup on style reload --- MC1/Views/Map/MC1MapView+Layers.swift | 5 ++ MC1/Views/Map/MC1MapView.swift | 72 +++++++++++++++++++++------ 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 1ac255298..80decffa1 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -214,6 +214,7 @@ extension MC1MapView.Coordinator { // 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) @@ -271,6 +272,10 @@ extension MC1MapView.Coordinator { // 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], diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 2b196ece7..1d871f2a7 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -11,9 +11,8 @@ private let logger = Logger(subsystem: "com.mc1", category: "MapPins") /// 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 `setDrawableSize:` on the internal Metal **UIView** (not -/// the CAMetalLayer) so that `layoutChanged()`'s read-back of -/// `resource.mtlView.drawableSize` also returns the corrected value. +/// 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 { @@ -32,6 +31,7 @@ private enum MetalLayerScaleFix { } 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 } @@ -39,8 +39,6 @@ private enum MetalLayerScaleFix { object_setClass(metalView, fixedClass) } - // MARK: - Private - private static func findMetalView(in view: UIView) -> UIView? { for subview in view.subviews where subview.layer is CAMetalLayer { return subview @@ -48,6 +46,12 @@ private enum MetalLayerScaleFix { 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 @@ -59,10 +63,7 @@ private enum MetalLayerScaleFix { let callOriginal = unsafeBitCast(originalIMP, to: SetDrawableSizeFn.self) let block: @convention(block) (UIView, CGSize) -> Void = { metalView, proposedSize in - var parent: UIView? = metalView.superview - while let v = parent, !(v is MLNMapView) { parent = v.superview } - - guard let mapView = parent, + guard let mapView = findMapView(from: metalView), mapView.bounds.size.width > 0, mapView.bounds.size.height > 0, let screen = mapView.window?.screen else { @@ -88,9 +89,37 @@ private enum MetalLayerScaleFix { 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 Metal layer scale fix once the view is attached to a window. +/// Applies the isa-swizzle once the view is attached to a window. private final class ScaledMLNMapView: MLNMapView { override func didMoveToWindow() { super.didMoveToWindow() @@ -184,9 +213,11 @@ struct MC1MapView: UIViewRepresentable { coordinator.currentPoints = points coordinator.currentLines = lines - // Style URL change + // 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) - if mapView.styleURL != newStyleURL { + if coordinator.lastAppliedStyleURL != newStyleURL { + coordinator.lastAppliedStyleURL = newStyleURL coordinator.isStyleLoaded = false mapView.styleURL = newStyleURL } @@ -196,8 +227,8 @@ struct MC1MapView: UIViewRepresentable { // User location mapView.showsUserLocation = showsUserLocation - // Update data layers (only when style is loaded and data changed) - if coordinator.isStyleLoaded { + // Update data layers (only when style is loaded, data changed, and not mid-gesture) + if coordinator.isStyleLoaded, !coordinator.isUserInteracting { if mapStyleChanged { coordinator.updateRasterLayerVisibility(mapView: mapView) } @@ -207,7 +238,10 @@ struct MC1MapView: UIViewRepresentable { if linesChanged { coordinator.updateLineSource(mapView: mapView) } - coordinator.updateLabelVisibility(mapView: mapView) + if coordinator.currentShowLabels != showLabels { + coordinator.currentShowLabels = showLabels + coordinator.updateLabelVisibility(mapView: mapView) + } } // Camera region (version-number pattern) @@ -290,11 +324,14 @@ extension MC1MapView { var setIsStyleLoaded: ((Bool) -> Void)? // State + var isUserInteracting = false var isUpdatingFromSwiftUI = false var isStyleLoaded = false var lastAppliedRegionVersion = 0 var pendingRegionTask: Task? var showLabels = true + var currentShowLabels = true + var lastAppliedStyleURL: URL? var currentMapStyle: MapStyleSelection? var currentPoints: [MapPoint] = [] var currentLines: [MapLine] = [] @@ -331,7 +368,12 @@ extension MC1MapView { .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) From 20c09293159f5623b2ed13c8b8fce585229a69a2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:56:19 -0700 Subject: [PATCH 18/55] refactor(map): move mapPoints construction to MapViewModel - Build and cache mapPoints in rebuildMapPoints() to avoid reallocation on every SwiftUI body evaluation - Remove callout dismissal on camera region change - Regenerated L10n.swift --- MC1/Resources/Generated/L10n.swift | 42 ++++++++++++++++++++++++++++++ MC1/Views/Map/MapContentView.swift | 2 -- MC1/Views/Map/MapView.swift | 24 +---------------- MC1/Views/Map/MapViewModel.swift | 28 ++++++++++++++++++++ 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 46e5fd1c0..74f487998 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3705,6 +3705,42 @@ 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: "Complete") + /// 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") + /// Section header for downloaded maps list + public static let downloaded = L10n.tr("Settings", "offlineMaps.downloaded", fallback: "Downloaded") + /// 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") + /// 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") + /// Section header for storage info + public static let storage = L10n.tr("Settings", "offlineMaps.storage", fallback: "Storage") + /// 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 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.") @@ -3813,6 +3849,12 @@ public enum L10n { public static let title = L10n.tr("Settings", "regenerateIdentity.sheet.title", fallback: "Regenerate Key") } } + public enum ReplyWithQuote { + /// Replying includes a preview of the original message. + public static let footer = L10n.tr("Settings", "replyWithQuote.footer", fallback: "Replying includes a preview of the original message.") + /// Reply with Quote + public static let toggle = L10n.tr("Settings", "replyWithQuote.toggle", fallback: "Reply with Quote") + } public enum Telemetry { /// Toggle label for allowing telemetry requests public static let allowRequests = L10n.tr("Settings", "telemetry.allowRequests", fallback: "Allow Telemetry Requests") diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index fa0d8ddcd..9708a2c12 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -38,8 +38,6 @@ struct MapContentView: View { }, onCameraRegionChange: { region in viewModel.cameraRegion = region - selectedCalloutContact = nil - selectedPointScreenPosition = nil }, isStyleLoaded: $isStyleLoaded ) diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 54051bc5e..833cf7012 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -16,7 +16,7 @@ struct MapView: View { NavigationStack { MapCanvasView( viewModel: viewModel, - mapPoints: mapPoints, + mapPoints: viewModel.mapPoints, colorScheme: colorScheme, selectedCalloutContact: $selectedCalloutContact, selectedPointScreenPosition: $selectedPointScreenPosition, @@ -52,28 +52,6 @@ struct MapView: View { } } - private var mapPoints: [MapPoint] { - viewModel.contactsWithLocation.map { contact in - MapPoint( - id: contact.id, - coordinate: contact.coordinate, - pinStyle: pinStyle(for: contact), - label: contact.displayName, - isClusterable: true, - hopIndex: nil, - badgeText: nil - ) - } - } - - private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { - switch contact.type { - case .chat: .contactChat - case .repeater: .contactRepeater - case .room: .contactRoom - } - } - // MARK: - Refresh Button private var refreshButton: some View { diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index 544e867c8..b9ddf9766 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -12,6 +12,9 @@ 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 @@ -66,6 +69,7 @@ final class MapViewModel { do { let allContacts = try await dataStore.fetchContacts(deviceID: deviceID) contactsWithLocation = allContacts.filter(\.hasLocation) + rebuildMapPoints() } catch { errorMessage = error.localizedDescription } @@ -73,6 +77,30 @@ final class MapViewModel { isLoading = false } + // MARK: - Map Points + + private func rebuildMapPoints() { + mapPoints = contactsWithLocation.map { contact in + MapPoint( + id: contact.id, + coordinate: contact.coordinate, + pinStyle: pinStyle(for: contact), + label: contact.displayName, + isClusterable: true, + hopIndex: nil, + badgeText: nil + ) + } + } + + private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { + switch contact.type { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } + // MARK: - Map Interaction /// Center map on a specific contact From f577f8884d9f93a50c70dd24085e203fece566a1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:51:21 -0700 Subject: [PATCH 19/55] feat(offline-maps): add satellite and topo offline layer support - Add OfflineMapLayer enum with base, satellite, and topo cases - Add offline style JSON files for satellite and topo layers - Extend OfflineMapService with multi-layer download, disk space checks, download speed tracking, error reporting, and database size - Update OfflineMapSettingsView with layer picker, error alerts, and storage footer - Add offlineMapLayer property to MapStyleSelection - Add localized strings for offline map layers across all languages --- MC1/Resources/Generated/L10n.swift | 44 ++- .../Localization/de.lproj/Settings.strings | 44 +++ .../Localization/en.lproj/Settings.strings | 44 +++ .../Localization/es.lproj/Settings.strings | 44 +++ .../Localization/fr.lproj/Settings.strings | 44 +++ .../Localization/nl.lproj/Settings.strings | 44 +++ .../Localization/pl.lproj/Settings.strings | 44 +++ .../Localization/ru.lproj/Settings.strings | 44 +++ .../Localization/uk.lproj/Settings.strings | 44 +++ .../zh-Hans.lproj/Settings.strings | 44 +++ MC1/Resources/Styles/satellite-offline.json | 22 ++ MC1/Resources/Styles/topo-offline.json | 22 ++ MC1/Services/OfflineMapService.swift | 298 ++++++++++++++-- MC1/Views/Map/MapStyleSelection.swift | 19 +- .../Settings/OfflineMapSettingsView.swift | 322 +++++++++++++++--- 15 files changed, 1048 insertions(+), 75 deletions(-) create mode 100644 MC1/Resources/Styles/satellite-offline.json create mode 100644 MC1/Resources/Styles/topo-offline.json diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 74f487998..7a5691a3f 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2037,8 +2037,12 @@ 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 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 @@ -2114,7 +2118,7 @@ public enum L10n { /// 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: "Topo") + public static let topo = L10n.tr("Map", "map.style.topo", fallback: "Topography") } } } @@ -3720,6 +3724,8 @@ public enum L10n { public static let download = L10n.tr("Settings", "offlineMaps.download", fallback: "Download") /// Section header for downloaded maps list public static let downloaded = L10n.tr("Settings", "offlineMaps.downloaded", fallback: "Downloaded") + /// 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 @@ -3728,18 +3734,54 @@ public enum L10n { 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") + /// Satellite + public static let satellite = L10n.tr("Settings", "offlineMaps.layer.satellite", fallback: "Satellite") + /// Topography + public static let topo = L10n.tr("Settings", "offlineMaps.layer.topo", fallback: "Topography") + } } public enum PathHashMode { /// Footer explaining path hash mode tradeoff diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 2b2b43791..073bab227 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Topografie"; + +/* 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/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index 703b44840..995100916 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1322,3 +1322,47 @@ /* 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.satellite" = "Satellite"; +"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/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index 63a779fb5..d9f982a31 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Topografía"; + +/* 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/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 83a51e9ae..005a2715b 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Topographie"; + +/* 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/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index 8928a6862..f3d7c69a6 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Topografie"; + +/* 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/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index b8ccf9559..693738fa2 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Topografia"; + +/* 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/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 287686a28..fee288962 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Топография"; + +/* 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/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 0e9b569fd..6313a161a 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1317,3 +1317,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "Топографія"; + +/* 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/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index 8bf7848fa..cb2e3e46a 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1290,3 +1290,47 @@ /* 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" = "Base Map"; +"offlineMaps.layer.satellite" = "Satellite"; +"offlineMaps.layer.topo" = "地形"; + +/* 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/Styles/satellite-offline.json b/MC1/Resources/Styles/satellite-offline.json new file mode 100644 index 000000000..ae6babd2d --- /dev/null +++ b/MC1/Resources/Styles/satellite-offline.json @@ -0,0 +1,22 @@ +{ + "version": 8, + "name": "Satellite Offline", + "sources": { + "satellite": { + "type": "raster", + "tiles": [ + "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + ], + "tileSize": 256, + "maxzoom": 19, + "attribution": "Esri" + } + }, + "layers": [ + { + "id": "satellite", + "type": "raster", + "source": "satellite" + } + ] +} 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 index e42a514ea..f10fe77dc 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -1,22 +1,82 @@ +import Foundation import MapLibre import Network import os +enum OfflineMapLayer: String, Codable, CaseIterable { + case base + case satellite + case topo + + var label: String { + switch self { + case .base: L10n.Settings.OfflineMaps.Layer.base + case .satellite: L10n.Settings.OfflineMaps.Layer.satellite + case .topo: L10n.Settings.OfflineMaps.Layer.topo + } + } + + var styleURL: URL? { + switch self { + case .base: + URL(string: MapTileURLs.openFreeMapLiberty) + case .satellite: + Bundle.main.url(forResource: "satellite-offline", withExtension: "json") + 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 + + var errorDescription: String? { + switch self { + case .insufficientDiskSpace: + L10n.Settings.OfflineMaps.Error.insufficientDiskSpace + } + } } @MainActor @Observable final class OfflineMapService { - static let logger = Logger(subsystem: "com.pocketmesh", category: "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 = [] init() { let networkStream = AsyncStream { continuation in @@ -39,11 +99,35 @@ final class OfflineMapService { 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 { @@ -54,8 +138,55 @@ final class OfflineMapService { } } + func hasCompletedPack(for layer: OfflineMapLayer) -> Bool { + packs.contains { $0.layer == layer && $0.isComplete } + } + func loadPacks() { - packs = (MLNOfflineStorage.shared.packs ?? []).map { OfflinePack(pack: $0) } + 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. @@ -68,37 +199,62 @@ final class OfflineMapService { } } + // 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, maxZoom: Double = 15 ) async throws { - // swiftlint:disable:next force_unwrapping - let styleURL = URL(string: MapTileURLs.openFreeMapLiberty)! - let region = MLNTilePyramidOfflineRegion( - styleURL: styleURL, - bounds: bounds, - fromZoomLevel: minZoom, - toZoomLevel: maxZoom + let values = try URL.documentsDirectory.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] ) + if let available = values.volumeAvailableCapacityForImportantUsage, + available < Self.minimumDiskSpaceBytes { + throw OfflineMapError.insufficientDiskSpace + } - let context = try JSONEncoder().encode(OfflinePackMetadata(name: name, createdAt: .now)) + let encoder = JSONEncoder() + let now = Date.now - 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() + for layer in layers { + guard let styleURL = layer.styleURL else { + Self.logger.error("Missing style URL for layer: \(layer.rawValue)") + continue + } + + let region = MLNTilePyramidOfflineRegion( + styleURL: styleURL, + bounds: bounds, + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + + let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) + let context = try encoder.encode(metadata) + + 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 { @@ -107,6 +263,20 @@ final class OfflineMapService { continuation.resume() } } + highWaterMarks.removeValue(forKey: pack.id) + byteSnapshots.removeValue(forKey: pack.id) + downloadSpeeds.removeValue(forKey: pack.id) + loadPacks() + updateDatabaseSize() + } + + func pausePack(_ pack: OfflinePack) { + pack.mlnPack.suspend() + loadPacks() + } + + func resumePack(_ pack: OfflinePack) { + pack.mlnPack.resume() loadPacks() } @@ -116,38 +286,112 @@ final class OfflineMapService { pack.resume() } } + loadPacks() + } + + func clearLastPackError() { + lastPackError = nil + } + + /// Estimated download size using per-zoom average byte sizes. + static func estimatedDownloadSize( + bounds: MLNCoordinateBounds, + minZoom: Int, + maxZoom: Int, + layer: OfflineMapLayer = .base + ) -> Int64 { + let bytesPerTile: [Int: Int64] + switch layer { + case .base: + // Average compressed vector tile sizes for OpenFreeMap (OpenMapTiles schema). + bytesPerTile = [ + 10: 2_000, 11: 3_000, 12: 5_000, + 13: 8_000, 14: 12_000, 15: 15_000, + ] + case .satellite: + // Esri World Imagery JPEG raster tiles (256px). + bytesPerTile = [ + 10: 20_000, 11: 25_000, 12: 30_000, + 13: 35_000, 14: 40_000, 15: 45_000, + ] + case .topo: + // OpenTopoMap PNG raster tiles (256px). + bytesPerTile = [ + 10: 15_000, 11: 18_000, 12: 22_000, + 13: 25_000, 14: 30_000, 15: 35_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 + } + + 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 - let mlnPack: MLNOfflinePack + fileprivate let mlnPack: MLNOfflinePack let name: String let createdAt: Date? - let progress: MLNOfflinePackProgress - let state: MLNOfflinePackState + let layer: OfflineMapLayer + let completedFraction: Double + let downloadSpeed: Int64? - var completedFraction: Double { - guard progress.countOfResourcesExpected > 0 else { return 0 } - return Double(progress.countOfResourcesCompleted) / Double(progress.countOfResourcesExpected) - } + 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) { + 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 - let context = pack.context - if let metadata = try? JSONDecoder().decode(OfflinePackMetadata.self, from: context) { + 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 } } } diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 4c387c969..4961c4047 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -22,16 +22,17 @@ enum MapStyleSelection: String, CaseIterable, Hashable { } } - func styleURL(isDarkMode: Bool) -> URL { + var offlineMapLayer: OfflineMapLayer { switch self { - case .standard: - let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty - return URL(string: url)! - case .satellite, .topo: - // Satellite/topo use raster overlays on top of the base vector style. - // The base style URL is still needed; raster sources are added later. - let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty - return URL(string: url)! + case .standard: .base + case .satellite: .satellite + case .topo: .topo } } + + /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. + func styleURL(isDarkMode: Bool) -> URL { + let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty + return URL(string: url)! + } } diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 6ae044d5e..7d4bff2a5 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -6,9 +6,10 @@ struct OfflineMapSettingsView: View { @Environment(\.appState) private var appState @State private var showingRegionPicker = false @State private var packToDelete: OfflinePack? + @State private var showError: String? var body: some View { - List { + Group { if appState.offlineMapService.packs.isEmpty { ContentUnavailableView { Label(L10n.Settings.OfflineMaps.emptyTitle, systemImage: "map") @@ -21,20 +22,20 @@ struct OfflineMapSettingsView: View { .buttonStyle(.bordered) } } else { - PacksSection(packToDelete: $packToDelete) - StorageSection() - } - } - .navigationTitle(L10n.Settings.OfflineMaps.title) - .toolbar { - if !appState.offlineMapService.packs.isEmpty { - ToolbarItem(placement: .primaryAction) { - Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { - showingRegionPicker = true + List { + PacksSection(packToDelete: $packToDelete) + StorageSection() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.Settings.OfflineMaps.downloadRegion, systemImage: "plus") { + showingRegionPicker = true + } } } } } + .navigationTitle(L10n.Settings.OfflineMaps.title) .sheet(isPresented: $showingRegionPicker) { RegionPickerSheet() } @@ -53,6 +54,13 @@ struct OfflineMapSettingsView: View { } message: { _ in Text(L10n.Settings.OfflineMaps.deleteMessage) } + .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in + if let newValue { + showError = newValue + appState.offlineMapService.clearLastPackError() + } + } + .errorAlert($showError) } } @@ -86,12 +94,13 @@ private struct StorageSection: View { var body: some View { Section { - let totalBytes = appState.offlineMapService.packs.reduce(UInt64(0)) { $0 + $1.completedBytes } LabeledContent(L10n.Settings.OfflineMaps.storageUsed) { - Text(Int64(totalBytes), format: .byteCount(style: .file)) + Text(appState.offlineMapService.databaseSize, format: .byteCount(style: .file)) } } header: { Text(L10n.Settings.OfflineMaps.storage) + } footer: { + Text(L10n.Settings.OfflineMaps.storageFooter) } } } @@ -99,16 +108,24 @@ private struct StorageSection: View { // MARK: - Offline Pack Row private struct OfflinePackRow: View { + @Environment(\.appState) private var appState let pack: OfflinePack var body: some View { - VStack(alignment: .leading) { - Text(pack.name) + 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) @@ -116,13 +133,35 @@ private struct OfflinePackRow: View { Spacer() - Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) - .foregroundStyle(.secondary) + VStack(alignment: .trailing) { + Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) + if let speed = pack.downloadSpeed, speed > 0 { + Text(speed, format: .byteCount(style: .file)) + Text("/s") + } + } + .foregroundStyle(.secondary) } .font(.caption) if !pack.isComplete { - ProgressView(value: pack.completedFraction) + 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) + } } } } @@ -137,8 +176,17 @@ private struct RegionPickerSheet: View { @State private var regionName = "" @State private var cameraRegion: MKCoordinateRegion? - @State private var isStyleLoaded = false @State private var isDownloading = false + @State private var showError: String? + @State private var mapSize: CGSize = .zero + @State private var includeSatellite = false + @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 { @@ -152,23 +200,34 @@ private struct RegionPickerSheet: View { 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 ) - .ignoresSafeArea(edges: .bottom) // Selection rectangle overlay RoundedRectangle(cornerRadius: 8) .strokeBorder(Color.accentColor, lineWidth: 2) - .padding(40) + .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 { @@ -181,29 +240,71 @@ private struct RegionPickerSheet: View { Button(L10n.Settings.OfflineMaps.download) { downloadRegion() } - .disabled(regionName.isEmpty || isDownloading) + .disabled( + regionName.isEmpty || isDownloading || exceedsAvailableSpace + || !appState.offlineMapService.isNetworkAvailable + ) } } .safeAreaInset(edge: .bottom) { - TextField(L10n.Settings.OfflineMaps.regionName, text: $regionName) - .textFieldStyle(.roundedBorder) - .padding() - .background(.regularMaterial) + RegionPickerBottomCard( + regionName: $regionName, + includeSatellite: $includeSatellite, + 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 + // MARK: - Download Estimate - private func downloadRegion() { - guard let region = cameraRegion else { return } - isDownloading = true + private var selectedLayers: Set { + var layers: Set = [.base] + if includeSatellite { layers.insert(.satellite) } + 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: 15, 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 } - // Approximate inset to match the 40pt padding on the selection rectangle - let paddingFraction = 0.15 - let latInset = region.span.latitudeDelta * paddingFraction - let lonInset = region.span.longitudeDelta * paddingFraction - let bounds = MLNCoordinateBounds( + 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) @@ -213,20 +314,165 @@ private struct RegionPickerSheet: View { 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) + try await appState.offlineMapService.downloadRegion( + name: regionName, + bounds: bounds, + layers: layers + ) dismiss() } catch { - OfflineMapService.logger.error("Failed to download region: \(error.localizedDescription)") - dismiss() + showError = error.localizedDescription } } } } +// MARK: - Region Picker Bottom Card + +private struct RegionPickerBottomCard: View { + @Binding var regionName: String + @Binding var includeSatellite: Bool + @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() + + LayerToggles(includeSatellite: $includeSatellite, includeTopo: $includeTopo) + + 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) + } + } +} + +// MARK: - Layer Toggles + +private struct LayerToggles: View { + @Binding var includeSatellite: Bool + @Binding var includeTopo: Bool + + var body: some View { + VStack(alignment: .leading) { + Text(L10n.Settings.OfflineMaps.layers) + .font(.caption) + .foregroundStyle(.secondary) + + Toggle(L10n.Settings.OfflineMaps.Layer.satellite, isOn: $includeSatellite) + Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) + } + .toggleStyle(.switch) + .controlSize(.mini) + } +} + #Preview { NavigationStack { OfflineMapSettingsView() From 61ca8b7f87c1256cb68b1035e1e17e17110630b4 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:51:34 -0700 Subject: [PATCH 20/55] refactor(map): simplify view hierarchy and add north-lock button - Extract CLLocationCoordinate2D+BoundingRegion and ContactDTO+Coordinate extensions - Add pinStyle to ContactType+Display, removing duplicate logic from view models - Extract LabelsToggleButton, NorthLockButton, MapRefreshButton, and CenterAllButton into standalone views - Add isNorthLocked and setCameraRegion to MapViewModel - Remove colorScheme and mapPoints params from MapCanvasView - Delete GlassButtonModifier in favor of liquidGlassSecondaryButtonStyle - Add north-lock button localization strings --- ...LLocationCoordinate2D+BoundingRegion.swift | 30 +++++ MC1/Extensions/ContactDTO+Coordinate.swift | 8 ++ MC1/Extensions/ContactType+Display.swift | 8 ++ .../Localization/de.lproj/Map.strings | 8 +- .../Localization/en.lproj/Map.strings | 8 +- .../Localization/es.lproj/Map.strings | 8 +- .../Localization/fr.lproj/Map.strings | 8 +- .../Localization/nl.lproj/Map.strings | 8 +- .../Localization/pl.lproj/Map.strings | 8 +- .../Localization/ru.lproj/Map.strings | 8 +- .../Localization/uk.lproj/Map.strings | 8 +- .../Localization/zh-Hans.lproj/Map.strings | 6 + MC1/Views/Components/LabelsToggleButton.swift | 19 +++ MC1/Views/Components/MapControlsToolbar.swift | 26 +++- MC1/Views/Contacts/ContactDetailView.swift | 26 +--- MC1/Views/Contacts/DiscoveryView.swift | 4 +- MC1/Views/Contacts/GlassButtonModifier.swift | 11 -- MC1/Views/Map/LayersMenu.swift | 4 +- MC1/Views/Map/MC1MapView+Layers.swift | 113 ++++++++---------- MC1/Views/Map/MC1MapView.swift | 26 +++- MC1/Views/Map/MapCanvasView.swift | 48 +++++--- MC1/Views/Map/MapContentView.swift | 6 +- MC1/Views/Map/MapView.swift | 54 ++++----- MC1/Views/Map/MapViewModel.swift | 68 ++--------- MC1/Views/Map/OfflineBadge.swift | 21 ++-- MC1/Views/Map/PinSpriteRenderer.swift | 32 ++++- MC1/Views/Settings/LocationPickerView.swift | 6 +- MC1/Views/Tools/RxLogView.swift | 4 +- 28 files changed, 346 insertions(+), 238 deletions(-) create mode 100644 MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift create mode 100644 MC1/Extensions/ContactDTO+Coordinate.swift create mode 100644 MC1/Views/Components/LabelsToggleButton.swift delete mode 100644 MC1/Views/Contacts/GlassButtonModifier.swift diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift new file mode 100644 index 000000000..2f7e6cc6b --- /dev/null +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -0,0 +1,30 @@ +import CoreLocation +import MapKit + +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)) + ) + ) + } +} 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 index a175fcb40..99966bd60 100644 --- a/MC1/Extensions/ContactType+Display.swift +++ b/MC1/Extensions/ContactType+Display.swift @@ -17,4 +17,12 @@ extension ContactType { case .room: .purple } } + + var pinStyle: MapPoint.PinStyle { + switch self { + case .chat: .contactChat + case .repeater: .contactRepeater + case .room: .contactRoom + } + } } diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index ddfd12410..e567e5b44 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satellit"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topografie"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index a3045a552..0ae510662 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satellite"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topography"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 4d936f1c1..b28c3467b 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satélite"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topografía"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 451abcea6..b7925864c 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satellite"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topographie"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index c58b31a35..cfd00ca0e 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satelliet"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topografie"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 9e85cbaab..ebb0d9acf 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Satelitarna"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Topo"; +"map.style.topo" = "Topografia"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index d98f2da5a..0cc1c8f85 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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" = "Разблокировать вращение"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Спутниковая"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Топо"; +"map.style.topo" = "Топография"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index fc1112954..b95938464 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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" = "Розблокувати обертання"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ @@ -48,7 +54,7 @@ "map.style.satellite" = "Супутникова"; /* Location: MapStyleSelection.swift - Purpose: Topo map style option */ -"map.style.topo" = "Топо"; +"map.style.topo" = "Топографія"; // MARK: - Contact Detail Sheet diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index 3c3ed3368..251547bfd 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -39,6 +39,12 @@ /* 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" = "解锁旋转"; + // MARK: - Map Style Selection /* Location: MapStyleSelection.swift - Purpose: Standard map style option */ 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 db81f3b13..00865f4e7 100644 --- a/MC1/Views/Components/MapControlsToolbar.swift +++ b/MC1/Views/Components/MapControlsToolbar.swift @@ -2,8 +2,8 @@ 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? @@ -13,11 +13,18 @@ struct MapControlsToolbar: View { /// 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() @@ -70,6 +77,21 @@ struct MapControlsToolbar: View { } } +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 + } +} + // MARK: - Custom Content Stack /// Wraps custom content and inserts dividers before each child view. diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index b603ed5e6..0d970c15c 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -702,21 +702,14 @@ private struct ContactLocationSection: View { let currentContact: ContactDTO - private var contactCoordinate: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: currentContact.latitude, - longitude: currentContact.longitude - ) - } - var body: some View { Section { // Mini map MC1MapView( points: [MapPoint( id: currentContact.id, - coordinate: contactCoordinate, - pinStyle: pinStyle(for: currentContact), + coordinate: currentContact.coordinate, + pinStyle: currentContact.type.pinStyle, label: currentContact.displayName, isClusterable: false, hopIndex: nil, @@ -730,14 +723,13 @@ private struct ContactLocationSection: View { isInteractive: false, showsScale: false, cameraRegion: .constant(MKCoordinateRegion( - center: contactCoordinate, + center: currentContact.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) )), cameraRegionVersion: 1, onPointTap: nil, onMapTap: nil, - onCameraRegionChange: nil, - isStyleLoaded: .constant(true) + onCameraRegionChange: nil ) .frame(height: 200) .clipShape(.rect(cornerRadius: 12)) @@ -770,18 +762,10 @@ 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() } - - private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { - switch contact.type { - case .chat: .contactChat - case .repeater: .contactRepeater - case .room: .contactRoom - } - } } private struct ContactNetworkPathSection: View { diff --git a/MC1/Views/Contacts/DiscoveryView.swift b/MC1/Views/Contacts/DiscoveryView.swift index 884db0300..c833e4058 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/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/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index 826a6c9e7..4bee79de8 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -9,7 +9,9 @@ struct LayersMenu: View { var body: some View { VStack(spacing: 0) { ForEach(MapStyleSelection.allCases, id: \.self) { style in - let isDisabled = style.requiresNetwork && !appState.offlineMapService.isNetworkAvailable + let isDisabled = style.requiresNetwork + && !appState.offlineMapService.isNetworkAvailable + && !appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) Button { selection = style diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 80decffa1..76f50b7a1 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -44,8 +44,15 @@ extension MC1MapView.Coordinator { func updatePointSource(mapView: MLNMapView) { guard let style = mapView.style else { return } - let clusterablePoints = currentPoints.filter(\.isClusterable) - let fixedPoints = currentPoints.filter { !$0.isClusterable } + 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 { @@ -82,7 +89,7 @@ extension MC1MapView.Coordinator { } } - func updateLabelVisibility(mapView: MLNMapView) { + 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 @@ -131,37 +138,13 @@ extension MC1MapView.Coordinator { // Name labels (above pins) with pill background let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND nameLabel != nil") - nameLabelLayer.text = NSExpression(forKeyPath: "nameLabel") - nameLabelLayer.textFontSize = NSExpression(forConstantValue: 10) - nameLabelLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) - nameLabelLayer.textColor = NSExpression(forConstantValue: UIColor.label) - nameLabelLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - nameLabelLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) - nameLabelLayer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) - nameLabelLayer.textAnchor = NSExpression(forConstantValue: "bottom") - nameLabelLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - nameLabelLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - nameLabelLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - nameLabelLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - nameLabelLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") - nameLabelLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - nameLabelLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) + 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") - badgeLayer.text = NSExpression(forKeyPath: "badgeText") - badgeLayer.textFontSize = NSExpression(forConstantValue: 11) - badgeLayer.textFontNames = mapFontNames - badgeLayer.textColor = NSExpression(forConstantValue: UIColor.label) - badgeLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - badgeLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) - badgeLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - badgeLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - badgeLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") - badgeLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - badgeLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + configureBadgeLayer(badgeLayer) style.addLayer(badgeLayer) } @@ -178,36 +161,12 @@ extension MC1MapView.Coordinator { let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) fixedNameLayer.predicate = NSPredicate(format: "nameLabel != nil") - fixedNameLayer.text = NSExpression(forKeyPath: "nameLabel") - fixedNameLayer.textFontSize = NSExpression(forConstantValue: 10) - fixedNameLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) - fixedNameLayer.textColor = NSExpression(forConstantValue: UIColor.label) - fixedNameLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - fixedNameLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) - fixedNameLayer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) - fixedNameLayer.textAnchor = NSExpression(forConstantValue: "bottom") - fixedNameLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - fixedNameLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - fixedNameLayer.iconAllowsOverlap = NSExpression(forConstantValue: true) - fixedNameLayer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - fixedNameLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") - fixedNameLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - fixedNameLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) + configureNameLabelLayer(fixedNameLayer) style.addLayer(fixedNameLayer) let fixedBadgeLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedBadgeText, source: source) fixedBadgeLayer.predicate = NSPredicate(format: "badgeText != nil") - fixedBadgeLayer.text = NSExpression(forKeyPath: "badgeText") - fixedBadgeLayer.textFontSize = NSExpression(forConstantValue: 11) - fixedBadgeLayer.textFontNames = mapFontNames - fixedBadgeLayer.textColor = NSExpression(forConstantValue: UIColor.label) - fixedBadgeLayer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - fixedBadgeLayer.textHaloWidth = NSExpression(forConstantValue: 0.5) - fixedBadgeLayer.textAllowsOverlap = NSExpression(forConstantValue: true) - fixedBadgeLayer.textIgnoresPlacement = NSExpression(forConstantValue: true) - fixedBadgeLayer.iconImageName = NSExpression(forConstantValue: "pill-bg") - fixedBadgeLayer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - fixedBadgeLayer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8))) + configureBadgeLayer(fixedBadgeLayer) style.addLayer(fixedBadgeLayer) } @@ -219,7 +178,7 @@ extension MC1MapView.Coordinator { style.addSource(source) let losLayer = MLNLineStyleLayer(identifier: MapLayerID.lineLOS, source: source) - losLayer.predicate = NSPredicate(format: "lineStyle == 'los'") + 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]) @@ -227,28 +186,28 @@ extension MC1MapView.Coordinator { style.addLayer(losLayer) let untracedLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceUntraced, source: source) - untracedLayer.predicate = NSPredicate(format: "lineStyle == 'traceUntraced'") + untracedLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceUntraced.rawValue) untracedLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGray) untracedLayer.lineWidth = NSExpression(forConstantValue: 2) untracedLayer.lineDashPattern = NSExpression(forConstantValue: [8, 6]) style.addLayer(untracedLayer) let weakLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceWeak, source: source) - weakLayer.predicate = NSPredicate(format: "lineStyle == 'traceWeak'") + weakLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceWeak.rawValue) weakLayer.lineColor = NSExpression(forConstantValue: UIColor.systemRed) weakLayer.lineWidth = NSExpression(forConstantValue: 3) weakLayer.lineDashPattern = NSExpression(forConstantValue: [4, 4]) style.addLayer(weakLayer) let mediumLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceMedium, source: source) - mediumLayer.predicate = NSPredicate(format: "lineStyle == 'traceMedium'") + mediumLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceMedium.rawValue) mediumLayer.lineColor = NSExpression(forConstantValue: UIColor.systemYellow) mediumLayer.lineWidth = NSExpression(forConstantValue: 3) mediumLayer.lineDashPattern = NSExpression(forConstantValue: [12, 4]) style.addLayer(mediumLayer) let goodLayer = MLNLineStyleLayer(identifier: MapLayerID.lineTraceGood, source: source) - goodLayer.predicate = NSPredicate(format: "lineStyle == 'traceGood'") + goodLayer.predicate = NSPredicate(format: "lineStyle == %@", MapLine.LineStyle.traceGood.rawValue) goodLayer.lineColor = NSExpression(forConstantValue: UIColor.systemGreen) goodLayer.lineWidth = NSExpression(forConstantValue: 4) style.addLayer(goodLayer) @@ -313,6 +272,40 @@ extension MC1MapView.Coordinator { style.layer(withIdentifier: MapLayerID.topoLayer)?.isVisible = currentMapStyle == .topo } + // MARK: - Shared layer configuration + + private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "nameLabel") + layer.textFontSize = NSExpression(forConstantValue: 10) + layer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) + layer.textColor = NSExpression(forConstantValue: UIColor.label) + layer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + layer.textHaloWidth = NSExpression(forConstantValue: 0.5) + layer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) + layer.textAnchor = NSExpression(forConstantValue: "bottom") + layer.textAllowsOverlap = NSExpression(forConstantValue: true) + layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconAllowsOverlap = NSExpression(forConstantValue: true) + layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) + layer.iconImageName = NSExpression(forConstantValue: "pill-bg") + layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) + layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) + } + + private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { + layer.text = NSExpression(forKeyPath: "badgeText") + layer.textFontSize = NSExpression(forConstantValue: 11) + layer.textFontNames = mapFontNames + layer.textColor = NSExpression(forConstantValue: UIColor.label) + layer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) + layer.textHaloWidth = NSExpression(forConstantValue: 0.5) + 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 { diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 1d871f2a7..8b3274cd6 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -140,6 +140,7 @@ struct MC1MapView: UIViewRepresentable { let showsUserLocation: Bool let isInteractive: Bool let showsScale: Bool + var isNorthLocked: Bool = false // Camera @Binding var cameraRegion: MKCoordinateRegion? @@ -153,7 +154,7 @@ struct MC1MapView: UIViewRepresentable { let onCameraRegionChange: ((MKCoordinateRegion) -> Void)? // Optional features - @Binding var isStyleLoaded: Bool + var isStyleLoaded: Binding = .constant(true) func makeCoordinator() -> Coordinator { Coordinator() @@ -206,8 +207,7 @@ struct MC1MapView: UIViewRepresentable { coordinator.onPointTap = onPointTap coordinator.onMapTap = onMapTap coordinator.onCameraRegionChange = onCameraRegionChange - coordinator.setIsStyleLoaded = { isStyleLoaded = $0 } - coordinator.showLabels = showLabels + coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } let pointsChanged = coordinator.currentPoints != points let linesChanged = coordinator.currentLines != lines coordinator.currentPoints = points @@ -225,7 +225,17 @@ struct MC1MapView: UIViewRepresentable { coordinator.currentMapStyle = mapStyle // User location - mapView.showsUserLocation = showsUserLocation + 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, data changed, and not mid-gesture) if coordinator.isStyleLoaded, !coordinator.isUserInteracting { @@ -240,7 +250,7 @@ struct MC1MapView: UIViewRepresentable { } if coordinator.currentShowLabels != showLabels { coordinator.currentShowLabels = showLabels - coordinator.updateLabelVisibility(mapView: mapView) + coordinator.updateLabelVisibility(mapView: mapView, showLabels: showLabels) } } @@ -329,7 +339,6 @@ extension MC1MapView { var isStyleLoaded = false var lastAppliedRegionVersion = 0 var pendingRegionTask: Task? - var showLabels = true var currentShowLabels = true var lastAppliedStyleURL: URL? var currentMapStyle: MapStyleSelection? @@ -357,6 +366,11 @@ extension MC1MapView { } func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { + if let style = mapView.style { + if PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return nil + } + } logger.error("didFailToLoadImage: \(imageName)") return nil } diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index f9fc9ca73..62fcf90f3 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -5,8 +5,6 @@ import MC1Services struct MapCanvasView: View { @Environment(\.appState) private var appState @Bindable var viewModel: MapViewModel - let mapPoints: [MapPoint] - let colorScheme: ColorScheme @Binding var selectedCalloutContact: ContactDTO? @Binding var selectedPointScreenPosition: CGPoint? @Binding var isStyleLoaded: Bool @@ -19,8 +17,6 @@ struct MapCanvasView: View { ZStack { MapContentView( viewModel: viewModel, - colorScheme: colorScheme, - mapPoints: mapPoints, selectedCalloutContact: $selectedCalloutContact, selectedPointScreenPosition: $selectedPointScreenPosition, isStyleLoaded: $isStyleLoaded, @@ -75,39 +71,61 @@ struct MapCanvasView: View { Spacer() MapControlsToolbar( onLocationTap: { onCenterOnUser() }, - showingLayersMenu: $viewModel.showingLayersMenu + showingLayersMenu: $viewModel.showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) + } ) { - labelsToggleButton - centerAllButton + LabelsToggleButton(showLabels: $viewModel.showLabels) + CenterAllButton( + isEmpty: viewModel.contactsWithLocation.isEmpty, + onClearSelection: onClearSelection, + onCenterAll: { viewModel.centerOnAllContacts() } + ) } } } +} + +// MARK: - Control Buttons - private var labelsToggleButton: some View { - Button(viewModel.showLabels ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { +private 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 { - viewModel.showLabels.toggle() + isNorthLocked.toggle() } } .font(.body.weight(.medium)) - .foregroundStyle(viewModel.showLabels ? .blue : .primary) + .foregroundStyle(isNorthLocked ? .blue : .primary) .frame(width: 44, height: 44) .contentShape(.rect) .buttonStyle(.plain) .labelStyle(.iconOnly) } +} + +private struct CenterAllButton: View { + let isEmpty: Bool + let onClearSelection: () -> Void + let onCenterAll: () -> Void - private var centerAllButton: some View { + var body: some View { Button(L10n.Map.Map.Controls.centerAll, systemImage: "arrow.up.left.and.arrow.down.right") { onClearSelection() - viewModel.centerOnAllContacts() + onCenterAll() } .font(.body.weight(.medium)) - .foregroundStyle(viewModel.contactsWithLocation.isEmpty ? .secondary : .primary) + .foregroundStyle(isEmpty ? .secondary : .primary) .frame(width: 44, height: 44) .contentShape(.rect) .buttonStyle(.plain) - .disabled(viewModel.contactsWithLocation.isEmpty) + .disabled(isEmpty) .labelStyle(.iconOnly) } } diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 9708a2c12..37b25b382 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -4,9 +4,8 @@ import MC1Services /// Map content displaying MC1MapView with contact points and popover callouts struct MapContentView: View { + @Environment(\.colorScheme) private var colorScheme @Bindable var viewModel: MapViewModel - let colorScheme: ColorScheme - let mapPoints: [MapPoint] @Binding var selectedCalloutContact: ContactDTO? @Binding var selectedPointScreenPosition: CGPoint? @Binding var isStyleLoaded: Bool @@ -18,7 +17,7 @@ struct MapContentView: View { emptyState } else { MC1MapView( - points: mapPoints, + points: viewModel.mapPoints, lines: [], mapStyle: viewModel.mapStyleSelection, isDarkMode: colorScheme == .dark, @@ -26,6 +25,7 @@ struct MapContentView: View { showsUserLocation: true, isInteractive: true, showsScale: true, + isNorthLocked: viewModel.isNorthLocked, cameraRegion: $viewModel.cameraRegion, cameraRegionVersion: viewModel.cameraRegionVersion, onPointTap: { point, screenPosition in diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 833cf7012..345c6e19f 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -5,7 +5,6 @@ import MC1Services /// Map view displaying contacts with their locations struct MapView: View { @Environment(\.appState) private var appState - @Environment(\.colorScheme) private var colorScheme @State private var viewModel = MapViewModel() @State private var selectedCalloutContact: ContactDTO? @State private var selectedPointScreenPosition: CGPoint? @@ -16,8 +15,6 @@ struct MapView: View { NavigationStack { MapCanvasView( viewModel: viewModel, - mapPoints: viewModel.mapPoints, - colorScheme: colorScheme, selectedCalloutContact: $selectedCalloutContact, selectedPointScreenPosition: $selectedPointScreenPosition, isStyleLoaded: $isStyleLoaded, @@ -31,7 +28,7 @@ struct MapView: View { BLEStatusIndicatorView() } ToolbarItem(placement: .topBarTrailing) { - refreshButton + MapRefreshButton(viewModel: viewModel) } } .task { @@ -52,23 +49,6 @@ struct MapView: View { } } - // MARK: - Refresh Button - - private var refreshButton: some View { - Button { - Task { - await viewModel.loadContactsWithLocation() - } - } label: { - if viewModel.isLoading { - ProgressView() - } else { - Image(systemName: "arrow.clockwise") - } - } - .disabled(viewModel.isLoading) - } - // MARK: - Actions private func clearSelection() { @@ -82,27 +62,41 @@ struct MapView: View { } private func showContactDetail(_ contact: ContactDTO) { - selectedCalloutContact = nil - selectedPointScreenPosition = nil + clearSelection() selectedContactForDetail = contact } private func centerOnUserLocation() { guard let location = appState.locationService.currentLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) - viewModel.cameraRegion = MKCoordinateRegion(center: location.coordinate, span: span) - viewModel.cameraRegionVersion += 1 + viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) } } -// MARK: - Preview +// MARK: - Map Refresh Button -#Preview("Map with Contacts") { - MapView() - .environment(\.appState, AppState()) +private struct MapRefreshButton: View { + var viewModel: MapViewModel + + var body: some View { + Button { + Task { + await viewModel.loadContactsWithLocation() + } + } label: { + if viewModel.isLoading { + ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(viewModel.isLoading) + } } -#Preview("Empty Map") { +// MARK: - Preview + +#Preview { MapView() .environment(\.appState, AppState()) } diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index b9ddf9766..4d623e282 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -25,7 +25,7 @@ final class MapViewModel { var cameraRegion: MKCoordinateRegion? /// Version counter for the camera region, incremented to signal a new camera target - var cameraRegionVersion = 1 + private(set) var cameraRegionVersion = 0 /// Current map style selection var mapStyleSelection: MapStyleSelection = .standard @@ -33,6 +33,9 @@ final class MapViewModel { /// 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 @@ -84,7 +87,7 @@ final class MapViewModel { MapPoint( id: contact.id, coordinate: contact.coordinate, - pinStyle: pinStyle(for: contact), + pinStyle: contact.type.pinStyle, label: contact.displayName, isClusterable: true, hopIndex: nil, @@ -93,29 +96,20 @@ final class MapViewModel { } } - private func pinStyle(for contact: ContactDTO) -> MapPoint.PinStyle { - switch contact.type { - case .chat: .contactChat - case .repeater: .contactRepeater - case .room: .contactRoom - } - } - // 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) - cameraRegionVersion += 1 + setCameraRegion(MKCoordinateRegion(center: contact.coordinate, span: span)) } /// Center map to show all contacts @@ -125,43 +119,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) - cameraRegionVersion += 1 - } -} - -// 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 index ea89ed492..e9d6d4e14 100644 --- a/MC1/Views/Map/OfflineBadge.swift +++ b/MC1/Views/Map/OfflineBadge.swift @@ -4,18 +4,13 @@ import SwiftUI struct OfflineBadge: View { var body: some View { - VStack { - HStack { - Text(L10n.Map.Map.OfflineBadge.label) - .font(.caption) - .bold() - .padding(.horizontal) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: .capsule) - Spacer() - } - .padding(.leading) - Spacer() - } + Text(L10n.Map.Map.OfflineBadge.label) + .font(.caption) + .bold() + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) } } diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 762abb204..db385d2bf 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -8,6 +8,8 @@ enum PinSpriteRenderer { private nonisolated(unsafe) 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) { let images: [String: UIImage] if let cached = cachedImages { @@ -20,11 +22,6 @@ enum PinSpriteRenderer { rendered["pin-badge"] = UIGraphicsImageRenderer( size: CGSize(width: 1, height: 1), format: .preferred() ).image { _ in } - if let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) { - for hop in 1...20 { - rendered["pin-repeater-ring-white-hop-\(hop)"] = render(ringWhiteSpec, hopIndex: hop) - } - } rendered["pill-bg"] = renderPillBackground() cachedImages = rendered images = rendered @@ -35,6 +32,31 @@ enum PinSpriteRenderer { } } + /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. + /// Returns `true` if the name was recognized and the image was registered. + @discardableResult + static func renderOnDemand(name: String, into style: MLNStyle) -> Bool { + guard name.hasPrefix("pin-repeater-ring-white-hop-") else { return false } + + // Check the cache first (may have been rendered for a different style load) + if let cached = cachedImages?[name] { + style.setImage(cached, forName: name) + return true + } + + 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 false + } + + let image = render(ringWhiteSpec, hopIndex: hop) + cachedImages?[name] = image + style.setImage(image, forName: name) + return true + } + // MARK: - Sprite specifications private struct SpriteSpec { diff --git a/MC1/Views/Settings/LocationPickerView.swift b/MC1/Views/Settings/LocationPickerView.swift index 9840424cf..523d0ec87 100644 --- a/MC1/Views/Settings/LocationPickerView.swift +++ b/MC1/Views/Settings/LocationPickerView.swift @@ -20,7 +20,6 @@ struct LocationPickerView: View { @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? @@ -49,8 +48,7 @@ struct LocationPickerView: View { cameraRegionVersion: cameraRegionVersion, onPointTap: nil, onMapTap: { coord in selectedCoordinate = coord }, - onCameraRegionChange: { region in visibleRegion = region }, - isStyleLoaded: .constant(true) + onCameraRegionChange: { region in cameraRegion = region } ) // Center crosshair for precise placement @@ -172,7 +170,7 @@ struct LocationPickerView: View { } private func dropPinAtCenter() { - if let region = visibleRegion { + if let region = cameraRegion { selectedCoordinate = region.center } } 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() From 9c96097264dc6d4534ee6c8c2994546b07c3f7f7 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:51:43 -0700 Subject: [PATCH 21/55] refactor(los): move display state to view model and extract subviews - Move showLabels, mapPoints, and mapLines to LineOfSightViewModel - Add didSet observers to rebuild map state on point/repeater changes - Extract AnalyzeButton into a standalone view - Extract HeightEditorGrid to deduplicate height editor layouts - Simplify PointHeightEditorView, RepeaterHeightEditorView, and RepeaterRowView --- MC1/Views/LineOfSight/HeightEditorGrid.swift | 73 +++++++ MC1/Views/LineOfSight/LineOfSightView.swift | 176 +++++------------ .../LineOfSight/LineOfSightViewModel.swift | 184 +++++++++++++----- .../LineOfSight/PointHeightEditorView.swift | 68 +------ .../LineOfSight/PointRowButtonsView.swift | 16 +- .../RepeaterHeightEditorView.swift | 60 +----- MC1/Views/LineOfSight/RepeaterRowView.swift | 88 +-------- 7 files changed, 295 insertions(+), 370 deletions(-) create mode 100644 MC1/Views/LineOfSight/HeightEditorGrid.swift diff --git a/MC1/Views/LineOfSight/HeightEditorGrid.swift b/MC1/Views/LineOfSight/HeightEditorGrid.swift new file mode 100644 index 000000000..8a1019182 --- /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("\(Int(groundElevation)) m") + .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("\(additionalHeight) m") + .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("\(Int(groundElevation) + additionalHeight) m") + .font(.caption) + .monospacedDigit() + .bold() + } + } + } + } +} diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 031660d5b..3633741c7 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -25,7 +25,6 @@ struct LineOfSightView: View { @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 private let layoutMode: LineOfSightLayoutMode @@ -93,7 +92,7 @@ struct LineOfSightView: View { appState: appState, mapStyleSelection: $mapStyleSelection, showingMapStyleMenu: $showingMapStyleMenu, - showLabels: $showLabels, + showLabels: $viewModel.showLabels, isDropPinMode: $isDropPinMode, mapOverlayBottomPadding: mapOverlayBottomPadding, cameraBottomSheetFraction: showSheet ? 0.25 : 0, @@ -303,33 +302,13 @@ struct LineOfSightView: View { // 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 } } - } - .liquidGlassProminentButtonStyle() - .controlSize(.large) - .disabled(viewModel.isAnalyzing || hasAnalysisResult) + ) } // MARK: - Result Summary Section @@ -429,8 +408,8 @@ private struct LOSMapCanvasView: View { var body: some View { ZStack { MC1MapView( - points: mapPoints, - lines: mapLines, + points: viewModel.mapPoints, + lines: viewModel.mapLines, mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, showLabels: showLabels, @@ -449,7 +428,6 @@ private struct LOSMapCanvasView: View { onCameraRegionChange: { region in viewModel.cameraRegion = region }, - isStyleLoaded: .constant(true) ) .ignoresSafeArea() @@ -461,25 +439,16 @@ 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 ? L10n.Map.Map.Controls.hideLabels : L10n.Map.Map.Controls.showLabels, systemImage: "character.textbox") { - showLabels.toggle() - } - .font(.body.weight(.medium)) - .foregroundStyle(showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) + LabelsToggleButton(showLabels: $showLabels) Button(isDropPinMode ? L10n.Tools.Tools.LineOfSight.cancelDropPin : L10n.Tools.Tools.LineOfSight.dropPin, systemImage: isDropPinMode ? "mappin.slash" : "mappin") { isDropPinMode.toggle() @@ -499,7 +468,7 @@ private struct LOSMapCanvasView: View { Button { withAnimation { showingMapStyleMenu = false } } label: { - Color.primary.opacity(0.3).ignoresSafeArea() + Color.black.opacity(0.3).ignoresSafeArea() } .buttonStyle(.plain) @@ -519,90 +488,6 @@ private struct LOSMapCanvasView: View { } } - // MARK: - Map Data - - private var mapPoints: [MapPoint] { - var points: [MapPoint] = [] - - let selectionState = viewModel.selectionState - for repeater in viewModel.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 = viewModel.pointA, pointA.contact == nil { - points.append(MapPoint( - id: viewModel.pointAMapID, - coordinate: pointA.coordinate, - pinStyle: .pointA, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } - - if let pointB = viewModel.pointB, pointB.contact == nil { - points.append(MapPoint( - id: viewModel.pointBMapID, - coordinate: pointB.coordinate, - pinStyle: .pointB, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } - - if let target = viewModel.repeaterPoint { - points.append(MapPoint( - id: viewModel.repeaterTargetMapID, - coordinate: target.coordinate, - pinStyle: .crosshair, - label: nil, - isClusterable: false, - hopIndex: nil, - badgeText: nil - )) - } - - return points - } - - private var mapLines: [MapLine] { - guard let a = viewModel.pointA?.coordinate, - let b = viewModel.pointB?.coordinate else { return [] } - - let activeOpacity = 0.7 - let dimOpacity = 0.3 - - if let r = viewModel.repeaterPoint?.coordinate { - let opacityAR = viewModel.relocatingPoint == .pointA ? dimOpacity : activeOpacity - let opacityRB = viewModel.relocatingPoint == .pointB ? dimOpacity : activeOpacity - return [ - MapLine(id: "los-ar", coordinates: [a, r], style: .los, - opacity: viewModel.relocatingPoint == .repeater ? dimOpacity : opacityAR), - MapLine(id: "los-rb", coordinates: [r, b], style: .los, - opacity: viewModel.relocatingPoint == .repeater ? dimOpacity : opacityRB) - ] - } else { - let opacity = viewModel.relocatingPoint != nil ? dimOpacity : activeOpacity - return [MapLine(id: "los-ab", coordinates: [a, b], style: .los, opacity: opacity)] - } - } } // MARK: - Frequency Input Row @@ -671,6 +556,41 @@ struct FrequencyInputRow: View { } } +// MARK: - Analyze Button + +private struct AnalyzeButton: View { + var viewModel: LineOfSightViewModel + let hasAnalysisResult: Bool + let onAnalyze: () -> Void + + 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 || hasAnalysisResult) + } +} + // MARK: - Preview #Preview("Empty") { diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 0f2805434..247ba888f 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -128,15 +128,21 @@ final class LineOfSightViewModel { // 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 @@ -157,14 +163,29 @@ final class LineOfSightViewModel { reanalyzeWithCachedProfileIfNeeded() } + // MARK: - Map Display State + + 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 { @@ -266,9 +287,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) @@ -286,15 +308,102 @@ 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) } @@ -303,29 +412,16 @@ final class LineOfSightViewModel { setCameraRegion(fitting: [pointA.coordinate, pointB.coordinate]) } - private func setCameraRegion( - fitting coordinates: [CLLocationCoordinate2D], - paddingMultiplier: Double = 1.5 - ) { - 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) - - cameraRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: ((lats.min()! + lats.max()!) / 2).clamped(to: -90...90), - longitude: (lons.min()! + lons.max()!) / 2 - ), - span: MKCoordinateSpan( - latitudeDelta: min(latDelta, 180), - longitudeDelta: min(lonDelta, 360) - ) - ) + 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. @@ -483,7 +579,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 { @@ -685,9 +781,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( @@ -695,7 +789,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) @@ -824,24 +922,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, @@ -854,7 +944,6 @@ final class LineOfSightViewModel { if Task.isCancelled { return } - // Update state on MainActor elevationProfile = profile profileSamples = FresnelZoneRenderer.buildProfileSamples( from: profile, @@ -930,7 +1019,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/PointHeightEditorView.swift b/MC1/Views/LineOfSight/PointHeightEditorView.swift index 3a15283ba..064200342 100644 --- a/MC1/Views/LineOfSight/PointHeightEditorView.swift +++ b/MC1/Views/LineOfSight/PointHeightEditorView.swift @@ -6,65 +6,13 @@ struct PointHeightEditorView: View { let pointID: PointID var body: 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() - } - } - } + 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 index 4cc077bed..111d701d6 100644 --- a/MC1/Views/LineOfSight/PointRowButtonsView.swift +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -13,17 +13,25 @@ struct PointRowButtonsView: View { @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 - private var point: SelectedPoint? { - pointID == .pointA ? viewModel.pointA : viewModel.pointB + 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 = point?.coordinate { + if let coord = 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.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() } diff --git a/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift index acc1d5a42..3a68f1747 100644 --- a/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift +++ b/MC1/Views/LineOfSight/RepeaterHeightEditorView.swift @@ -5,56 +5,14 @@ struct RepeaterHeightEditorView: View { let repeaterPoint: RepeaterPoint var body: 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() - } - } - - GridRow { - Text(L10n.Tools.Tools.LineOfSight.additionalHeight) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Stepper( - value: Binding( - get: { repeaterPoint.additionalHeight }, - set: { - viewModel.updateRepeaterHeight(meters: $0) - viewModel.analyzeWithRepeater() - } - ), - 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() - } - } - } + 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 index 01986ded1..379a120d7 100644 --- a/MC1/Views/LineOfSight/RepeaterRowView.swift +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -1,5 +1,3 @@ -import CoreLocation -import MapKit import SwiftUI struct RepeaterRowView: View { @@ -8,8 +6,6 @@ struct RepeaterRowView: View { @Binding var editingPoint: PointID? let onRelocate: () -> Void - @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 - private var isEditing: Bool { editingPoint == .repeater } var body: some View { @@ -42,81 +38,15 @@ struct RepeaterRowView: View { 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 - 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 == .repeater { - viewModel.relocatingPoint = nil - } else { - viewModel.relocatingPoint = .repeater - 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 != .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) - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) - - // Clear button - Button { - viewModel.clearRepeater() - } label: { - Label(L10n.Tools.Tools.LineOfSight.clear, systemImage: "xmark") - .labelStyle(.iconOnly) - .frame(width: iconButtonSize, height: iconButtonSize) - } - .liquidGlassSecondaryButtonStyle() - .controlSize(.small) + PointRowButtonsView( + viewModel: viewModel, + pointID: .repeater, + isEditing: isEditing, + copyHapticTrigger: $copyHapticTrigger, + editingPoint: $editingPoint, + onRelocate: onRelocate, + onClear: { viewModel.clearRepeater() } + ) } // Expanded editor From 68461a9db16c6e1f1f43495f64f4844fb2381583 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:51:51 -0700 Subject: [PATCH 22/55] refactor(trace-path): cache computed state and rebuild selectively - Convert pathState from computed property to stored, rebuilt via rebuildPathState() - Add stored mapPoints rebuilt via rebuildMapPoints() - Make cameraRegionVersion private(set) with increment method - Trigger selective rebuilds via didSet observers on showLabels and repeatersWithLocation --- .../AvailableRepeatersSectionView.swift | 7 +- .../TracePathMapToolbarView.swift | 16 +- .../TracePathMap/TracePathMapView.swift | 27 +-- .../TracePathMap/TracePathMapViewModel.swift | 155 ++++++++---------- 4 files changed, 80 insertions(+), 125 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift index 2ed830812..d8292f339 100644 --- a/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift +++ b/MC1/Views/Contacts/TracePathMap/AvailableRepeatersSectionView.swift @@ -37,6 +37,7 @@ struct AvailableRepeatersSectionView: View { } var body: some View { + let nodes = filteredNodes Section { DisclosureGroup(isExpanded: $isRepeatersExpanded) { Toggle(L10n.Contacts.Contacts.Trace.List.favoritesOnly, isOn: $showOnlyFavorites) @@ -45,7 +46,7 @@ struct AvailableRepeatersSectionView: View { Toggle(L10n.Contacts.Contacts.Trace.List.includeDiscovered, isOn: $includeDiscovered) } - if filteredNodes.isEmpty { + if nodes.isEmpty { if showOnlyFavorites { ContentUnavailableView( L10n.Contacts.Contacts.Trace.List.NoFavorites.title, @@ -60,7 +61,7 @@ struct AvailableRepeatersSectionView: View { ) } } else { - ForEach(filteredNodes) { node in + ForEach(nodes) { node in Button { recentlyAddedRepeaterID = node.id addHapticTrigger += 1 @@ -98,7 +99,7 @@ struct AvailableRepeatersSectionView: View { HStack { Text(L10n.Contacts.Contacts.Trace.List.repeaters) Spacer() - Text("\(filteredNodes.count)") + Text("\(nodes.count)") .foregroundStyle(.secondary) } } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift index bb2f990f4..d0b718b08 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -15,27 +15,17 @@ struct TracePathMapToolbarView: View { MapControlsToolbar( onLocationTap: { if let location = appState.locationService.currentLocation { - mapViewModel.cameraRegion = MKCoordinateRegion( + mapViewModel.setCameraRegion(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 ? L10n.Contacts.Contacts.Trace.Map.hideLabels : L10n.Contacts.Contacts.Trace.Map.showLabels, systemImage: "character.textbox") { - mapViewModel.showLabels.toggle() - } - .font(.body.weight(.medium)) - .foregroundStyle(mapViewModel.showLabels ? .blue : .primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - .buttonStyle(.plain) - .labelStyle(.iconOnly) + LabelsToggleButton(showLabels: $mapViewModel.showLabels) // Center on path if mapViewModel.hasPath { diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 09acfb921..c653efac1 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -60,6 +60,7 @@ struct TracePathMapView: View { mapViewModel.updateUserLocation(newLocation) } .onChange(of: traceViewModel.availableNodes) { _, _ in + mapViewModel.rebuildPathState() if !mapViewModel.hasInitiallyCenteredOnRepeaters && !mapViewModel.repeatersWithLocation.isEmpty { mapViewModel.performInitialCentering() } @@ -102,32 +103,9 @@ struct TracePathMapView: View { // MARK: - Map Content - private var mapPoints: [MapPoint] { - var points: [MapPoint] = [] - let pathState = mapViewModel.pathState - - for repeater in mapViewModel.repeatersWithLocation { - let info = pathState[repeater.id] - let inPath = info?.inPath ?? false - let style: MapPoint.PinStyle = inPath ? .repeaterRingWhite : .repeater - points.append(MapPoint( - id: repeater.id, - coordinate: repeater.coordinate, - pinStyle: style, - label: mapViewModel.showLabels ? repeater.displayName : nil, - isClusterable: !inPath, - hopIndex: info?.hopIndex, - badgeText: nil - )) - } - - points.append(contentsOf: mapViewModel.badgePoints) - return points - } - private var mapContent: some View { MC1MapView( - points: mapPoints, + points: mapViewModel.mapPoints, lines: mapViewModel.mapLines, mapStyle: mapViewModel.mapStyleSelection, isDarkMode: colorScheme == .dark, @@ -151,7 +129,6 @@ struct TracePathMapView: View { onCameraRegionChange: { region in mapViewModel.cameraRegion = region }, - isStyleLoaded: .constant(true) ) .ignoresSafeArea() } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index b1c267f07..55215fe49 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -14,9 +14,11 @@ final class TracePathMapViewModel { var cameraRegion: MKCoordinateRegion? /// Incremented when code intentionally moves the camera (not from user gesture sync) - var cameraRegionVersion = 0 + private(set) var cameraRegionVersion = 0 var mapStyleSelection: MapStyleSelection = .standard - var showLabels: Bool = true + var showLabels: Bool = true { + didSet { rebuildMapPoints() } + } var showingLayersMenu: Bool = false /// Tracks whether initial centering on repeaters has been performed @@ -26,6 +28,7 @@ final class TracePathMapViewModel { private(set) var mapLines: [MapLine] = [] private(set) var badgePoints: [MapPoint] = [] + private(set) var mapPoints: [MapPoint] = [] // MARK: - Dependencies @@ -42,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,29 +97,59 @@ final class TracePathMapViewModel { 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: !inPath, + 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 { @@ -156,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 } @@ -180,6 +186,7 @@ final class TracePathMapViewModel { func clearPath() { traceViewModel?.clearPath() clearOverlays() + rebuildPathState() } // MARK: - Trace Execution @@ -203,6 +210,7 @@ final class TracePathMapViewModel { /// Rebuild map lines based on current path func rebuildOverlays() { clearOverlays() + rebuildPathState() guard let traceViewModel, !traceViewModel.outboundPath.isEmpty else { return } @@ -269,9 +277,8 @@ final class TracePathMapViewModel { .formatted(.measurement(width: .abbreviated, usage: .road)) let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) - // swiftlint:disable:next force_unwrapping badgePoints.append(MapPoint( - id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", index))")!, + id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", index))") ?? UUID(), coordinate: mid, pinStyle: .badge, label: nil, @@ -286,6 +293,7 @@ final class TracePathMapViewModel { } mapLines = updatedLines + rebuildMapPoints() } // MARK: - Signal Quality @@ -294,8 +302,9 @@ final class TracePathMapViewModel { case untraced, weak, medium, good } + /// 3-tier scale with wider thresholds (±5 dB) matching SNRQuality doc convention for path segments private func signalQuality(snr: Double) -> SignalQuality { - if snr <= 0 { return .weak } + if snr < -5 { return .weak } if snr < 5 { return .medium } return .good } @@ -340,9 +349,7 @@ final class TracePathMapViewModel { return } - let coordinates = repeaters.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } + let coordinates = repeaters.map(\.coordinate) setCameraRegion(fitting: coordinates) hasInitiallyCenteredOnRepeaters = true } @@ -394,33 +401,13 @@ final class TracePathMapViewModel { hasInitiallyCenteredOnRepeaters = true } - /// Compute a bounding region for the given coordinates and update the camera. - private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { - guard !coordinates.isEmpty else { return } - - var minLat = coordinates[0].latitude - var maxLat = coordinates[0].latitude - var minLon = coordinates[0].longitude - var maxLon = coordinates[0].longitude - - for coord in coordinates { - minLat = min(minLat, coord.latitude) - maxLat = max(maxLat, coord.latitude) - minLon = min(minLon, coord.longitude) - maxLon = max(maxLon, coord.longitude) - } - - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - - let span = MKCoordinateSpan( - latitudeDelta: min(180, max(0.01, (maxLat - minLat) * 1.5)), - longitudeDelta: min(360, max(0.01, (maxLon - minLon) * 1.5)) - ) - - cameraRegion = MKCoordinateRegion(center: center, span: span) + func setCameraRegion(_ region: MKCoordinateRegion) { + cameraRegion = region cameraRegionVersion += 1 } + + private func setCameraRegion(fitting coordinates: [CLLocationCoordinate2D]) { + guard let region = coordinates.boundingRegion() else { return } + setCameraRegion(region) + } } From bb46490ccd9e708dfe486534cd0bc8e395e0a4f8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:54:36 -0700 Subject: [PATCH 23/55] ui(offline-maps): remove section title, rename complete to downloaded - Remove "Downloaded" section header from packs list - Change pack status label from "Complete" to "Downloaded" - Remove unused offlineMaps.downloaded localization key --- MC1/Resources/Generated/L10n.swift | 4 +--- MC1/Resources/Localization/de.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/en.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/es.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/fr.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/nl.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/pl.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/ru.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/uk.lproj/Settings.strings | 4 +--- MC1/Resources/Localization/zh-Hans.lproj/Settings.strings | 4 +--- MC1/Views/Settings/OfflineMapSettingsView.swift | 2 -- 11 files changed, 10 insertions(+), 32 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 7a5691a3f..36298e0d7 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3713,7 +3713,7 @@ public enum L10n { /// 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: "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 @@ -3722,8 +3722,6 @@ public enum L10n { 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") - /// Section header for downloaded maps list - public static let downloaded = L10n.tr("Settings", "offlineMaps.downloaded", fallback: "Downloaded") /// 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 diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 073bab227..7c12fed1f 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Region herunterladen"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Heruntergeladen"; /* Section header for storage info */ "offlineMaps.storage" = "Speicher"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Unbekannte Region"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Abgeschlossen"; +"offlineMaps.complete" = "Heruntergeladen"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Wird heruntergeladen…"; diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index 995100916..2daa10184 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1284,8 +1284,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Download Region"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Downloaded"; /* Section header for storage info */ "offlineMaps.storage" = "Storage"; @@ -1297,7 +1295,7 @@ "offlineMaps.unknownRegion" = "Unknown Region"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Complete"; +"offlineMaps.complete" = "Downloaded"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Downloading…"; diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index d9f982a31..cb32e4f0e 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Descargar región"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Descargados"; /* Section header for storage info */ "offlineMaps.storage" = "Almacenamiento"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Región desconocida"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Completado"; +"offlineMaps.complete" = "Descargado"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Descargando…"; diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 005a2715b..d8fc9782d 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Télécharger une région"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Téléchargées"; /* Section header for storage info */ "offlineMaps.storage" = "Stockage"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Région inconnue"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Terminé"; +"offlineMaps.complete" = "Téléchargé"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Téléchargement…"; diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index f3d7c69a6..b73d2f0ad 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Regio downloaden"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Gedownload"; /* Section header for storage info */ "offlineMaps.storage" = "Opslag"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Onbekende regio"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Voltooid"; +"offlineMaps.complete" = "Gedownload"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Downloaden…"; diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 693738fa2..8b38a12b3 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Pobierz region"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Pobrane"; /* Section header for storage info */ "offlineMaps.storage" = "Pamięć"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Nieznany region"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Ukończono"; +"offlineMaps.complete" = "Pobrano"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Pobieranie…"; diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index fee288962..e4750c2f5 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Скачать регион"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Скачанные"; /* Section header for storage info */ "offlineMaps.storage" = "Хранилище"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Неизвестный регион"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Завершено"; +"offlineMaps.complete" = "Загружено"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Загрузка…"; diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 6313a161a..408a2a9e0 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1279,8 +1279,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "Завантажити регіон"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "Завантажені"; /* Section header for storage info */ "offlineMaps.storage" = "Сховище"; @@ -1292,7 +1290,7 @@ "offlineMaps.unknownRegion" = "Невідомий регіон"; /* Status when pack download is complete */ -"offlineMaps.complete" = "Завершено"; +"offlineMaps.complete" = "Завантажено"; /* Status when pack is downloading */ "offlineMaps.downloading" = "Завантаження…"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index cb2e3e46a..6c1159683 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1252,8 +1252,6 @@ /* Button to download a new offline region */ "offlineMaps.downloadRegion" = "下载区域"; -/* Section header for downloaded maps list */ -"offlineMaps.downloaded" = "已下载"; /* Section header for storage info */ "offlineMaps.storage" = "存储空间"; @@ -1265,7 +1263,7 @@ "offlineMaps.unknownRegion" = "未知区域"; /* Status when pack download is complete */ -"offlineMaps.complete" = "已完成"; +"offlineMaps.complete" = "已下载"; /* Status when pack is downloading */ "offlineMaps.downloading" = "下载中…"; diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 7d4bff2a5..9764e5a17 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -81,8 +81,6 @@ private struct PacksSection: View { packToDelete = appState.offlineMapService.packs[index] } } - } header: { - Text(L10n.Settings.OfflineMaps.downloaded) } } } From d9f220fe76637803ee5551aa12eeba6158d2ea30 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:39:29 -0700 Subject: [PATCH 24/55] fix(map): always show map regardless of contact location data - Remove if/else that replaced the map with ContentUnavailableView when no contacts had location - Remove emptyState computed property and unused MapKit import - Remove map.emptyState and map.common.refresh L10n keys across all 9 languages - Remove corresponding EmptyState enum and Common.refresh from L10n.swift --- MC1/Resources/Generated/L10n.swift | 8 -- .../Localization/de.lproj/Map.strings | 11 -- .../Localization/en.lproj/Map.strings | 11 -- .../Localization/es.lproj/Map.strings | 11 -- .../Localization/fr.lproj/Map.strings | 11 -- .../Localization/nl.lproj/Map.strings | 11 -- .../Localization/pl.lproj/Map.strings | 11 -- .../Localization/ru.lproj/Map.strings | 11 -- .../Localization/uk.lproj/Map.strings | 11 -- .../Localization/zh-Hans.lproj/Map.strings | 11 -- MC1/Views/Map/MapContentView.swift | 110 +++++++----------- 11 files changed, 44 insertions(+), 173 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 36298e0d7..2d1ca4ebc 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2025,8 +2025,6 @@ public enum L10n { public enum Common { /// 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 @@ -2094,12 +2092,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") diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index e567e5b44..47cec2259 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index 0ae510662..ff96a4bbd 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index b28c3467b..a3e3faf08 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index b7925864c..8d29d5345 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index cfd00ca0e..eecc95b85 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index ebb0d9acf..ac7cc7a76 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 0cc1c8f85..b814dee3e 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index b95938464..1d0dc08d2 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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-мережі."; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index 251547bfd..e1ef99aa2 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -11,17 +11,6 @@ /* 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网络上发现后显示在此处"; - // MARK: - Map Controls /* Location: MapView.swift - Purpose: Accessibility label when labels are visible */ diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 37b25b382..6df6075e1 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -1,5 +1,4 @@ import SwiftUI -import MapKit import MC1Services /// Map content displaying MC1MapView with contact points and popover callouts @@ -13,74 +12,53 @@ struct MapContentView: View { let onNavigateToChat: (ContactDTO) -> Void var body: some View { - if viewModel.contactsWithLocation.isEmpty && !viewModel.isLoading { - emptyState - } else { - MC1MapView( - points: viewModel.mapPoints, - lines: [], - mapStyle: viewModel.mapStyleSelection, - isDarkMode: colorScheme == .dark, - showLabels: viewModel.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 - }, - isStyleLoaded: $isStyleLoaded + MC1MapView( + points: viewModel.mapPoints, + lines: [], + mapStyle: viewModel.mapStyleSelection, + isDarkMode: colorScheme == .dark, + showLabels: viewModel.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 + }, + 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) } ) - .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 { - loadingOverlay - } - } + .presentationCompactAdaptation(.popover) } - } - - // 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() - } + .overlay { + if !isStyleLoaded { + ProgressView() + .scaleEffect(1.5) + } else if viewModel.isLoading { + loadingOverlay } - .buttonStyle(.bordered) } } From aef671a182d9d8e13f0e5b4a5e524715dc9657ff Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:48:14 -0700 Subject: [PATCH 25/55] fix(trace-path): respect sidebar safe area when auto-zooming map --- MC1/Views/Contacts/TracePathMap/TracePathMapView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index c653efac1..4850d310e 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -115,6 +115,7 @@ struct TracePathMapView: View { showsScale: true, cameraRegion: $mapViewModel.cameraRegion, cameraRegionVersion: mapViewModel.cameraRegionVersion, + cameraBottomSheetFraction: 0, onPointTap: { point, _ in if let repeater = mapViewModel.repeatersWithLocation.first(where: { $0.id == point.id }) { let result = mapViewModel.handleRepeaterTap(repeater) From 2f90fc23389cf5119e9d8ab3deabbc69ad668eb0 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:54:10 -0700 Subject: [PATCH 26/55] fix(map): use black text with no halo on map labels --- MC1/Views/Map/MC1MapView+Layers.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 76f50b7a1..982378ffe 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -278,9 +278,7 @@ extension MC1MapView.Coordinator { layer.text = NSExpression(forKeyPath: "nameLabel") layer.textFontSize = NSExpression(forConstantValue: 10) layer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) - layer.textColor = NSExpression(forConstantValue: UIColor.label) - layer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - layer.textHaloWidth = NSExpression(forConstantValue: 0.5) + layer.textColor = NSExpression(forConstantValue: UIColor.black) layer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) layer.textAnchor = NSExpression(forConstantValue: "bottom") layer.textAllowsOverlap = NSExpression(forConstantValue: true) @@ -296,9 +294,7 @@ extension MC1MapView.Coordinator { layer.text = NSExpression(forKeyPath: "badgeText") layer.textFontSize = NSExpression(forConstantValue: 11) layer.textFontNames = mapFontNames - layer.textColor = NSExpression(forConstantValue: UIColor.label) - layer.textHaloColor = NSExpression(forConstantValue: UIColor.systemBackground) - layer.textHaloWidth = NSExpression(forConstantValue: 0.5) + layer.textColor = NSExpression(forConstantValue: UIColor.black) layer.textAllowsOverlap = NSExpression(forConstantValue: true) layer.textIgnoresPlacement = NSExpression(forConstantValue: true) layer.iconImageName = NSExpression(forConstantValue: "pill-bg") From 2ecc2c66ee7cec9e382d18030d15fa39e7dc3929 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:04:42 -0700 Subject: [PATCH 27/55] feat(map): add north lock button to trace path and line of sight maps - Extract NorthLockButton to Components/ for reuse across map tools - Add isNorthLocked state to TracePathMapViewModel and LineOfSightViewModel - Wire NorthLockButton into toolbar topContent slot for both tool maps - Pass isNorthLocked to MC1MapView in both tool maps --- MC1/Views/Components/NorthLockButton.swift | 22 +++++++++++++++++++ .../TracePathMapToolbarView.swift | 5 ++++- .../TracePathMap/TracePathMapView.swift | 1 + .../TracePathMap/TracePathMapViewModel.swift | 1 + MC1/Views/LineOfSight/LineOfSightView.swift | 6 ++++- .../LineOfSight/LineOfSightViewModel.swift | 1 + MC1/Views/Map/MapCanvasView.swift | 21 ------------------ 7 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 MC1/Views/Components/NorthLockButton.swift 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/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift index d0b718b08..280f92537 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -23,7 +23,10 @@ struct TracePathMapToolbarView: View { appState.locationService.requestLocation() } }, - showingLayersMenu: $mapViewModel.showingLayersMenu + showingLayersMenu: $mapViewModel.showingLayersMenu, + topContent: { + NorthLockButton(isNorthLocked: $mapViewModel.isNorthLocked) + } ) { LabelsToggleButton(showLabels: $mapViewModel.showLabels) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 4850d310e..695896bea 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -113,6 +113,7 @@ struct TracePathMapView: View { showsUserLocation: true, isInteractive: true, showsScale: true, + isNorthLocked: mapViewModel.isNorthLocked, cameraRegion: $mapViewModel.cameraRegion, cameraRegionVersion: mapViewModel.cameraRegionVersion, cameraBottomSheetFraction: 0, diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 55215fe49..1b928b2fc 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -19,6 +19,7 @@ final class TracePathMapViewModel { var showLabels: Bool = true { didSet { rebuildMapPoints() } } + var isNorthLocked = false var showingLayersMenu: Bool = false /// Tracks whether initial centering on repeaters has been performed diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 3633741c7..ce7df4dc7 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -416,6 +416,7 @@ private struct LOSMapCanvasView: View { showsUserLocation: true, isInteractive: true, showsScale: true, + isNorthLocked: viewModel.isNorthLocked, cameraRegion: $viewModel.cameraRegion, cameraRegionVersion: viewModel.cameraRegionVersion, cameraBottomSheetFraction: cameraBottomSheetFraction, @@ -446,7 +447,10 @@ private struct LOSMapCanvasView: View { } } }, - showingLayersMenu: $showingMapStyleMenu + showingLayersMenu: $showingMapStyleMenu, + topContent: { + NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) + } ) { LabelsToggleButton(showLabels: $showLabels) diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 247ba888f..33a48af9a 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -165,6 +165,7 @@ final class LineOfSightViewModel { // MARK: - Map Display State + var isNorthLocked = false var showLabels: Bool = true { didSet { rebuildMapPoints() } } diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index 62fcf90f3..03a329b9b 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -89,27 +89,6 @@ struct MapCanvasView: View { // MARK: - Control Buttons -private 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) - } -} - private struct CenterAllButton: View { let isEmpty: Bool let onClearSelection: () -> Void From 2e643de99df57a2ed4b688cc9eccbb80699d1762 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:21:27 -0700 Subject: [PATCH 28/55] fix(map): use fixed white pill background for label readability in dark mode --- MC1/Views/Map/PinSpriteRenderer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index db385d2bf..0599e13fe 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -235,8 +235,8 @@ enum PinSpriteRenderer { pillPath.fill() cgContext.restoreGState() - // Single fill at reduced opacity to approximate translucent blur - UIColor.secondarySystemBackground.withAlphaComponent(0.75).setFill() + // Light fill for readability in both light and dark mode + UIColor.white.withAlphaComponent(0.85).setFill() pillPath.fill() } From eac737c1e9b76732fc2e9a6db66f6c6cd49f7301 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:41:44 -0700 Subject: [PATCH 29/55] fix(offline-maps): remove satellite offline download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esri World Imagery prohibits bulk tile downloads; anonymous requests at high zoom levels produce sustained 503 errors - Remove OfflineMapLayer.satellite and all switch arms - Delete satellite-offline.json style file - Remove satellite toggle from the region picker UI - Map MapStyleSelection.satellite → .base for offline pack check - Remove offlineMaps.layer.satellite localization key (all 9 languages) - Remove unused CaseIterable from OfflineMapLayer - Inline LayerToggles into RegionPickerBottomCard - Online satellite viewing (live tile streaming) is unaffected --- MC1/Resources/Generated/L10n.swift | 2 -- .../Localization/de.lproj/Settings.strings | 1 - .../Localization/en.lproj/Settings.strings | 1 - .../Localization/es.lproj/Settings.strings | 1 - .../Localization/fr.lproj/Settings.strings | 1 - .../Localization/nl.lproj/Settings.strings | 1 - .../Localization/pl.lproj/Settings.strings | 1 - .../Localization/ru.lproj/Settings.strings | 1 - .../Localization/uk.lproj/Settings.strings | 1 - .../zh-Hans.lproj/Settings.strings | 1 - MC1/Resources/Styles/satellite-offline.json | 22 ------------ MC1/Services/OfflineMapService.swift | 31 ++++++++-------- MC1/Views/Map/MapStyleSelection.swift | 2 +- .../Settings/OfflineMapSettingsView.swift | 36 ++++++------------- 14 files changed, 25 insertions(+), 77 deletions(-) delete mode 100644 MC1/Resources/Styles/satellite-offline.json diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 2d1ca4ebc..4541b6780 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3767,8 +3767,6 @@ public enum L10n { public enum Layer { /// Layer type labels public static let base = L10n.tr("Settings", "offlineMaps.layer.base", fallback: "Base Map") - /// Satellite - public static let satellite = L10n.tr("Settings", "offlineMaps.layer.satellite", fallback: "Satellite") /// Topography public static let topo = L10n.tr("Settings", "offlineMaps.layer.topo", fallback: "Topography") } diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 7c12fed1f..ae0c9fd89 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topografie"; /* Layers section header */ diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index 2daa10184..9c4545327 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1350,7 +1350,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topography"; /* Layers section header */ diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index cb32e4f0e..91b4a88cc 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topografía"; /* Layers section header */ diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index d8fc9782d..9fda1be5e 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topographie"; /* Layers section header */ diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index b73d2f0ad..d6880684e 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topografie"; /* Layers section header */ diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 8b38a12b3..64842f8a3 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Topografia"; /* Layers section header */ diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index e4750c2f5..2f388db0c 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Топография"; /* Layers section header */ diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 408a2a9e0..196e42aa3 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1345,7 +1345,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "Топографія"; /* Layers section header */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index 6c1159683..483ec5ee1 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1318,7 +1318,6 @@ /* Layer type labels */ "offlineMaps.layer.base" = "Base Map"; -"offlineMaps.layer.satellite" = "Satellite"; "offlineMaps.layer.topo" = "地形"; /* Layers section header */ diff --git a/MC1/Resources/Styles/satellite-offline.json b/MC1/Resources/Styles/satellite-offline.json deleted file mode 100644 index ae6babd2d..000000000 --- a/MC1/Resources/Styles/satellite-offline.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 8, - "name": "Satellite Offline", - "sources": { - "satellite": { - "type": "raster", - "tiles": [ - "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" - ], - "tileSize": 256, - "maxzoom": 19, - "attribution": "Esri" - } - }, - "layers": [ - { - "id": "satellite", - "type": "raster", - "source": "satellite" - } - ] -} diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index f10fe77dc..0d9b2f652 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -3,25 +3,28 @@ import MapLibre import Network import os -enum OfflineMapLayer: String, Codable, CaseIterable { +enum OfflineMapLayer: String, Codable { case base - case satellite case topo var label: String { switch self { case .base: L10n.Settings.OfflineMaps.Layer.base - case .satellite: L10n.Settings.OfflineMaps.Layer.satellite 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 .satellite: - Bundle.main.url(forResource: "satellite-offline", withExtension: "json") case .topo: Bundle.main.url(forResource: "topo-offline", withExtension: "json") } @@ -206,8 +209,7 @@ final class OfflineMapService { name: String, bounds: MLNCoordinateBounds, layers: Set, - minZoom: Double = 10, - maxZoom: Double = 15 + minZoom: Double = 10 ) async throws { let values = try URL.documentsDirectory.resourceValues( forKeys: [.volumeAvailableCapacityForImportantUsageKey] @@ -230,7 +232,7 @@ final class OfflineMapService { styleURL: styleURL, bounds: bounds, fromZoomLevel: minZoom, - toZoomLevel: maxZoom + toZoomLevel: layer.maxDownloadZoom ) let metadata = OfflinePackMetadata(name: name, createdAt: now, layer: layer) @@ -303,22 +305,17 @@ final class OfflineMapService { let bytesPerTile: [Int: Int64] switch layer { case .base: - // Average compressed vector tile sizes for OpenFreeMap (OpenMapTiles schema). + // Average compressed vector tile sizes for OpenFreeMap (OpenMapTiles schema, max z14). bytesPerTile = [ 10: 2_000, 11: 3_000, 12: 5_000, - 13: 8_000, 14: 12_000, 15: 15_000, - ] - case .satellite: - // Esri World Imagery JPEG raster tiles (256px). - bytesPerTile = [ - 10: 20_000, 11: 25_000, 12: 30_000, - 13: 35_000, 14: 40_000, 15: 45_000, + 13: 8_000, 14: 12_000, ] case .topo: - // OpenTopoMap PNG raster tiles (256px). + // 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, ] } diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 4961c4047..987dd46b0 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -25,7 +25,7 @@ enum MapStyleSelection: String, CaseIterable, Hashable { var offlineMapLayer: OfflineMapLayer { switch self { case .standard: .base - case .satellite: .satellite + case .satellite: .base case .topo: .topo } } diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 9764e5a17..0d324a0bc 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -177,7 +177,6 @@ private struct RegionPickerSheet: View { @State private var isDownloading = false @State private var showError: String? @State private var mapSize: CGSize = .zero - @State private var includeSatellite = false @State private var includeTopo = false @State private var isStyleLoaded = false @State private var debouncedRegion: MKCoordinateRegion? @@ -247,7 +246,6 @@ private struct RegionPickerSheet: View { .safeAreaInset(edge: .bottom) { RegionPickerBottomCard( regionName: $regionName, - includeSatellite: $includeSatellite, includeTopo: $includeTopo, estimatedDownloadBytes: estimatedDownloadBytes, exceedsAvailableSpace: exceedsAvailableSpace, @@ -265,7 +263,6 @@ private struct RegionPickerSheet: View { private var selectedLayers: Set { var layers: Set = [.base] - if includeSatellite { layers.insert(.satellite) } if includeTopo { layers.insert(.topo) } return layers } @@ -273,7 +270,7 @@ private struct RegionPickerSheet: View { 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: 15, layer: layer) + total + OfflineMapService.estimatedDownloadSize(bounds: bounds, minZoom: 10, maxZoom: Int(layer.maxDownloadZoom), layer: layer) } } @@ -342,7 +339,6 @@ private struct RegionPickerSheet: View { private struct RegionPickerBottomCard: View { @Binding var regionName: String - @Binding var includeSatellite: Bool @Binding var includeTopo: Bool let estimatedDownloadBytes: Int64? let exceedsAvailableSpace: Bool @@ -360,7 +356,15 @@ private struct RegionPickerBottomCard: View { Divider() - LayerToggles(includeSatellite: $includeSatellite, includeTopo: $includeTopo) + 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() @@ -451,26 +455,6 @@ private struct RegionPickerBottomCard: View { } } -// MARK: - Layer Toggles - -private struct LayerToggles: View { - @Binding var includeSatellite: Bool - @Binding var includeTopo: Bool - - var body: some View { - VStack(alignment: .leading) { - Text(L10n.Settings.OfflineMaps.layers) - .font(.caption) - .foregroundStyle(.secondary) - - Toggle(L10n.Settings.OfflineMaps.Layer.satellite, isOn: $includeSatellite) - Toggle(L10n.Settings.OfflineMaps.Layer.topo, isOn: $includeTopo) - } - .toggleStyle(.switch) - .controlSize(.mini) - } -} - #Preview { NavigationStack { OfflineMapSettingsView() From 1604b43bbc0d765c70c55f4f799e748bc1d93365 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:42:30 -0700 Subject: [PATCH 30/55] refactor(map): persist map style and label settings with @AppStorage - Remove mapStyleSelection and showLabels from MapViewModel and TracePathMapViewModel - Add @AppStorage("mapStyleSelection") and @AppStorage("mapShowLabels") to MapView, TracePathMapView, LineOfSightView - Thread bindings through MapCanvasView, MapContentView, and TracePathMapToolbarView --- .../TracePathMap/TracePathMapToolbarView.swift | 6 ++++-- .../Contacts/TracePathMap/TracePathMapView.swift | 16 +++++++++++++--- .../TracePathMap/TracePathMapViewModel.swift | 1 - MC1/Views/LineOfSight/LineOfSightView.swift | 9 +++++++-- MC1/Views/Map/MapCanvasView.swift | 8 ++++++-- MC1/Views/Map/MapContentView.swift | 6 ++++-- MC1/Views/Map/MapView.swift | 4 ++++ MC1/Views/Map/MapViewModel.swift | 6 ------ 8 files changed, 38 insertions(+), 18 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift index 280f92537..002652fd4 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -6,6 +6,8 @@ import MC1Services 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 { @@ -28,7 +30,7 @@ struct TracePathMapToolbarView: View { NorthLockButton(isNorthLocked: $mapViewModel.isNorthLocked) } ) { - LabelsToggleButton(showLabels: $mapViewModel.showLabels) + LabelsToggleButton(showLabels: $showLabels) // Center on path if mapViewModel.hasPath { @@ -48,7 +50,7 @@ struct TracePathMapToolbarView: View { .overlay(alignment: .bottomTrailing) { if mapViewModel.showingLayersMenu { LayersMenu( - selection: $mapViewModel.mapStyleSelection, + selection: $mapStyleSelection, isPresented: $mapViewModel.showingLayersMenu ) .padding(.trailing, 16) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 695896bea..f870311ef 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -11,6 +11,8 @@ struct TracePathMapView: View { @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 @@ -46,16 +48,24 @@ struct TracePathMapView: View { ) // Map controls toolbar - TracePathMapToolbarView(mapViewModel: mapViewModel) + TracePathMapToolbarView( + mapViewModel: mapViewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels + ) } .onAppear { mapViewModel.configure( traceViewModel: traceViewModel, userLocation: appState.locationService.currentLocation ) + mapViewModel.showLabels = showLabels mapViewModel.rebuildOverlays() mapViewModel.performInitialCentering() } + .onChange(of: showLabels) { _, newValue in + mapViewModel.showLabels = newValue + } .onChange(of: appState.locationService.currentLocation) { _, newLocation in mapViewModel.updateUserLocation(newLocation) } @@ -107,9 +117,9 @@ struct TracePathMapView: View { MC1MapView( points: mapViewModel.mapPoints, lines: mapViewModel.mapLines, - mapStyle: mapViewModel.mapStyleSelection, + mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, - showLabels: mapViewModel.showLabels, + showLabels: showLabels, showsUserLocation: true, isInteractive: true, showsScale: true, diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 1b928b2fc..80286c889 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -15,7 +15,6 @@ final class TracePathMapViewModel { var cameraRegion: MKCoordinateRegion? /// Incremented when code intentionally moves the camera (not from user gesture sync) private(set) var cameraRegionVersion = 0 - var mapStyleSelection: MapStyleSelection = .standard var showLabels: Bool = true { didSet { rebuildMapPoints() } } diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index ce7df4dc7..a832ec92f 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -20,7 +20,8 @@ struct LineOfSightView: View { @State private var showAnalysisSheet: Bool @State private var editingPoint: PointID? @State private var isDropPinMode = false - @State private var mapStyleSelection: MapStyleSelection = .topo + @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 @@ -92,7 +93,7 @@ struct LineOfSightView: View { appState: appState, mapStyleSelection: $mapStyleSelection, showingMapStyleMenu: $showingMapStyleMenu, - showLabels: $viewModel.showLabels, + showLabels: $showLabels, isDropPinMode: $isDropPinMode, mapOverlayBottomPadding: mapOverlayBottomPadding, cameraBottomSheetFraction: showSheet ? 0.25 : 0, @@ -166,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 diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index 03a329b9b..ed0fdfa28 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -5,6 +5,8 @@ import MC1Services 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 @@ -17,6 +19,8 @@ struct MapCanvasView: View { ZStack { MapContentView( viewModel: viewModel, + mapStyleSelection: mapStyleSelection, + showLabels: showLabels, selectedCalloutContact: $selectedCalloutContact, selectedPointScreenPosition: $selectedPointScreenPosition, isStyleLoaded: $isStyleLoaded, @@ -53,7 +57,7 @@ struct MapCanvasView: View { HStack { Spacer() LayersMenu( - selection: $viewModel.mapStyleSelection, + selection: $mapStyleSelection, isPresented: $viewModel.showingLayersMenu ) .padding(.trailing, 72) @@ -76,7 +80,7 @@ struct MapCanvasView: View { NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) } ) { - LabelsToggleButton(showLabels: $viewModel.showLabels) + LabelsToggleButton(showLabels: $showLabels) CenterAllButton( isEmpty: viewModel.contactsWithLocation.isEmpty, onClearSelection: onClearSelection, diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 6df6075e1..a28bdfd7c 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -5,6 +5,8 @@ import MC1Services struct MapContentView: View { @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 @@ -15,9 +17,9 @@ struct MapContentView: View { MC1MapView( points: viewModel.mapPoints, lines: [], - mapStyle: viewModel.mapStyleSelection, + mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, - showLabels: viewModel.showLabels, + showLabels: showLabels, showsUserLocation: true, isInteractive: true, showsScale: true, diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 345c6e19f..aa9c4144a 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -5,6 +5,8 @@ import MC1Services /// Map view displaying contacts with their locations struct MapView: View { @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? @@ -15,6 +17,8 @@ struct MapView: View { NavigationStack { MapCanvasView( viewModel: viewModel, + mapStyleSelection: $mapStyleSelection, + showLabels: $showLabels, selectedCalloutContact: $selectedCalloutContact, selectedPointScreenPosition: $selectedPointScreenPosition, isStyleLoaded: $isStyleLoaded, diff --git a/MC1/Views/Map/MapViewModel.swift b/MC1/Views/Map/MapViewModel.swift index 4d623e282..60fe2ba93 100644 --- a/MC1/Views/Map/MapViewModel.swift +++ b/MC1/Views/Map/MapViewModel.swift @@ -27,12 +27,6 @@ final class MapViewModel { /// Version counter for the camera region, incremented to signal a new camera target private(set) var cameraRegionVersion = 0 - /// Current map style selection - var mapStyleSelection: MapStyleSelection = .standard - - /// Whether to show contact name labels - var showLabels = true - /// Whether the map bearing is locked to true north var isNorthLocked = false From 3eab6cb365712e208e9b7b1be08d6e6f1f0cdd66 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:42:41 -0700 Subject: [PATCH 31/55] fix(map): improve node label layer rendering - Add symbolSortKey by hopIndex for consistent label ordering - Add text halo for improved label readability - Fix hopIndex attribute type from String to Int --- MC1/Views/Map/MC1MapView+Layers.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 982378ffe..77dbf9300 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -281,8 +281,11 @@ extension MC1MapView.Coordinator { layer.textColor = NSExpression(forConstantValue: UIColor.black) layer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) layer.textAnchor = NSExpression(forConstantValue: "bottom") + layer.symbolSortKey = NSExpression(forKeyPath: "hopIndex") layer.textAllowsOverlap = NSExpression(forConstantValue: true) layer.textIgnoresPlacement = NSExpression(forConstantValue: true) + layer.textHaloColor = NSExpression(forConstantValue: UIColor.black.withAlphaComponent(0.15)) + layer.textHaloWidth = NSExpression(forConstantValue: 1) layer.iconAllowsOverlap = NSExpression(forConstantValue: true) layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) layer.iconImageName = NSExpression(forConstantValue: "pill-bg") @@ -312,7 +315,7 @@ extension MC1MapView.Coordinator { "spriteName": spriteName(for: point), ] if let label = point.label { attributes["nameLabel"] = label } - if let hopIndex = point.hopIndex { attributes["hopIndex"] = "\(hopIndex)" } + if let hopIndex = point.hopIndex { attributes["hopIndex"] = hopIndex } if let badgeText = point.badgeText { attributes["badgeText"] = badgeText } feature.attributes = attributes return feature From f5744f8c28872b6ccc9c40b1a32b43296ba83ded Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:56:32 -0700 Subject: [PATCH 32/55] fix(map): update renamed adminAccess l10n key to management --- MC1/Views/Map/ContactDetailSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index e5ed35992..c188789d2 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -97,7 +97,7 @@ struct ContactDetailSheet: View { Button { activeSheet = .adminAuth } label: { - Label(L10n.Map.Map.Detail.Action.adminAccess, systemImage: "gearshape.2") + Label(L10n.Map.Map.Detail.Action.management, systemImage: "gearshape.2") } case .room: From 6d29cb318696f1e85db59e43f41f216838c5b9cc Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:42:01 -0700 Subject: [PATCH 33/55] fix(map): pre-render label sprites to fix z-ordering - Added renderLabelSprite(text:) to PinSpriteRenderer, rendering pill background + text as a single UIImage - Extended renderOnDemand to handle "label-" prefix sprites on-demand, matching the existing hop-ring pattern - Updated configureNameLabelLayer to use per-feature pre-rendered sprites instead of MapLibre text rendering, eliminating the separate icon/text render pass that caused text from underlying labels to bleed through - Removed dead nameLabel feature attribute; predicates now use labelSpriteName - Extracted labelSpritePrefix constant shared by builder and recognizer --- MC1/Views/Map/MC1MapView+Layers.swift | 24 +++------ MC1/Views/Map/PinSpriteRenderer.swift | 73 +++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 77dbf9300..01270eeda 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -137,7 +137,7 @@ extension MC1MapView.Coordinator { // Name labels (above pins) with pill background let nameLabelLayer = MLNSymbolStyleLayer(identifier: MapLayerID.nameLabels, source: source) - nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND nameLabel != nil") + nameLabelLayer.predicate = NSPredicate(format: "cluster != YES AND labelSpriteName != nil") configureNameLabelLayer(nameLabelLayer) style.addLayer(nameLabelLayer) @@ -160,7 +160,7 @@ extension MC1MapView.Coordinator { style.addLayer(fixedIconLayer) let fixedNameLayer = MLNSymbolStyleLayer(identifier: MapLayerID.fixedNameLabels, source: source) - fixedNameLayer.predicate = NSPredicate(format: "nameLabel != nil") + fixedNameLayer.predicate = NSPredicate(format: "labelSpriteName != nil") configureNameLabelLayer(fixedNameLayer) style.addLayer(fixedNameLayer) @@ -275,22 +275,12 @@ extension MC1MapView.Coordinator { // MARK: - Shared layer configuration private func configureNameLabelLayer(_ layer: MLNSymbolStyleLayer) { - layer.text = NSExpression(forKeyPath: "nameLabel") - layer.textFontSize = NSExpression(forConstantValue: 10) - layer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Bold"]) - layer.textColor = NSExpression(forConstantValue: UIColor.black) - layer.textOffset = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 0, dy: -4.8))) - layer.textAnchor = NSExpression(forConstantValue: "bottom") + 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.textAllowsOverlap = NSExpression(forConstantValue: true) - layer.textIgnoresPlacement = NSExpression(forConstantValue: true) - layer.textHaloColor = NSExpression(forConstantValue: UIColor.black.withAlphaComponent(0.15)) - layer.textHaloWidth = NSExpression(forConstantValue: 1) layer.iconAllowsOverlap = NSExpression(forConstantValue: true) layer.iconIgnoresPlacement = NSExpression(forConstantValue: true) - layer.iconImageName = NSExpression(forConstantValue: "pill-bg") - layer.iconTextFit = NSExpression(forConstantValue: NSValue(mlnIconTextFit: .both)) - layer.iconTextFitPadding = NSExpression(forConstantValue: NSValue(uiEdgeInsets: UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2))) } private func configureBadgeLayer(_ layer: MLNSymbolStyleLayer) { @@ -314,7 +304,9 @@ extension MC1MapView.Coordinator { "pointId": point.id.uuidString, "spriteName": spriteName(for: point), ] - if let label = point.label { attributes["nameLabel"] = label } + 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 diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 0599e13fe..175ecded6 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -6,6 +6,8 @@ enum PinSpriteRenderer { /// 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 nonisolated(unsafe) static var cachedImages: [String: UIImage]? /// Registers base pin sprites into the style. Hop-ring variants are rendered @@ -36,22 +38,28 @@ enum PinSpriteRenderer { /// Returns `true` if the name was recognized and the image was registered. @discardableResult static func renderOnDemand(name: String, into style: MLNStyle) -> Bool { - guard name.hasPrefix("pin-repeater-ring-white-hop-") else { return false } - - // Check the cache first (may have been rendered for a different style load) if let cached = cachedImages?[name] { style.setImage(cached, forName: name) return true } - 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 { + 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 false + } + image = render(ringWhiteSpec, hopIndex: hop) + } else if name.hasPrefix(labelSpritePrefix) { + let text = String(name.dropFirst(labelSpritePrefix.count)) + guard !text.isEmpty else { return false } + image = renderLabelSprite(text: text) + } else { return false } - let image = render(ringWhiteSpec, hopIndex: hop) cachedImages?[name] = image style.setImage(image, forName: name) return true @@ -208,7 +216,7 @@ enum PinSpriteRenderer { // MARK: - Pill sprites - /// Semi-transparent stretchable pill for name labels and stats badges. + /// 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 { @@ -246,6 +254,53 @@ enum PinSpriteRenderer { ) } + private static func renderLabelSprite(text: String) -> UIImage { + let font = UIFont.systemFont(ofSize: 10, 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 From 29721e2eb54c147e57e06f0880bde2da1d34f9c3 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:42:07 -0700 Subject: [PATCH 34/55] chore(l10n): regenerate with updated remote nodes strings --- MC1/Resources/Generated/L10n.swift | 44 +----------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 4541b6780..578b01f42 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2384,7 +2384,7 @@ public enum L10n { /// Location: NodeStatusHistoryView.swift - Noise floor chart title public static let noiseFloor = L10n.tr("RemoteNodes", "remoteNodes.history.noiseFloor", fallback: "Noise Floor") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist - public static let noSnapshotsMessage = L10n.tr("RemoteNodes", "remoteNodes.history.noSnapshotsMessage", fallback: "Connect to this node at least once to see history.") + public static let noSnapshotsMessage = L10n.tr("RemoteNodes", "remoteNodes.history.noSnapshotsMessage", fallback: "Connect to this repeater at least once to see history.") /// Location: NeighborRow - Not seen status public static let notSeen = L10n.tr("RemoteNodes", "remoteNodes.history.notSeen", fallback: "Not seen") /// Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title @@ -2465,8 +2465,6 @@ public enum L10n { public static let infoTitle = L10n.tr("RemoteNodes", "remoteNodes.room.infoTitle", fallback: "Room Info") /// Location: RoomConversationView.swift - Last connected label public static let lastConnected = L10n.tr("RemoteNodes", "remoteNodes.room.lastConnected", fallback: "Last Connected") - /// Location: RoomInfoSheet.swift - Management button - public static let management = L10n.tr("RemoteNodes", "remoteNodes.room.management", fallback: "Management") /// Location: RoomConversationView.swift - Empty state title public static let noMessagesYet = L10n.tr("RemoteNodes", "remoteNodes.room.noMessagesYet", fallback: "No public messages yet") /// Location: RoomConversationView.swift - Permission label @@ -2479,8 +2477,6 @@ public enum L10n { public static let reconnected = L10n.tr("RemoteNodes", "remoteNodes.room.reconnected", fallback: "Room reconnected") /// Location: RoomConversationView.swift - Status label public static let status = L10n.tr("RemoteNodes", "remoteNodes.room.status", fallback: "Status") - /// Location: RoomInfoSheet.swift - Telemetry button - public static let telemetry = L10n.tr("RemoteNodes", "remoteNodes.room.telemetry", fallback: "Telemetry") /// Location: RoomConversationView.swift - Read-only banner public static let viewOnlyBanner = L10n.tr("RemoteNodes", "remoteNodes.room.viewOnlyBanner", fallback: "View only - join as member to post") /// Location: RoomConversationView.swift - Hint text for read-only banner @@ -2498,44 +2494,6 @@ public enum L10n { } } } - public enum RoomSettings { - /// Location: RoomSettingsView.swift - Allow read-only toggle label - public static let allowReadOnly = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.allowReadOnly", fallback: "Allow Read-Only") - /// Location: RoomSettingsView.swift - Allow read-only footer - public static let allowReadOnlyFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.allowReadOnlyFooter", fallback: "Allow users without a password to connect in read-only mode.") - /// Location: RoomSettingsView.swift - Apply room settings button - public static let applyRoomSettings = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.applyRoomSettings", fallback: "Apply Room Settings") - /// Location: RoomSettingsView.swift - Clock ahead error - public static let clockAheadError = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.clockAheadError", fallback: "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again.") - /// Location: RoomSettingsView.swift - Guest password label - public static let guestPassword = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.guestPassword", fallback: "Guest Password") - /// Location: RoomSettingsView.swift - Identity section footer - public static let identityFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.identityFooter", fallback: "Room name and GPS coordinates for map display.") - /// Location: RoomSettingsView.swift - No service error - public static let noService = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.noService", fallback: "Room service not available") - /// Location: RoomSettingsView.swift - Not connected error - public static let notConnected = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.notConnected", fallback: "Not connected to room") - /// Location: RoomSettingsView.swift - Radio restart warning - public static let radioRestartWarning = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.radioRestartWarning", fallback: "Applying these changes will restart the room") - /// Location: RoomSettingsView.swift - Reboot confirmation title - public static let rebootConfirmTitle = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.rebootConfirmTitle", fallback: "Reboot Room?") - /// Location: RoomSettingsView.swift - Reboot confirmation message - public static let rebootMessage = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.rebootMessage", fallback: "The room will restart and be temporarily unavailable.") - /// Location: RoomSettingsView.swift - Room settings section footer - public static let roomSettingsFooter = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.roomSettingsFooter", fallback: "Guest access, advertisement intervals, and flood hops.") - /// Location: RoomSettingsView.swift - Room settings section header - public static let roomSettingsSection = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.roomSettingsSection", fallback: "Room Settings") - /// Location: RoomSettingsView.swift - Navigation title - public static let title = L10n.tr("RemoteNodes", "remoteNodes.roomSettings.title", fallback: "Room Settings") - } - public enum RoomStatus { - /// Location: RoomStatusView.swift - Posts pushed label - public static let postsPushed = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.postsPushed", fallback: "Posts Pushed") - /// Location: RoomStatusView.swift - Posts received label - public static let postsReceived = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.postsReceived", fallback: "Posts Received") - /// Location: RoomStatusView.swift - Navigation title - public static let title = L10n.tr("RemoteNodes", "remoteNodes.roomStatus.title", fallback: "Room Status") - } public enum Settings { /// Location: RepeaterSettingsView.swift - Advert interval (0-hop) label public static let advertInterval0Hop = L10n.tr("RemoteNodes", "remoteNodes.settings.advertInterval0Hop", fallback: "Advert Interval (0-hop)") From af6ff4d261ccacb3cc9496f7e8b0799dee2b333b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:10:55 -0700 Subject: [PATCH 35/55] fix(map): prevent repeater pin blink on trace path tap - Keep all trace path repeaters in the fixed (non-clustered) source so pins never migrate between MapLibre sources on tap, eliminating the async re-cluster gap that caused the blink - Return the rendered image from didFailToLoadImage so MapLibre uses it as an immediate fallback rather than skipping the frame --- .../TracePathMap/TracePathMapViewModel.swift | 2 +- MC1/Views/Map/MC1MapView.swift | 7 +++---- MC1/Views/Map/PinSpriteRenderer.swift | 16 ++++++++-------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 80286c889..f722528b7 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -136,7 +136,7 @@ final class TracePathMapViewModel { coordinate: repeater.coordinate, pinStyle: inPath ? .repeaterRingWhite : .repeater, label: showLabels ? repeater.displayName : nil, - isClusterable: !inPath, + isClusterable: false, hopIndex: info?.hopIndex, badgeText: nil )) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 8b3274cd6..96d4bc587 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -366,10 +366,9 @@ extension MC1MapView { } func mapView(_ mapView: MLNMapView, didFailToLoadImage imageName: String) -> UIImage? { - if let style = mapView.style { - if PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { - return nil - } + if let style = mapView.style, + let image = PinSpriteRenderer.renderOnDemand(name: imageName, into: style) { + return image } logger.error("didFailToLoadImage: \(imageName)") return nil diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 175ecded6..0d57be3e5 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -35,12 +35,12 @@ enum PinSpriteRenderer { } /// Renders a hop-ring sprite on demand when MapLibre requests a missing image name. - /// Returns `true` if the name was recognized and the image was registered. - @discardableResult - static func renderOnDemand(name: String, into style: MLNStyle) -> Bool { + /// 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 true + return cached } let image: UIImage @@ -49,20 +49,20 @@ enum PinSpriteRenderer { let hop = Int(hopString), (1...20).contains(hop), let ringWhiteSpec = allSpecs.first(where: { $0.name == "pin-repeater-ring-white" }) else { - return false + return nil } image = render(ringWhiteSpec, hopIndex: hop) } else if name.hasPrefix(labelSpritePrefix) { let text = String(name.dropFirst(labelSpritePrefix.count)) - guard !text.isEmpty else { return false } + guard !text.isEmpty else { return nil } image = renderLabelSprite(text: text) } else { - return false + return nil } cachedImages?[name] = image style.setImage(image, forName: name) - return true + return image } // MARK: - Sprite specifications From 49b908a52e988ce429620025981ef1f2fb281efd Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:19:13 -0700 Subject: [PATCH 36/55] fix(map): disable quick-zoom gesture - MapLibre has no API for this, so we find its internal UILongPressGestureRecognizer (numberOfTapsRequired == 1, minimumPressDuration == 0) and disable it --- MC1/Views/Map/MC1MapView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 96d4bc587..3133ea979 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -182,6 +182,12 @@ struct MC1MapView: UIViewRepresentable { 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, From 3476b73434f81608d5b4195e2d011822f4c46c12 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:54:33 -0700 Subject: [PATCH 37/55] ui(map): add white border to trace path lines - White casing layer behind each trace line style outlines each dash - Dash pattern values are scaled by the width ratio so casing and line stay in sync --- MC1/Views/Map/MC1MapView+Layers.swift | 58 +++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 01270eeda..d31e66ab0 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -21,6 +21,10 @@ enum MapLayerID { 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" } @@ -185,27 +189,75 @@ extension MC1MapView.Coordinator { 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: [8, 6]) + 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: [4, 4]) + 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: [12, 4]) + 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) From 96c0ba8b7653c5474dd401e3fd6b7850e82eebae Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:17:23 -0700 Subject: [PATCH 38/55] fix(trace-path): use outbound path index for SNR hop lookup --- .../Contacts/TracePathMap/TracePathMapViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index f722528b7..eca16ba27 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -251,8 +251,9 @@ final class TracePathMapViewModel { badgePoints.removeAll() var updatedLines: [MapLine] = [] - for (index, line) in mapLines.enumerated() { - let hopIndex = index + 1 + for line in mapLines { + let pathIndex = Int(line.id.replacing("trace-", with: "")) + let hopIndex = (pathIndex ?? 0) + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] let quality = signalQuality(snr: hop.snr) @@ -278,7 +279,7 @@ final class TracePathMapViewModel { let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) badgePoints.append(MapPoint( - id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", index))") ?? UUID(), + id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", hopIndex))") ?? UUID(), coordinate: mid, pinStyle: .badge, label: nil, From ac38a902148d92404fb21c24919e73027586b2b3 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:37:20 -0700 Subject: [PATCH 39/55] fix(trace): use SNRQuality for signal level and color in TraceResultHopRow - Replace removed TraceHop.signalLevel/signalColor static methods with SNRQuality computed properties to match dev's refactored API --- MC1/Views/Contacts/TraceResultHopRow.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/MC1/Views/Contacts/TraceResultHopRow.swift b/MC1/Views/Contacts/TraceResultHopRow.swift index de419ee43..4667bd4f8 100644 --- a/MC1/Views/Contacts/TraceResultHopRow.swift +++ b/MC1/Views/Contacts/TraceResultHopRow.swift @@ -22,13 +22,11 @@ struct TraceResultHopRow: View { } } - private var signalLevel: Double { - TraceHop.signalLevel(for: displaySNR) - } + private var snrQuality: SNRQuality { SNRQuality(snr: displaySNR) } - private var signalColor: Color { - TraceHop.signalColor(for: displaySNR) - } + private var signalLevel: Double { snrQuality.barLevel } + + private var signalColor: Color { snrQuality.color } var body: some View { HStack { From c0a5946fc3e5d698be9a576a4054b5a53e39660e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:43:34 -0700 Subject: [PATCH 40/55] fix(i18n): translate missing offline map strings, add MainActor to PinSpriteRenderer Five offline map settings keys were English in all non-English locales. Translated for de, es, fr, nl, pl, ru, uk, zh-Hans. Added @MainActor to PinSpriteRenderer so the compiler enforces thread safety rather than trusting nonisolated(unsafe). --- MC1/Resources/Localization/de.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/es.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/fr.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/nl.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/pl.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/ru.lproj/Settings.strings | 10 +++++----- MC1/Resources/Localization/uk.lproj/Settings.strings | 10 +++++----- .../Localization/zh-Hans.lproj/Settings.strings | 10 +++++----- MC1/Views/Map/PinSpriteRenderer.swift | 3 ++- 9 files changed, 42 insertions(+), 41 deletions(-) diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index ae0c9fd89..1f7792716 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "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" = "Base Map"; +"offlineMaps.layer.base" = "Basiskarte"; "offlineMaps.layer.topo" = "Topografie"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Kartenebenen"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Zusätzliche Kartenebenen für die Offline-Nutzung einschließen."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Zum Herunterladen von Karten ist eine Internetverbindung erforderlich."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"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/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index 91b4a88cc..e070cac3d 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "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" = "Base Map"; +"offlineMaps.layer.base" = "Mapa base"; "offlineMaps.layer.topo" = "Topografía"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Capas"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Incluir capas adicionales para uso sin conexión."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Se requiere conexión a internet para descargar mapas."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"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/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 9fda1be5e..74a4f5400 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "offlineMaps.exceedsStorage" = "Pas assez d'espace sur cet appareil. Zoomez pour sélectionner une zone plus petite."; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "Carte de base"; "offlineMaps.layer.topo" = "Topographie"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Couches"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Inclure des couches supplémentaires pour une utilisation hors ligne."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Une connexion internet est nécessaire pour télécharger les cartes."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"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/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index d6880684e..372e43f17 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "offlineMaps.exceedsStorage" = "Niet genoeg opslagruimte op dit apparaat. Zoom in om een kleiner gebied te selecteren."; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "Basiskaart"; "offlineMaps.layer.topo" = "Topografie"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Kaartlagen"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Extra kaartlagen opnemen voor offline gebruik."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Een internetverbinding is vereist om kaarten te downloaden."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"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/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 64842f8a3..2139f0129 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "offlineMaps.exceedsStorage" = "Za mało miejsca na tym urządzeniu. Przybliż, aby wybrać mniejszy obszar."; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "Mapa bazowa"; "offlineMaps.layer.topo" = "Topografia"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Warstwy"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Dołącz dodatkowe warstwy do użytku offline."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Do pobrania map wymagane jest połączenie z internetem."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"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/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 2f388db0c..00dc1f5a1 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "offlineMaps.exceedsStorage" = "Недостаточно места на устройстве. Увеличьте масштаб, чтобы выбрать меньшую область."; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "Базовая карта"; "offlineMaps.layer.topo" = "Топография"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Слои"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Включить дополнительные слои для использования офлайн."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Для загрузки карт требуется подключение к интернету."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"offlineMaps.storageFooter" = "Включает данные карты и внутренние индексы. Общий объём может превышать сумму отдельных загрузок."; diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 196e42aa3..d3cf44935 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1344,17 +1344,17 @@ "offlineMaps.exceedsStorage" = "Недостатньо місця на пристрої. Збільште масштаб, щоб вибрати меншу область."; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "Базова карта"; "offlineMaps.layer.topo" = "Топографія"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "Шари"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "Додати додаткові шари для використання офлайн."; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "Для завантаження карт потрібне з'єднання з інтернетом."; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"offlineMaps.storageFooter" = "Включає дані карти та внутрішні індекси. Загальний обсяг може перевищувати суму окремих завантажень."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index 483ec5ee1..c19945ed7 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1317,17 +1317,17 @@ "offlineMaps.exceedsStorage" = "设备存储空间不足。请放大地图以选择更小的区域。"; /* Layer type labels */ -"offlineMaps.layer.base" = "Base Map"; +"offlineMaps.layer.base" = "基础地图"; "offlineMaps.layer.topo" = "地形"; /* Layers section header */ -"offlineMaps.layers" = "Layers"; +"offlineMaps.layers" = "图层"; /* Include layers prompt */ -"offlineMaps.includeLayers" = "Include additional layers for offline use."; +"offlineMaps.includeLayers" = "包含额外图层以供离线使用。"; /* No network available */ -"offlineMaps.noNetwork" = "An internet connection is required to download maps."; +"offlineMaps.noNetwork" = "下载地图需要互联网连接。"; /* Storage section footer */ -"offlineMaps.storageFooter" = "Includes map data and internal indexes. Total may be larger than the sum of individual downloads."; +"offlineMaps.storageFooter" = "包括地图数据和内部索引。总大小可能超过各项下载的总和。"; diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 0d57be3e5..10603b3f7 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -1,6 +1,7 @@ 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. @@ -8,7 +9,7 @@ enum PinSpriteRenderer { static let labelSpritePrefix = "label-" - private nonisolated(unsafe) static var cachedImages: [String: UIImage]? + 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. From 87dbe6535f57ac52e1b43d34992445ac3a8a0910 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:04:25 -0700 Subject: [PATCH 41/55] fix(offline-maps): remove delete confirmation to fix row flicker Swipe-to-delete was causing the row to disappear, reappear when the confirmation alert rendered, then disappear again after confirming. Delete directly on swipe instead. --- .../Settings/OfflineMapSettingsView.swift | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 0d324a0bc..808de1a61 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -5,7 +5,6 @@ import SwiftUI struct OfflineMapSettingsView: View { @Environment(\.appState) private var appState @State private var showingRegionPicker = false - @State private var packToDelete: OfflinePack? @State private var showError: String? var body: some View { @@ -23,7 +22,7 @@ struct OfflineMapSettingsView: View { } } else { List { - PacksSection(packToDelete: $packToDelete) + PacksSection() StorageSection() } .toolbar { @@ -39,21 +38,6 @@ struct OfflineMapSettingsView: View { .sheet(isPresented: $showingRegionPicker) { RegionPickerSheet() } - .alert( - L10n.Settings.OfflineMaps.deleteTitle, - isPresented: .init( - get: { packToDelete != nil }, - set: { if !$0 { packToDelete = nil } } - ), - presenting: packToDelete - ) { pack in - Button(L10n.Settings.OfflineMaps.delete, role: .destructive) { - Task { await appState.offlineMapService.deletePack(pack) } - } - Button(L10n.Settings.OfflineMaps.cancel, role: .cancel) {} - } message: { _ in - Text(L10n.Settings.OfflineMaps.deleteMessage) - } .onChange(of: appState.offlineMapService.lastPackError) { _, newValue in if let newValue { showError = newValue @@ -69,7 +53,6 @@ struct OfflineMapSettingsView: View { private struct PacksSection: View { @Environment(\.appState) private var appState - @Binding var packToDelete: OfflinePack? var body: some View { Section { @@ -78,7 +61,8 @@ private struct PacksSection: View { } .onDelete { indexSet in if let index = indexSet.first { - packToDelete = appState.offlineMapService.packs[index] + let pack = appState.offlineMapService.packs[index] + Task { await appState.offlineMapService.deletePack(pack) } } } } From 69ee50a52adcca85f0dce0bbc41f2e435906d417 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:24:20 -0700 Subject: [PATCH 42/55] fix(map): flush deferred data updates after gestures, gate offline styles, guard download bounds Track last-applied points/lines separately from current coordinator state so data arriving mid-gesture gets pushed to MapLibre once the gesture ends. Disable all map styles when offline without a matching pack, not just satellite. Disable the download button until debounced bounds exist. --- MC1/Services/OfflineMapService.swift | 2 +- MC1/Views/Map/LayersMenu.swift | 6 +++--- MC1/Views/Map/MC1MapView.swift | 20 ++++++++++++------- .../Settings/OfflineMapSettingsView.swift | 1 + 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 0d9b2f652..1c8e11a33 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -296,7 +296,7 @@ final class OfflineMapService { } /// Estimated download size using per-zoom average byte sizes. - static func estimatedDownloadSize( + nonisolated static func estimatedDownloadSize( bounds: MLNCoordinateBounds, minZoom: Int, maxZoom: Int, diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index 4bee79de8..e85ac8442 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -9,9 +9,9 @@ struct LayersMenu: View { var body: some View { VStack(spacing: 0) { ForEach(MapStyleSelection.allCases, id: \.self) { style in - let isDisabled = style.requiresNetwork - && !appState.offlineMapService.isNetworkAvailable - && !appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer) + let isDisabled = !appState.offlineMapService.isNetworkAvailable + && (style.requiresNetwork + || !appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer)) Button { selection = style diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 3133ea979..3b4230ba7 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -22,7 +22,7 @@ private enum MetalLayerScaleFix { let selector = NSSelectorFromString("setDrawableSize:") guard metalView.responds(to: selector) else { return } - let originalClass: AnyClass = object_getClass(metalView)! + guard let originalClass: AnyClass = object_getClass(metalView) else { return } let name = "_MC1FixedScale_\(NSStringFromClass(originalClass))" let fixedClass: AnyClass @@ -214,8 +214,6 @@ struct MC1MapView: UIViewRepresentable { coordinator.onMapTap = onMapTap coordinator.onCameraRegionChange = onCameraRegionChange coordinator.setIsStyleLoaded = { isStyleLoaded.wrappedValue = $0 } - let pointsChanged = coordinator.currentPoints != points - let linesChanged = coordinator.currentLines != lines coordinator.currentPoints = points coordinator.currentLines = lines @@ -243,16 +241,20 @@ struct MC1MapView: UIViewRepresentable { } } - // Update data layers (only when style is loaded, data changed, and not mid-gesture) + // 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 pointsChanged { + if coordinator.lastAppliedPoints != points { coordinator.updatePointSource(mapView: mapView) + coordinator.lastAppliedPoints = points } - if linesChanged { + if coordinator.lastAppliedLines != lines { coordinator.updateLineSource(mapView: mapView) + coordinator.lastAppliedLines = lines } if coordinator.currentShowLabels != showLabels { coordinator.currentShowLabels = showLabels @@ -350,6 +352,8 @@ extension MC1MapView { var currentMapStyle: MapStyleSelection? var currentPoints: [MapPoint] = [] var currentLines: [MapLine] = [] + var lastAppliedPoints: [MapPoint] = [] + var lastAppliedLines: [MapLine] = [] var clusterSource: MLNShapeSource? var fixedSource: MLNShapeSource? @@ -359,9 +363,11 @@ extension MC1MapView { isStyleLoaded = true setIsStyleLoaded?(true) - // Clear stale source references from the previous style. + // Clear stale source/state references from the previous style. clusterSource = nil fixedSource = nil + lastAppliedPoints = [] + lastAppliedLines = [] PinSpriteRenderer.renderAll(into: style) setupRasterSources(style: style, mapView: mapView) diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 808de1a61..691db5ae0 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -224,6 +224,7 @@ private struct RegionPickerSheet: View { .disabled( regionName.isEmpty || isDownloading || exceedsAvailableSpace || !appState.offlineMapService.isNetworkAvailable + || selectionBounds == nil ) } } From 96cb3585e198eb7353a98d1ac93cec0eda199508 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:10:23 -0700 Subject: [PATCH 43/55] fix(map): address PR review findings - Fix callout popover not dismissing when tapping badge text layers - Add name label layers to tap hit-test so tapping a name pill selects the contact - Re-render pin sprites on each style reload to fix stale colors after dark/light switch - Reset label visibility flag on style reload so user preference is reapplied - Derive mini-map camera version from coordinates so it recenters on refresh - Preserve user-paused offline downloads across app foreground cycles - Throw on nil style URL in offline download instead of silently skipping - Add NWPathMonitor onTermination handler for proper teardown - Add VoiceOver announcement and top padding on offline badge - Remove .controlSize(.small) from callout buttons for HIG tap targets - Allow re-analysis in Line of Sight after results exist - Use Measurement formatter for elevation display in LOS views - Add accessibility labels on comparison row triangle indicators - Fix refresh button layout shift and add accessibility label - Add safe area padding on trace path results banner - Extract computed property sub-views to View structs - Add OpenTopoMap b/c subdomain round-robin --- MC1/Services/OfflineMapService.swift | 14 ++++++-- MC1/Views/Contacts/ComparisonRowView.swift | 3 ++ MC1/Views/Contacts/ContactDetailView.swift | 2 +- .../TracePathMap/TracePathMapView.swift | 28 ++++++++++----- .../TracePathMap/TracePathMapViewModel.swift | 2 +- MC1/Views/LineOfSight/LineOfSightView.swift | 4 ++- MC1/Views/LineOfSight/PointRowView.swift | 5 ++- MC1/Views/LineOfSight/RepeaterRowView.swift | 5 ++- MC1/Views/Map/ContactCalloutContent.swift | 6 ++-- MC1/Views/Map/MC1MapView+Layers.swift | 2 +- MC1/Views/Map/MC1MapView.swift | 16 ++++++--- MC1/Views/Map/MapCanvasView.swift | 35 ++++++++++++++----- MC1/Views/Map/MapContentView.swift | 9 +++-- MC1/Views/Map/MapTileURLs.swift | 2 ++ MC1/Views/Map/MapView.swift | 11 +++--- MC1/Views/Map/OfflineBadge.swift | 6 ++++ MC1/Views/Map/PinSpriteRenderer.swift | 24 +++++-------- 17 files changed, 117 insertions(+), 57 deletions(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 1c8e11a33..11725e562 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -52,11 +52,14 @@ struct OfflinePackMetadata: Codable { 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)" } } } @@ -80,9 +83,12 @@ final class OfflineMapService { 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)) @@ -224,8 +230,7 @@ final class OfflineMapService { for layer in layers { guard let styleURL = layer.styleURL else { - Self.logger.error("Missing style URL for layer: \(layer.rawValue)") - continue + throw OfflineMapError.missingStyleResource(layer) } let region = MLNTilePyramidOfflineRegion( @@ -273,18 +278,21 @@ final class OfflineMapService { } 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 ?? [] { - if pack.state == .inactive { + let packID = ObjectIdentifier(pack) + if pack.state == .inactive, !userPausedPackIDs.contains(packID) { pack.resume() } } diff --git a/MC1/Views/Contacts/ComparisonRowView.swift b/MC1/Views/Contacts/ComparisonRowView.swift index 24ac0ba0f..566243d01 100644 --- a/MC1/Views/Contacts/ComparisonRowView.swift +++ b/MC1/Views/Contacts/ComparisonRowView.swift @@ -25,6 +25,9 @@ struct ComparisonRowView: View { 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("%") diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index e442cef19..4190babf4 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -764,7 +764,7 @@ private struct ContactLocationSection: View { center: currentContact.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) )), - cameraRegionVersion: 1, + cameraRegionVersion: currentContact.latitude.hashValue ^ currentContact.longitude.hashValue, onPointTap: nil, onMapTap: nil, onCameraRegionChange: nil diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index 69e84bd63..dcb3804d0 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -31,12 +31,15 @@ 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 @@ -147,15 +150,21 @@ struct TracePathMapView: View { .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("•") Text(Measurement(value: distance, unit: UnitLength.meters), format: .measurement(width: .abbreviated, usage: .road)) @@ -168,14 +177,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( @@ -187,5 +198,4 @@ struct TracePathMapView: View { } .background(.regularMaterial) } - } diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index eca16ba27..8fd09ca43 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -202,7 +202,7 @@ final class TracePathMapViewModel { } func generatePathName() -> String { - traceViewModel?.generatePathName() ?? "Path" + traceViewModel?.generatePathName() ?? L10n.Contacts.Contacts.Trace.Map.defaultPathName } // MARK: - Overlay Management diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index a832ec92f..2a98cd20b 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -222,6 +222,8 @@ 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() @@ -596,7 +598,7 @@ private struct AnalyzeButton: View { } .liquidGlassProminentButtonStyle() .controlSize(.large) - .disabled(viewModel.isAnalyzing || hasAnalysisResult) + .disabled(viewModel.isAnalyzing) } } diff --git a/MC1/Views/LineOfSight/PointRowView.swift b/MC1/Views/LineOfSight/PointRowView.swift index e8d0653ba..c94fd256a 100644 --- a/MC1/Views/LineOfSight/PointRowView.swift +++ b/MC1/Views/LineOfSight/PointRowView.swift @@ -44,7 +44,10 @@ struct PointRowView: View { .foregroundStyle(.secondary) } } else if let elevation = point.groundElevation { - Text("\(Int(elevation) + point.additionalHeight)m") + Text(Measurement( + value: Double(Int(elevation) + point.additionalHeight), + unit: UnitLength.meters + ).formatted()) .font(.caption) .foregroundStyle(.secondary) } diff --git a/MC1/Views/LineOfSight/RepeaterRowView.swift b/MC1/Views/LineOfSight/RepeaterRowView.swift index 379a120d7..3abcf2bd8 100644 --- a/MC1/Views/LineOfSight/RepeaterRowView.swift +++ b/MC1/Views/LineOfSight/RepeaterRowView.swift @@ -30,7 +30,10 @@ struct RepeaterRowView: View { if let elevation = viewModel.repeaterGroundElevation { let totalHeight = Int(elevation) + (viewModel.repeaterPoint?.additionalHeight ?? 0) - Text("\(totalHeight)m") + Text(Measurement( + value: Double(totalHeight), + unit: UnitLength.meters + ).formatted()) .font(.caption) .foregroundStyle(.secondary) } diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index ae79492e1..c2b96afe9 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 @@ -26,18 +26,16 @@ struct ContactCalloutContent: View { VStack(spacing: 6) { Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) .buttonStyle(.bordered) - .controlSize(.small) if contact.type == .chat || contact.type == .room { Button(L10n.Map.Map.Callout.message, systemImage: "message.fill", action: onMessage) .buttonStyle(.bordered) - .controlSize(.small) } } .frame(maxWidth: .infinity) } .padding(12) - .frame(width: 160) + .frame(minWidth: 160) } // MARK: - Computed Properties diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index d31e66ab0..30f601be2 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -303,7 +303,7 @@ extension MC1MapView.Coordinator { let topoSource = MLNRasterTileSource( identifier: MapSourceID.topoTiles, - tileURLTemplates: [MapTileURLs.openTopoMapA], + tileURLTemplates: [MapTileURLs.openTopoMapA, MapTileURLs.openTopoMapB, MapTileURLs.openTopoMapC], options: [ .tileSize: 256, .maximumZoomLevel: 17, diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 3b4230ba7..d9fe8fe0c 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -364,10 +364,13 @@ extension MC1MapView { 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) @@ -444,10 +447,13 @@ extension MC1MapView { return } - // 2. Check point layers (both clustered and fixed) + // 2. Check point and name label layers (both clustered and fixed) let pointFeatures = mapView.visibleFeatures( at: point, - styleLayerIdentifiers: [MapLayerID.unclusteredIcons, MapLayerID.fixedIcons] + 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, @@ -461,13 +467,15 @@ extension MC1MapView { return } - // 3. Check badge text layers + // 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 { - return // absorb tap on badges + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + onMapTap?(coordinate) + return } // 4. Map background tap diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index ed0fdfa28..699044822 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -37,7 +37,15 @@ struct MapCanvasView: View { // Floating controls VStack { Spacer() - mapControls + MapCanvasControls( + isNorthLocked: $viewModel.isNorthLocked, + showingLayersMenu: $viewModel.showingLayersMenu, + showLabels: $showLabels, + contactsEmpty: viewModel.contactsWithLocation.isEmpty, + onLocationTap: { onCenterOnUser() }, + onClearSelection: onClearSelection, + onCenterAll: { viewModel.centerOnAllContacts() } + ) } // Layers menu overlay @@ -68,23 +76,34 @@ struct MapCanvasView: View { } } - // MARK: - Map Controls +} + +// MARK: - Map Controls - private var mapControls: some View { +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: { onCenterOnUser() }, - showingLayersMenu: $viewModel.showingLayersMenu, + onLocationTap: onLocationTap, + showingLayersMenu: $showingLayersMenu, topContent: { - NorthLockButton(isNorthLocked: $viewModel.isNorthLocked) + NorthLockButton(isNorthLocked: $isNorthLocked) } ) { LabelsToggleButton(showLabels: $showLabels) CenterAllButton( - isEmpty: viewModel.contactsWithLocation.isEmpty, + isEmpty: contactsEmpty, onClearSelection: onClearSelection, - onCenterAll: { viewModel.centerOnAllContacts() } + onCenterAll: onCenterAll ) } } diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index a28bdfd7c..7825e9376 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -59,14 +59,17 @@ struct MapContentView: View { ProgressView() .scaleEffect(1.5) } else if viewModel.isLoading { - loadingOverlay + MapLoadingOverlay() } } } - // MARK: - Loading Overlay +} + +// MARK: - Loading Overlay - private var loadingOverlay: some View { +private struct MapLoadingOverlay: View { + var body: some View { ZStack { Color.black.opacity(0.1) ProgressView() diff --git a/MC1/Views/Map/MapTileURLs.swift b/MC1/Views/Map/MapTileURLs.swift index b9d53f170..466a34760 100644 --- a/MC1/Views/Map/MapTileURLs.swift +++ b/MC1/Views/Map/MapTileURLs.swift @@ -3,4 +3,6 @@ enum MapTileURLs { 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 aa9c4144a..6d8e8c429 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -83,18 +83,19 @@ private struct MapRefreshButton: View { var viewModel: MapViewModel var body: some View { - Button { + Button(L10n.Map.Map.Controls.refresh, systemImage: "arrow.clockwise") { Task { await viewModel.loadContactsWithLocation() } - } label: { + } + .labelStyle(.iconOnly) + .disabled(viewModel.isLoading) + .opacity(viewModel.isLoading ? 0 : 1) + .overlay { if viewModel.isLoading { ProgressView() - } else { - Image(systemName: "arrow.clockwise") } } - .disabled(viewModel.isLoading) } } diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift index e9d6d4e14..384260c3b 100644 --- a/MC1/Views/Map/OfflineBadge.swift +++ b/MC1/Views/Map/OfflineBadge.swift @@ -1,3 +1,4 @@ +import Accessibility import SwiftUI // MARK: - Offline Badge @@ -10,7 +11,12 @@ struct OfflineBadge: View { .padding(.horizontal) .padding(.vertical, 6) .background(.ultraThinMaterial, in: .capsule) + .accessibilityAddTraits(.updatesFrequently) .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 index 10603b3f7..80330ed84 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -14,23 +14,17 @@ enum PinSpriteRenderer { /// 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) { - let images: [String: UIImage] - if let cached = cachedImages { - images = cached - } else { - 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 - images = rendered + 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 images { + for (name, image) in rendered { style.setImage(image, forName: name) } } From 28a411cfd754c4392c71cad61c85bbbc89ccacca Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:10:47 -0700 Subject: [PATCH 44/55] chore(l10n): add accessibility and fallback strings - Add comparison row increased/decreased labels for VoiceOver - Add refresh button accessibility label - Add default path name fallback - Translations for all 9 languages - Regenerate L10n.swift via SwiftGen --- MC1/Resources/Generated/L10n.swift | 10 ++++++++++ MC1/Resources/Localization/de.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/de.lproj/Map.strings | 3 +++ MC1/Resources/Localization/en.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/en.lproj/Map.strings | 3 +++ MC1/Resources/Localization/es.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/es.lproj/Map.strings | 3 +++ MC1/Resources/Localization/fr.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/fr.lproj/Map.strings | 3 +++ MC1/Resources/Localization/nl.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/nl.lproj/Map.strings | 3 +++ MC1/Resources/Localization/pl.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/pl.lproj/Map.strings | 3 +++ MC1/Resources/Localization/ru.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/ru.lproj/Map.strings | 3 +++ MC1/Resources/Localization/uk.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/uk.lproj/Map.strings | 3 +++ .../Localization/zh-Hans.lproj/Contacts.strings | 7 +++++++ MC1/Resources/Localization/zh-Hans.lproj/Map.strings | 3 +++ 19 files changed, 100 insertions(+) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index ed65912de..dd34fec1b 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 { @@ -1776,6 +1782,8 @@ public enum L10n { 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 @@ -2037,6 +2045,8 @@ public enum L10n { 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) diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 24fdf3388..c01b76aa1 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 47cec2259..a59abe39c 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 0996595bf..a2b9ca893 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 ff96a4bbd..eeae59e81 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index defbd6393..4defd58b7 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 a3e3faf08..962f8970b 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 1235448c2..987ff347b 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 8d29d5345..fd10d4ca2 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index bc5b01ce9..de4875284 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 eecc95b85..02b271a67 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index b7562c990..03bd9459e 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -910,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 */ @@ -939,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 ac7cc7a76..7f957acb4 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index d9d6a1533..7fcac86b1 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -910,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 */ @@ -939,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 b814dee3e..f641568f3 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index b7f885ce8..17559d38c 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -910,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 */ @@ -939,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 1d0dc08d2..18b3c26f0 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 99fe56773..3e50f4c62 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -923,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 */ @@ -952,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 e1ef99aa2..57b2246bf 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -34,6 +34,9 @@ /* 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 */ From 2fa4beb54f6bf4823fbd3352cf9318da74002b54 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:18:50 -0700 Subject: [PATCH 45/55] fix(map): update estimated size on pinch-to-zoom in region picker - Add .gesturePinch to userGestureReasons so onCameraRegionChange fires during pinch-to-zoom, not just pan or double-tap zoom --- MC1/Views/Map/MC1MapView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index d9fe8fe0c..27de1094c 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -392,7 +392,7 @@ extension MC1MapView { // MARK: - Region changes private static let userGestureReasons: MLNCameraChangeReason = [ - .gesturePan, .gestureZoomIn, .gestureZoomOut, + .gesturePan, .gesturePinch, .gestureZoomIn, .gestureZoomOut, .gestureRotate, .gestureTilt, .gestureOneFingerZoom ] From 2cbe574003008f9187224bf7c7dd2b6e64f6982b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:03:19 -0700 Subject: [PATCH 46/55] fix(offline-maps): correct vector tile size estimates Previous constants (2-12 KB/tile) reflected global averages skewed by empty ocean tiles. Populated land regions average 30-150 KB/tile at z10-14. Also add 500 KB overhead for non-tile resources (style JSON, sprites, glyph PBFs) that were previously excluded from the estimate. --- MC1/Services/OfflineMapService.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 11725e562..9e500a23f 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -313,10 +313,11 @@ final class OfflineMapService { let bytesPerTile: [Int: Int64] switch layer { case .base: - // Average compressed vector tile sizes for OpenFreeMap (OpenMapTiles schema, max z14). + // OpenFreeMap vector tiles (OpenMapTiles schema, max z14). + // Populated land regions average 30-150 KB per tile at these zooms. bytesPerTile = [ - 10: 2_000, 11: 3_000, 12: 5_000, - 13: 8_000, 14: 12_000, + 10: 15_000, 11: 25_000, 12: 45_000, + 13: 70_000, 14: 100_000, ] case .topo: // OpenTopoMap PNG raster tiles (256px, max z17). @@ -327,6 +328,9 @@ final class OfflineMapService { ] } + // 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) @@ -341,7 +345,7 @@ final class OfflineMapService { let tileCount = (abs(xMax - xMin) + 1) * (abs(yMax - yMin) + 1) total += Int64(tileCount) * (bytesPerTile[z] ?? 10_000) } - return total + return total + overhead } private func excludeDatabaseFromBackup() { From 6541d533f1e54f22574eab8b9494ebeca0b9c10d Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:50:45 -0700 Subject: [PATCH 47/55] fix(map): restore bestAvailableLocation for recenter - Use bestAvailableLocation in MapView and TracePathMapToolbarView - Recenter was silently failing when phone GPS denied but radio had a fix --- .../Contacts/TracePathMap/TracePathMapToolbarView.swift | 6 ++++-- MC1/Views/Map/MapView.swift | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift index 002652fd4..165974fff 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapToolbarView.swift @@ -1,4 +1,5 @@ import MapKit +import MapLibre import SwiftUI import MC1Services @@ -16,7 +17,7 @@ struct TracePathMapToolbarView: View { Spacer() MapControlsToolbar( onLocationTap: { - if let location = appState.locationService.currentLocation { + if let location = appState.bestAvailableLocation { mapViewModel.setCameraRegion(MKCoordinateRegion( center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) @@ -51,7 +52,8 @@ struct TracePathMapToolbarView: View { if mapViewModel.showingLayersMenu { LayersMenu( selection: $mapStyleSelection, - isPresented: $mapViewModel.showingLayersMenu + isPresented: $mapViewModel.showingLayersMenu, + viewportBounds: mapViewModel.cameraRegion?.toMLNCoordinateBounds() ) .padding(.trailing, 16) .padding(.bottom, 160) diff --git a/MC1/Views/Map/MapView.swift b/MC1/Views/Map/MapView.swift index 6d8e8c429..73a64f4ec 100644 --- a/MC1/Views/Map/MapView.swift +++ b/MC1/Views/Map/MapView.swift @@ -71,7 +71,7 @@ struct MapView: View { } private func centerOnUserLocation() { - guard let location = appState.locationService.currentLocation else { return } + guard let location = appState.bestAvailableLocation else { return } let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) viewModel.setCameraRegion(MKCoordinateRegion(center: location.coordinate, span: span)) } From 409e1c809c4f108dd693f658a0cc4956b8d46e02 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:50:53 -0700 Subject: [PATCH 48/55] fix(offline): force liberty style offline, check viewport coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fall back to Liberty style URL when offline (packs only cache that style, so dark mode went blank) - Thread isOffline through MC1MapView to field-use callers - Store pack bounds in OfflinePack, check viewport overlap in LayersMenu - Add MKCoordinateRegion → MLNCoordinateBounds conversion - Add L10n strings for disabled style hints (all 9 locales) --- ...LLocationCoordinate2D+BoundingRegion.swift | 16 +++++++++++++ MC1/Resources/Generated/L10n.swift | 6 +++++ .../Localization/de.lproj/Map.strings | 9 ++++++++ .../Localization/en.lproj/Map.strings | 9 ++++++++ .../Localization/es.lproj/Map.strings | 9 ++++++++ .../Localization/fr.lproj/Map.strings | 9 ++++++++ .../Localization/nl.lproj/Map.strings | 9 ++++++++ .../Localization/pl.lproj/Map.strings | 9 ++++++++ .../Localization/ru.lproj/Map.strings | 9 ++++++++ .../Localization/uk.lproj/Map.strings | 9 ++++++++ .../Localization/zh-Hans.lproj/Map.strings | 9 ++++++++ MC1/Services/OfflineMapService.swift | 17 ++++++++++++++ MC1/Views/Contacts/ContactDetailView.swift | 2 ++ .../TracePathMap/TracePathMapView.swift | 6 +++-- MC1/Views/LineOfSight/LineOfSightView.swift | 4 +++- MC1/Views/Map/LayersMenu.swift | 23 ++++++++++++++++++- MC1/Views/Map/MC1MapView.swift | 3 ++- MC1/Views/Map/MapCanvasView.swift | 4 +++- MC1/Views/Map/MapContentView.swift | 6 +++++ MC1/Views/Map/MapStyleSelection.swift | 12 +++++++--- 20 files changed, 171 insertions(+), 9 deletions(-) diff --git a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift index 2f7e6cc6b..41d8f9955 100644 --- a/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift +++ b/MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift @@ -1,5 +1,6 @@ import CoreLocation import MapKit +import MapLibre extension Array where Element == CLLocationCoordinate2D { /// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding. @@ -28,3 +29,18 @@ extension Array where Element == CLLocationCoordinate2D { ) } } + +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/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index dd34fec1b..a349be211 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2115,6 +2115,12 @@ public enum L10n { public static let label = L10n.tr("Map", "map.offlineBadge.label", fallback: "Offline") } public enum Style { + /// 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 diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index a59abe39c..124958da1 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index eeae59e81..ef628d3c2 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 962f8970b..a4c36173e 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index fd10d4ca2..3b85d227b 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index 02b271a67..72e00ad17 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 7f957acb4..826b2684f 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index f641568f3..809ab7447 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 18b3c26f0..26add6294 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index 57b2246bf..aa8d99ef5 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -48,6 +48,15 @@ /* 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 /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 9e500a23f..3dac1a910 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -151,6 +151,12 @@ final class OfflineMapService { 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) }) @@ -368,6 +374,7 @@ struct OfflinePack: Identifiable { let layer: OfflineMapLayer let completedFraction: Double let downloadSpeed: Int64? + let bounds: MLNCoordinateBounds? private let progress: MLNOfflinePackProgress private let state: MLNOfflinePackState @@ -381,6 +388,7 @@ struct OfflinePack: Identifiable { self.mlnPack = pack self.progress = pack.progress self.state = pack.state + self.bounds = (pack.region as? MLNTilePyramidOfflineRegion)?.bounds let rawFraction: Double if state == .complete { @@ -404,3 +412,12 @@ struct OfflinePack: Identifiable { } } } + +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/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 4190babf4..9ebb394eb 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -736,6 +736,7 @@ private struct ContactInfoSection: View { } private struct ContactLocationSection: View { + @Environment(\.appState) private var appState @Environment(\.colorScheme) private var colorScheme let currentContact: ContactDTO @@ -756,6 +757,7 @@ private struct ContactLocationSection: View { lines: [], mapStyle: .standard, isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: false, showsUserLocation: false, isInteractive: false, diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift index dcb3804d0..d81da5783 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapView.swift @@ -124,6 +124,7 @@ struct TracePathMapView: View { lines: mapViewModel.mapLines, mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: showLabels, showsUserLocation: true, isInteractive: true, @@ -194,8 +195,9 @@ private struct TracePathEmptyState: View { systemImage: "map", description: Text(L10n.Contacts.Contacts.Trace.Map.Empty.description) ) - Spacer() + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 16)) + .padding() } - .background(.regularMaterial) } } diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 2a98cd20b..177c1b709 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -419,6 +419,7 @@ private struct LOSMapCanvasView: View { lines: viewModel.mapLines, mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: showLabels, showsUserLocation: true, isInteractive: true, @@ -489,7 +490,8 @@ private struct LOSMapCanvasView: View { Spacer() LayersMenu( selection: $mapStyleSelection, - isPresented: $showingMapStyleMenu + isPresented: $showingMapStyleMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() ) .padding(.trailing) } diff --git a/MC1/Views/Map/LayersMenu.swift b/MC1/Views/Map/LayersMenu.swift index e85ac8442..4f577cd94 100644 --- a/MC1/Views/Map/LayersMenu.swift +++ b/MC1/Views/Map/LayersMenu.swift @@ -1,3 +1,4 @@ +import MapLibre import SwiftUI /// Dropdown menu for selecting map layers @@ -5,13 +6,14 @@ 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 - || !appState.offlineMapService.hasCompletedPack(for: style.offlineMapLayer)) + || !hasOfflineCoverage(for: style)) Button { selection = style @@ -32,6 +34,7 @@ struct LayersMenu: View { .padding(.vertical, 12) } .disabled(isDisabled) + .accessibilityHint(isDisabled ? disabledReason(for: style) : "") if style != MapStyleSelection.allCases.last { Divider() @@ -41,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 + } } } diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index 27de1094c..496b29bba 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -134,6 +134,7 @@ struct MC1MapView: UIViewRepresentable { let lines: [MapLine] let mapStyle: MapStyleSelection let isDarkMode: Bool + var isOffline: Bool = false // Configuration let showLabels: Bool @@ -219,7 +220,7 @@ struct MC1MapView: UIViewRepresentable { // 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) + let newStyleURL = mapStyle.styleURL(isDarkMode: isDarkMode, isOffline: isOffline) if coordinator.lastAppliedStyleURL != newStyleURL { coordinator.lastAppliedStyleURL = newStyleURL coordinator.isStyleLoaded = false diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index 699044822..daf9dd7f7 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -1,3 +1,4 @@ +import MapLibre import SwiftUI import MC1Services @@ -66,7 +67,8 @@ struct MapCanvasView: View { Spacer() LayersMenu( selection: $mapStyleSelection, - isPresented: $viewModel.showingLayersMenu + isPresented: $viewModel.showingLayersMenu, + viewportBounds: viewModel.cameraRegion?.toMLNCoordinateBounds() ) .padding(.trailing, 72) .padding(.bottom) diff --git a/MC1/Views/Map/MapContentView.swift b/MC1/Views/Map/MapContentView.swift index 7825e9376..3d3ed1cf6 100644 --- a/MC1/Views/Map/MapContentView.swift +++ b/MC1/Views/Map/MapContentView.swift @@ -3,6 +3,7 @@ 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 @@ -19,6 +20,7 @@ struct MapContentView: View { lines: [], mapStyle: mapStyleSelection, isDarkMode: colorScheme == .dark, + isOffline: !appState.offlineMapService.isNetworkAvailable, showLabels: showLabels, showsUserLocation: true, isInteractive: true, @@ -36,6 +38,10 @@ struct MapContentView: View { }, onCameraRegionChange: { region in viewModel.cameraRegion = region + if selectedCalloutContact != nil { + selectedCalloutContact = nil + selectedPointScreenPosition = nil + } }, isStyleLoaded: $isStyleLoaded ) diff --git a/MC1/Views/Map/MapStyleSelection.swift b/MC1/Views/Map/MapStyleSelection.swift index 987dd46b0..40cfdc50f 100644 --- a/MC1/Views/Map/MapStyleSelection.swift +++ b/MC1/Views/Map/MapStyleSelection.swift @@ -31,8 +31,14 @@ enum MapStyleSelection: String, CaseIterable, Hashable { } /// All styles use the same base vector style; satellite/topo add raster overlays at runtime. - func styleURL(isDarkMode: Bool) -> URL { - let url = isDarkMode ? MapTileURLs.openFreeMapDark : MapTileURLs.openFreeMapLiberty - return URL(string: url)! + /// 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 } } From a722d1a298bc75a01741906424d5a2c78532fa88 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:50:59 -0700 Subject: [PATCH 49/55] fix(map): accessibility and code quality cleanup - Group callout content for VoiceOver, add hints to action buttons - Bump PointRowButtons icon from 16pt to 22pt for gloved use - Add isolated deinit to LineOfSightViewModel - Dismiss callout on camera move (stale anchor after rotation) - Use canonical SNRQuality instead of private 3-tier enum - Drop C-style %012d for Swift string padding - Use Measurement formatter in HeightEditorGrid instead of hardcoded "m" - Make TracePathEmptyState a card overlay instead of full-screen material - Document nonisolated(unsafe) NSExpression safety - Fix OfflineBadge trait, bump label sprite font to 12pt --- .../TracePathMap/TracePathMapViewModel.swift | 31 +++++++------------ MC1/Views/LineOfSight/HeightEditorGrid.swift | 6 ++-- .../LineOfSight/LineOfSightViewModel.swift | 7 +++++ .../LineOfSight/PointRowButtonsView.swift | 2 +- MC1/Views/Map/ContactCalloutContent.swift | 3 ++ MC1/Views/Map/MC1MapView+Layers.swift | 1 + MC1/Views/Map/OfflineBadge.swift | 2 +- MC1/Views/Map/PinSpriteRenderer.swift | 2 +- 8 files changed, 28 insertions(+), 26 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 8fd09ca43..69efcd74f 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -256,8 +256,7 @@ final class TracePathMapViewModel { let hopIndex = (pathIndex ?? 0) + 1 if hopIndex < result.hops.count { let hop = result.hops[hopIndex] - let quality = signalQuality(snr: hop.snr) - let style = lineStyle(for: quality) + let style = lineStyle(for: hop.snr) updatedLines.append(MapLine( id: line.id, @@ -279,7 +278,10 @@ final class TracePathMapViewModel { let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) badgePoints.append(MapPoint( - id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", hopIndex))") ?? UUID(), + id: { + let padded = String(repeating: "0", count: 12) + "\(hopIndex)" + return UUID(uuidString: "00000000-0000-0000-0000-\(String(padded.suffix(12)))") ?? UUID() + }(), coordinate: mid, pinStyle: .badge, label: nil, @@ -299,23 +301,12 @@ final class TracePathMapViewModel { // MARK: - Signal Quality - private enum SignalQuality { - case untraced, weak, medium, good - } - - /// 3-tier scale with wider thresholds (±5 dB) matching SNRQuality doc convention for path segments - private func signalQuality(snr: Double) -> SignalQuality { - if snr < -5 { return .weak } - if snr < 5 { return .medium } - return .good - } - - private func lineStyle(for quality: SignalQuality) -> MapLine.LineStyle { - switch quality { - case .untraced: .traceUntraced - case .weak: .traceWeak - case .medium: .traceMedium - case .good: .traceGood + 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 } } diff --git a/MC1/Views/LineOfSight/HeightEditorGrid.swift b/MC1/Views/LineOfSight/HeightEditorGrid.swift index 8a1019182..029c3b302 100644 --- a/MC1/Views/LineOfSight/HeightEditorGrid.swift +++ b/MC1/Views/LineOfSight/HeightEditorGrid.swift @@ -16,7 +16,7 @@ struct HeightEditorGrid: View { Spacer() - Text("\(Int(groundElevation)) m") + Text(Measurement(value: groundElevation, unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) .font(.caption) .monospacedDigit() } @@ -41,7 +41,7 @@ struct HeightEditorGrid: View { Spacer() Stepper(value: $additionalHeight, in: range) { - Text("\(additionalHeight) m") + Text(Measurement(value: Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) .font(.caption) .monospacedDigit() } @@ -62,7 +62,7 @@ struct HeightEditorGrid: View { Spacer() - Text("\(Int(groundElevation) + additionalHeight) m") + Text(Measurement(value: groundElevation + Double(additionalHeight), unit: UnitLength.meters).formatted(.measurement(width: .abbreviated))) .font(.caption) .monospacedDigit() .bold() diff --git a/MC1/Views/LineOfSight/LineOfSightViewModel.swift b/MC1/Views/LineOfSight/LineOfSightViewModel.swift index 33a48af9a..32ff98e78 100644 --- a/MC1/Views/LineOfSight/LineOfSightViewModel.swift +++ b/MC1/Views/LineOfSight/LineOfSightViewModel.swift @@ -276,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 diff --git a/MC1/Views/LineOfSight/PointRowButtonsView.swift b/MC1/Views/LineOfSight/PointRowButtonsView.swift index 111d701d6..b3754a6a0 100644 --- a/MC1/Views/LineOfSight/PointRowButtonsView.swift +++ b/MC1/Views/LineOfSight/PointRowButtonsView.swift @@ -11,7 +11,7 @@ struct PointRowButtonsView: View { let onRelocate: () -> Void let onClear: () -> Void - @ScaledMetric(relativeTo: .body) private var iconButtonSize: CGFloat = 16 + private let iconButtonSize: CGFloat = 22 private var coordinate: CLLocationCoordinate2D? { switch pointID { diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index c2b96afe9..4dc401f11 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -19,6 +19,7 @@ struct ContactCalloutContent: View { .font(.subheadline) .foregroundStyle(.secondary) } + .accessibilityElement(children: .combine) Divider() @@ -26,10 +27,12 @@ struct ContactCalloutContent: View { VStack(spacing: 6) { Button(L10n.Map.Map.Callout.details, systemImage: "info.circle", action: onDetail) .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) if contact.type == .chat || contact.type == .room { Button(L10n.Map.Map.Callout.message, systemImage: "message.fill", action: onMessage) .buttonStyle(.bordered) + .accessibilityHint(contact.displayName) } } .frame(maxWidth: .infinity) diff --git a/MC1/Views/Map/MC1MapView+Layers.swift b/MC1/Views/Map/MC1MapView+Layers.swift index 30f601be2..094c46aaa 100644 --- a/MC1/Views/Map/MC1MapView+Layers.swift +++ b/MC1/Views/Map/MC1MapView+Layers.swift @@ -3,6 +3,7 @@ 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 diff --git a/MC1/Views/Map/OfflineBadge.swift b/MC1/Views/Map/OfflineBadge.swift index 384260c3b..f54a1d19e 100644 --- a/MC1/Views/Map/OfflineBadge.swift +++ b/MC1/Views/Map/OfflineBadge.swift @@ -11,7 +11,7 @@ struct OfflineBadge: View { .padding(.horizontal) .padding(.vertical, 6) .background(.ultraThinMaterial, in: .capsule) - .accessibilityAddTraits(.updatesFrequently) + .accessibilityAddTraits(.isStaticText) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) .padding(.trailing) .padding(.top) diff --git a/MC1/Views/Map/PinSpriteRenderer.swift b/MC1/Views/Map/PinSpriteRenderer.swift index 80330ed84..e78022811 100644 --- a/MC1/Views/Map/PinSpriteRenderer.swift +++ b/MC1/Views/Map/PinSpriteRenderer.swift @@ -250,7 +250,7 @@ enum PinSpriteRenderer { } private static func renderLabelSprite(text: String) -> UIImage { - let font = UIFont.systemFont(ofSize: 10, weight: .bold) + 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) From 2f780d43c70668de1fcd8a757ab7a33815876475 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:24:00 -0700 Subject: [PATCH 50/55] fix(map): guard repeater and room actions on connection state - Apply .radioDisabled to telemetry, management, and room join buttons - Previously only the chat message button had the connection guard --- MC1/Views/Map/ContactDetailSheet.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index c188789d2..1a535b687 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -93,12 +93,14 @@ struct ContactDetailSheet: View { } 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 { @@ -106,6 +108,7 @@ struct ContactDetailSheet: View { } label: { Label(L10n.Map.Map.Detail.Action.joinRoom, systemImage: "door.left.hand.open") } + .radioDisabled(for: appState.connectionState) case .chat: Button { From 94e12c508121bb6b6b7c8159f63e576328d6057e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:24:04 -0700 Subject: [PATCH 51/55] refactor(map): store path index structurally on MapLine - Add pathIndex property to MapLine instead of parsing from string ID - Replace hand-rolled UUID construction with UUID(hopIndex:) initializer --- .../TracePathMap/TracePathMapViewModel.swift | 27 +++++++++++++------ MC1/Views/Map/MapLine.swift | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift index 69efcd74f..43bb1b6f4 100644 --- a/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift +++ b/MC1/Views/Contacts/TracePathMap/TracePathMapViewModel.swift @@ -236,7 +236,8 @@ final class TracePathMapViewModel { id: "trace-\(index)", coordinates: [prevCoord, hopCoordinate], style: .traceUntraced, - opacity: 1.0 + opacity: 1.0, + pathIndex: index )) } @@ -252,8 +253,11 @@ final class TracePathMapViewModel { var updatedLines: [MapLine] = [] for line in mapLines { - let pathIndex = Int(line.id.replacing("trace-", with: "")) - let hopIndex = (pathIndex ?? 0) + 1 + 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 style = lineStyle(for: hop.snr) @@ -262,7 +266,8 @@ final class TracePathMapViewModel { id: line.id, coordinates: line.coordinates, style: style, - opacity: 1.0 + opacity: 1.0, + pathIndex: pathIndex )) // Badge at midpoint @@ -278,10 +283,7 @@ final class TracePathMapViewModel { let snrFormatted = hop.snr.formatted(.number.precision(.fractionLength(1))) badgePoints.append(MapPoint( - id: { - let padded = String(repeating: "0", count: 12) + "\(hopIndex)" - return UUID(uuidString: "00000000-0000-0000-0000-\(String(padded.suffix(12)))") ?? UUID() - }(), + id: UUID(hopIndex: hopIndex), coordinate: mid, pinStyle: .badge, label: nil, @@ -403,3 +405,12 @@ final class TracePathMapViewModel { setCameraRegion(region) } } + +private extension UUID { + /// Deterministic UUID for badge points keyed by hop index. + init(hopIndex: Int) { + let hex = String(hopIndex, radix: 16) + let padded = String(repeating: "0", count: max(0, 12 - hex.count)) + hex + self = UUID(uuidString: "00000000-0000-0000-0000-\(padded)") ?? UUID() + } +} diff --git a/MC1/Views/Map/MapLine.swift b/MC1/Views/Map/MapLine.swift index e31149ec2..59ff1ddd5 100644 --- a/MC1/Views/Map/MapLine.swift +++ b/MC1/Views/Map/MapLine.swift @@ -5,6 +5,7 @@ struct MapLine: Identifiable, Equatable { let coordinates: [CLLocationCoordinate2D] let style: LineStyle let opacity: Double + var pathIndex: Int? enum LineStyle: String, Hashable { case los From e379d709572c03bf03807c437acbcf5cb0b3af82 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:24:09 -0700 Subject: [PATCH 52/55] perf(map): download offline map layers concurrently - Use withThrowingTaskGroup so multiple layers start simultaneously - Pre-compute regions and contexts before spawning tasks --- MC1/Services/OfflineMapService.swift | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 3dac1a910..7c4f777df 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -234,31 +234,39 @@ final class OfflineMapService { 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)) + } - 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() + try await withThrowingTaskGroup(of: Void.self) { group in + for (region, context) in pendingPacks { + nonisolated(unsafe) let region = region + group.addTask { + 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() + } + } } } } + try await group.waitForAll() } loadPacks() updateDatabaseSize() From 2de70ceb000ff076120ad1708c8eeb73454bd7de Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:58:39 -0700 Subject: [PATCH 53/55] fix(a11y): add VoiceOver labels to overlay dismiss buttons - Dismiss overlay buttons on the map and LOS views had no accessibility label, making them invisible to VoiceOver - Added map.common.dismissOverlay key across all 9 languages --- MC1/Resources/Generated/L10n.swift | 2 ++ MC1/Resources/Localization/de.lproj/Map.strings | 1 + MC1/Resources/Localization/en.lproj/Map.strings | 1 + MC1/Resources/Localization/es.lproj/Map.strings | 1 + MC1/Resources/Localization/fr.lproj/Map.strings | 1 + MC1/Resources/Localization/nl.lproj/Map.strings | 1 + MC1/Resources/Localization/pl.lproj/Map.strings | 1 + MC1/Resources/Localization/ru.lproj/Map.strings | 1 + MC1/Resources/Localization/uk.lproj/Map.strings | 1 + MC1/Resources/Localization/zh-Hans.lproj/Map.strings | 1 + MC1/Views/LineOfSight/LineOfSightView.swift | 1 + MC1/Views/Map/MapCanvasView.swift | 1 + 12 files changed, 13 insertions(+) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index a349be211..96cfe53ca 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2031,6 +2031,8 @@ 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") } diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 124958da1..999085b82 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Fertig"; +"map.common.dismissOverlay" = "Schließen"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index ef628d3c2..443e3454f 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Done"; +"map.common.dismissOverlay" = "Dismiss"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index a4c36173e..cc3e6934c 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Listo"; +"map.common.dismissOverlay" = "Cerrar"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 3b85d227b..07a145d6a 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Terminé"; +"map.common.dismissOverlay" = "Fermer"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index 72e00ad17..f7857235b 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Klaar"; +"map.common.dismissOverlay" = "Sluiten"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 826b2684f..a5e5b553d 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Gotowe"; +"map.common.dismissOverlay" = "Zamknij"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 809ab7447..be54431dd 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; +"map.common.dismissOverlay" = "Закрыть"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 26add6294..cdf012004 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "Готово"; +"map.common.dismissOverlay" = "Закрити"; // MARK: - Map Controls diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index aa8d99ef5..3bcfa42e0 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -10,6 +10,7 @@ /* Location: MapView.swift - Purpose: Done button for sheets */ "map.common.done" = "完成"; +"map.common.dismissOverlay" = "关闭"; // MARK: - Map Controls diff --git a/MC1/Views/LineOfSight/LineOfSightView.swift b/MC1/Views/LineOfSight/LineOfSightView.swift index 177c1b709..856a8ca20 100644 --- a/MC1/Views/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/LineOfSight/LineOfSightView.swift @@ -483,6 +483,7 @@ private struct LOSMapCanvasView: View { Color.black.opacity(0.3).ignoresSafeArea() } .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) VStack { Spacer() diff --git a/MC1/Views/Map/MapCanvasView.swift b/MC1/Views/Map/MapCanvasView.swift index daf9dd7f7..2608408c4 100644 --- a/MC1/Views/Map/MapCanvasView.swift +++ b/MC1/Views/Map/MapCanvasView.swift @@ -60,6 +60,7 @@ struct MapCanvasView: View { .ignoresSafeArea() } .buttonStyle(.plain) + .accessibilityLabel(L10n.Map.Map.Common.dismissOverlay) VStack { Spacer() From 11ffb9061e1407f5f377d16b429277e506627ff9 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:58:47 -0700 Subject: [PATCH 54/55] fix(map): replace deprecated Text concat and fix CI concurrency error - Replace Text + Text("/s") with single interpolated Text in OfflinePackRow - Add nonisolated(unsafe) to context capture in task group to fix a sending-parameter error in Xcode 26.2's stricter region isolation checks --- MC1/Services/OfflineMapService.swift | 4 ++++ MC1/Views/Settings/OfflineMapSettingsView.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index 7c4f777df..b356b4013 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -252,7 +252,11 @@ final class OfflineMapService { try await withThrowingTaskGroup(of: Void.self) { group in for (region, context) in pendingPacks { + // Safety: both values are immutable after creation; safe to send across task boundary. + // MLNTilePyramidOfflineRegion is non-Sendable ObjC; Data is Sendable but the Xcode 26.2 + // compiler's region-based isolation still flags it without explicit transfer. nonisolated(unsafe) let region = region + nonisolated(unsafe) let context = context group.addTask { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in diff --git a/MC1/Views/Settings/OfflineMapSettingsView.swift b/MC1/Views/Settings/OfflineMapSettingsView.swift index 691db5ae0..2de6e5584 100644 --- a/MC1/Views/Settings/OfflineMapSettingsView.swift +++ b/MC1/Views/Settings/OfflineMapSettingsView.swift @@ -118,7 +118,7 @@ private struct OfflinePackRow: View { VStack(alignment: .trailing) { Text(Int64(pack.completedBytes), format: .byteCount(style: .file)) if let speed = pack.downloadSpeed, speed > 0 { - Text(speed, format: .byteCount(style: .file)) + Text("/s") + Text("\(speed, format: .byteCount(style: .file))/s") } } .foregroundStyle(.secondary) From 912f62f75851970ddc5bc4ebc08081e7c67378d7 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:07:07 -0700 Subject: [PATCH 55/55] fix(map): replace task group with sequential downloads for CI compat - Xcode 26.2's stricter sending-parameter checks reject captures from @MainActor context in group.addTask closures, even with nonisolated(unsafe) - Sequential download is sufficient for the typical 1-2 offline map layers --- MC1/Services/OfflineMapService.swift | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/MC1/Services/OfflineMapService.swift b/MC1/Services/OfflineMapService.swift index b356b4013..11db500d7 100644 --- a/MC1/Services/OfflineMapService.swift +++ b/MC1/Services/OfflineMapService.swift @@ -250,27 +250,17 @@ final class OfflineMapService { pendingPacks.append((region, context)) } - try await withThrowingTaskGroup(of: Void.self) { group in - for (region, context) in pendingPacks { - // Safety: both values are immutable after creation; safe to send across task boundary. - // MLNTilePyramidOfflineRegion is non-Sendable ObjC; Data is Sendable but the Xcode 26.2 - // compiler's region-based isolation still flags it without explicit transfer. - nonisolated(unsafe) let region = region - nonisolated(unsafe) let context = context - group.addTask { - 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() - } - } + 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() } } } - try await group.waitForAll() } loadPacks() updateDatabaseSize()