diff --git a/PocketMesh/Resources/Generated/L10n.swift b/PocketMesh/Resources/Generated/L10n.swift index 1d3df263e..83bcbbe47 100644 --- a/PocketMesh/Resources/Generated/L10n.swift +++ b/PocketMesh/Resources/Generated/L10n.swift @@ -1489,7 +1489,11 @@ public enum L10n { public static let direct = L10n.tr("Contacts", "contacts.route.direct", fallback: "Direct") /// Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Flood routing label public static let flood = L10n.tr("Contacts", "contacts.route.flood", fallback: "Flood") - /// Location: ContactRowView.swift - Purpose: Hops count display + /// Location: ContactRowView.swift - Purpose: Singular hop count display + public static func hop(_ p1: Int) -> String { + return L10n.tr("Contacts", "contacts.route.hop", p1, fallback: "%d hop") + } + /// Location: ContactRowView.swift - Purpose: Plural hops count display public static func hops(_ p1: Int) -> String { return L10n.tr("Contacts", "contacts.route.hops", p1, fallback: "%d hops") } @@ -3277,6 +3281,14 @@ public enum L10n { public static let exportFailed = L10n.tr("Settings", "diagnostics.error.exportFailed", fallback: "Failed to create export file") } } + public enum Geocoding { + /// Footer for geocoding settings section + public static let footer = L10n.tr("Settings", "geocoding.footer", fallback: "Resolves City and State information for nodes that advert with their GPS location enabled. We use Apple Maps for lookup, which requires an internet connection. This lookup only occurs when a new node location is heard. When disabled, no internet lookups will occur, but previously resolved locations will still be shown from cached lookups.") + /// Section header for geocoding + public static let header = L10n.tr("Settings", "geocoding.header", fallback: "Geocoding") + /// Toggle label for node location lookup + public static let nodeLocationLookup = L10n.tr("Settings", "geocoding.nodeLocationLookup", fallback: "Node Location Lookup") + } public enum InlineImages { /// Toggle label for auto-play GIFs public static let autoPlayGifs = L10n.tr("Settings", "inlineImages.autoPlayGifs", fallback: "Auto-play GIFs") diff --git a/PocketMesh/Resources/Localization/en.lproj/Contacts.strings b/PocketMesh/Resources/Localization/en.lproj/Contacts.strings index 7137eecec..55b7d4de4 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Contacts.strings @@ -60,7 +60,10 @@ /* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Direct routing label */ "contacts.route.direct" = "Direct"; -/* Location: ContactRowView.swift - Purpose: Hops count display */ +/* Location: ContactRowView.swift - Purpose: Singular hop count display */ +"contacts.route.hop" = "%d hop"; + +/* Location: ContactRowView.swift - Purpose: Plural hops count display */ "contacts.route.hops" = "%d hops"; // MARK: - Node Segments diff --git a/PocketMesh/Resources/Localization/en.lproj/Settings.strings b/PocketMesh/Resources/Localization/en.lproj/Settings.strings index b9212f9ad..e3ba57f98 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Settings.strings @@ -740,6 +740,18 @@ "location.notSharing" = "Not sharing"; +// MARK: - Geocoding Settings Section + +/* Section header for geocoding */ +"geocoding.header" = "Geocoding"; + +/* Toggle label for node location lookup */ +"geocoding.nodeLocationLookup" = "Node Location Lookup"; + +/* Footer for geocoding settings section */ +"geocoding.footer" = "Resolves City and State information for nodes that advert with their GPS location enabled. We use Apple Maps for lookup, which requires an internet connection. This lookup only occurs when a new node location is heard. When disabled, no internet lookups will occur, but previously resolved locations will still be shown from cached lookups."; + + // MARK: - Notification Settings Section /* Section header for notifications */ diff --git a/PocketMesh/Services/ReverseGeocodeCache.swift b/PocketMesh/Services/ReverseGeocodeCache.swift new file mode 100644 index 000000000..e36c696e1 --- /dev/null +++ b/PocketMesh/Services/ReverseGeocodeCache.swift @@ -0,0 +1,113 @@ +import CoreLocation +import Foundation + +actor ReverseGeocodeCache { + static let shared = ReverseGeocodeCache() + + private var cache: [String: String] = [:] + private var pendingKeys: Set = [] + private var queue: [(key: String, location: CLLocation, continuation: CheckedContinuation)] = [] + private var isProcessing = false + + private static var cacheFileURL: URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("reverse_geocode_cache.json") + } + + private init() { + cache = Self.loadFromDisk() + } + + static var isEnabled: Bool { + UserDefaults.standard.object(forKey: "geocodingEnabled") as? Bool ?? true + } + + func locality(for coordinate: CLLocationCoordinate2D) async -> String? { + let key = cacheKey(for: coordinate) + + if let cached = cache[key] { + return cached + } + + guard Self.isEnabled else { return nil } + guard !pendingKeys.contains(key) else { return nil } + pendingKeys.insert(key) + + let location = CLLocation( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + + return await withCheckedContinuation { continuation in + queue.append((key: key, location: location, continuation: continuation)) + processQueue() + } + } + + private func processQueue() { + guard !isProcessing, let next = queue.first else { return } + isProcessing = true + queue.removeFirst() + + Task { + let result = await geocode(key: next.key, location: next.location) + next.continuation.resume(returning: result) + isProcessing = false + pendingKeys.remove(next.key) + processQueue() + } + } + + private func geocode(key: String, location: CLLocation) async -> String? { + let geocoder = CLGeocoder() + do { + let placemarks = try await geocoder.reverseGeocodeLocation(location) + if let placemark = placemarks.first { + let result = formatLocality(from: placemark) + if let result { + cache[key] = result + saveToDisk() + } + return result + } + } catch {} + return nil + } + + private func formatLocality(from placemark: CLPlacemark) -> String? { + let city = placemark.locality + let state = placemark.administrativeArea + + switch (city, state) { + case let (city?, state?): + return "\(city), \(state)" + case let (city?, nil): + return city + case let (nil, state?): + return state + default: + return nil + } + } + + private func cacheKey(for coordinate: CLLocationCoordinate2D) -> String { + let lat = (coordinate.latitude * 100).rounded() / 100 + let lon = (coordinate.longitude * 100).rounded() / 100 + return "\(lat),\(lon)" + } + + // MARK: - Disk Persistence + + private static func loadFromDisk() -> [String: String] { + guard let data = try? Data(contentsOf: cacheFileURL), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { + return [:] + } + return decoded + } + + private func saveToDisk() { + guard let data = try? JSONEncoder().encode(cache) else { return } + try? data.write(to: Self.cacheFileURL, options: .atomic) + } +} diff --git a/PocketMesh/Views/Contacts/ContactDetailView.swift b/PocketMesh/Views/Contacts/ContactDetailView.swift index c57d00c45..b3f6047b7 100644 --- a/PocketMesh/Views/Contacts/ContactDetailView.swift +++ b/PocketMesh/Views/Contacts/ContactDetailView.swift @@ -88,6 +88,7 @@ struct ContactDetailView: View { @State private var navigateToSettings = false // QR sharing state @State private var showQRShareSheet = false + @State private var locality: String? // Ping state @State private var isPinging = false @State private var pingResult: PingResult? @@ -105,7 +106,8 @@ struct ContactDetailView: View { // Profile header ContactProfileSection( currentContact: currentContact, - contactTypeLabel: contactTypeLabel + locality: locality, + userLocation: appState.locationService.currentLocation ) // Quick actions @@ -168,6 +170,7 @@ struct ContactDetailView: View { onDelete: { showingDeleteAlert = true } ) } + .contentMargins(.top, 0, for: .scrollContent) .errorAlert($errorMessage) .navigationTitle(contactTypeLabel) .navigationBarTitleDisplayMode(.inline) @@ -194,6 +197,14 @@ struct ContactDetailView: View { .onAppear { nickname = currentContact.nickname ?? "" } + .task(id: "\(currentContact.id)-\(currentContact.latitude)-\(currentContact.longitude)") { + guard currentContact.hasLocation else { return } + let coordinate = CLLocationCoordinate2D( + latitude: currentContact.latitude, + longitude: currentContact.longitude + ) + locality = await ReverseGeocodeCache.shared.locality(for: coordinate) + } .task { pathViewModel.configure(appState: appState) { Task { @MainActor in @@ -469,7 +480,8 @@ private struct ContactDetailAvatarView: View { private struct ContactProfileSection: View { let currentContact: ContactDTO - let contactTypeLabel: String + let locality: String? + let userLocation: CLLocation? var body: some View { Section { @@ -477,32 +489,45 @@ private struct ContactProfileSection: View { ContactDetailAvatarView(contact: currentContact) VStack(spacing: 4) { - Text(currentContact.displayName) - .font(.title2) - .bold() + HStack(spacing: 6) { + Text(currentContact.displayName) + .font(.title2) + .bold() - Text(contactTypeLabel) - .font(.subheadline) - .foregroundStyle(.secondary) - - // Status indicators - HStack(spacing: 12) { if currentContact.isFavorite { - Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star.fill") - .font(.caption) + Image(systemName: "star.fill") .foregroundStyle(.yellow) + .font(.system(size: 13.2)) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.favorite) } if currentContact.isBlocked { - Label(L10n.Contacts.Contacts.Detail.blocked, systemImage: "hand.raised.fill") - .font(.caption) + Image(systemName: "hand.raised.fill") .foregroundStyle(.orange) + .font(.caption) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.blocked) } + } - if currentContact.hasLocation { - Label(L10n.Contacts.Contacts.Detail.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.green) + if locality != nil || distanceToContact != nil { + HStack(spacing: 4) { + if let locality { + Text(locality) + .font(.caption) + .foregroundStyle(.secondary) + } + + if locality != nil, distanceToContact != nil { + Text("·") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let distance = distanceToContact { + Text(distance) + .font(.caption) + .foregroundStyle(.secondary) + } } } } @@ -511,6 +536,23 @@ private struct ContactProfileSection: View { .listRowBackground(Color.clear) } } + + private var distanceToContact: String? { + guard let userLocation, currentContact.hasLocation else { return nil } + + let contactLocation = CLLocation( + latitude: currentContact.latitude, + longitude: currentContact.longitude + ) + let meters = userLocation.distance(from: contactLocation) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + + let formatted = measurement.formatted(.measurement( + width: .wide, + usage: .road + )) + return "\(formatted) away" + } } private struct ContactActionsSection: View { diff --git a/PocketMesh/Views/Contacts/ContactRowView.swift b/PocketMesh/Views/Contacts/ContactRowView.swift index 6590076ca..8ba0f2efb 100644 --- a/PocketMesh/Views/Contacts/ContactRowView.swift +++ b/PocketMesh/Views/Contacts/ContactRowView.swift @@ -9,6 +9,8 @@ struct ContactRowView: View { let index: Int let isTogglingFavorite: Bool + @State private var locality: String? + init( contact: ContactDTO, showTypeLabel: Bool = false, @@ -55,14 +57,25 @@ struct ContactRowView: View { RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) } - HStack(spacing: 8) { + HStack(spacing: 4) { // Show type label only in search results if showTypeLabel { Text(contactTypeLabel) .font(.caption) .foregroundStyle(.secondary) - Text("\u{00B7}") + Text("·") + .font(.caption) + .foregroundStyle(.secondary) + } + + // Locality from reverse geocode + if let locality { + Text(locality) + .font(.caption) + .foregroundStyle(.secondary) + + Text("·") .font(.caption) .foregroundStyle(.secondary) } @@ -71,20 +84,6 @@ struct ContactRowView: View { Text(routeLabel) .font(.caption) .foregroundStyle(.secondary) - - // Location indicator with optional distance - if contact.hasLocation { - Label(L10n.Contacts.Contacts.Row.location, systemImage: "location.fill") - .labelStyle(.iconOnly) - .font(.caption) - .foregroundStyle(.green) - - if let distance = distanceToContact { - Text(distance) - .font(.caption) - .foregroundStyle(.secondary) - } - } } } .alignmentGuide(.listRowSeparatorLeading) { dimensions in @@ -92,6 +91,14 @@ struct ContactRowView: View { } } .padding(.vertical, 4) + .task(id: "\(contact.id)-\(contact.latitude)-\(contact.longitude)") { + guard contact.hasLocation else { return } + let coordinate = CLLocationCoordinate2D( + latitude: contact.latitude, + longitude: contact.longitude + ) + locality = await ReverseGeocodeCache.shared.locality(for: coordinate) + } } @ViewBuilder @@ -119,25 +126,11 @@ struct ContactRowView: View { return L10n.Contacts.Contacts.Route.flood } else if contact.pathHopCount == 0 { return L10n.Contacts.Contacts.Route.direct + } else if contact.pathHopCount == 1 { + return L10n.Contacts.Contacts.Route.hop(contact.pathHopCount) } else { return L10n.Contacts.Contacts.Route.hops(contact.pathHopCount) } } - private var distanceToContact: String? { - guard let userLocation, contact.hasLocation else { return nil } - - let contactLocation = CLLocation( - latitude: contact.latitude, - longitude: contact.longitude - ) - let meters = userLocation.distance(from: contactLocation) - let measurement = Measurement(value: meters, unit: UnitLength.meters) - - let formattedDistance = measurement.formatted(.measurement( - width: .abbreviated, - usage: .road - )) - return L10n.Contacts.Contacts.Row.away(formattedDistance) - } } diff --git a/PocketMesh/Views/Settings/LocationSettingsView.swift b/PocketMesh/Views/Settings/LocationSettingsView.swift index d773c59a0..ed4184b2d 100644 --- a/PocketMesh/Views/Settings/LocationSettingsView.swift +++ b/PocketMesh/Views/Settings/LocationSettingsView.swift @@ -8,6 +8,8 @@ struct LocationSettingsView: View { var body: some View { List { LocationSettingsSection(showingLocationPicker: $showingLocationPicker) + + GeocodingSettingsSection() } .navigationTitle(L10n.Settings.Location.header) .navigationBarTitleDisplayMode(.inline) diff --git a/PocketMesh/Views/Settings/Sections/GeocodingSettingsSection.swift b/PocketMesh/Views/Settings/Sections/GeocodingSettingsSection.swift new file mode 100644 index 000000000..677882d22 --- /dev/null +++ b/PocketMesh/Views/Settings/Sections/GeocodingSettingsSection.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct GeocodingSettingsSection: View { + @AppStorage("geocodingEnabled") private var geocodingEnabled = true + + var body: some View { + Section { + Toggle(isOn: $geocodingEnabled) { + TintedLabel(L10n.Settings.Geocoding.nodeLocationLookup, systemImage: "map") + } + } header: { + Text(L10n.Settings.Geocoding.header) + } footer: { + Text(L10n.Settings.Geocoding.footer) + } + } +}