Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion PocketMesh/Resources/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion PocketMesh/Resources/Localization/en.lproj/Contacts.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions PocketMesh/Resources/Localization/en.lproj/Settings.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
113 changes: 113 additions & 0 deletions PocketMesh/Services/ReverseGeocodeCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import CoreLocation
import Foundation

actor ReverseGeocodeCache {
static let shared = ReverseGeocodeCache()

private var cache: [String: String] = [:]
private var pendingKeys: Set<String> = []
private var queue: [(key: String, location: CLLocation, continuation: CheckedContinuation<String?, Never>)] = []
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)
}
}
80 changes: 61 additions & 19 deletions PocketMesh/Views/Contacts/ContactDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -105,7 +106,8 @@ struct ContactDetailView: View {
// Profile header
ContactProfileSection(
currentContact: currentContact,
contactTypeLabel: contactTypeLabel
locality: locality,
userLocation: appState.locationService.currentLocation
)

// Quick actions
Expand Down Expand Up @@ -168,6 +170,7 @@ struct ContactDetailView: View {
onDelete: { showingDeleteAlert = true }
)
}
.contentMargins(.top, 0, for: .scrollContent)
.errorAlert($errorMessage)
.navigationTitle(contactTypeLabel)
.navigationBarTitleDisplayMode(.inline)
Expand All @@ -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
Expand Down Expand Up @@ -469,40 +480,54 @@ 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 {
VStack(spacing: 16) {
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)
}
}
}
}
Expand All @@ -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 {
Expand Down
Loading