Remove "street" location channel; add coverage + names; style mesh

- Drop GeohashChannelLevel.street and update mappings/tests\n- Map geohash lengths >=8 to Block in teleport, no Street\n- Show ~distance coverage (mi/km via Locale.measurementSystem) next to each #geohash\n- Add reverse geocoding to display coarse names (country/region/city/neighborhood) per level\n- Style "#mesh" row title with toolbar blue\n- Fix iOS 16 deprecation: use Locale.measurementSystem
This commit is contained in:
jack
2025-08-21 00:34:17 +02:00
parent 43166bcd64
commit b4fcea2672
5 changed files with 126 additions and 11 deletions
+1
View File
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
// Default relay list (can be customized)
private static let defaultRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub",
"wss://nostr21.com"
-4
View File
@@ -2,7 +2,6 @@ import Foundation
/// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case street
case block
case neighborhood
case city
@@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
/// Geohash length used for this level.
var precision: Int {
switch self {
case .street: return 8
case .block: return 7
case .neighborhood: return 6
case .city: return 5
@@ -23,7 +21,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String {
switch self {
case .street: return "Street"
case .block: return "Block"
case .neighborhood: return "Neighborhood"
case .city: return "City"
@@ -68,4 +65,3 @@ enum ChannelID: Equatable, Codable {
}
}
}
@@ -17,14 +17,17 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
}
private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private let userDefaultsKey = "locationChannel.selected"
private var isGeocoding: Bool = false
// Published state for UI bindings
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
private override init() {
super.init()
@@ -123,6 +126,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeIfNeeded(location: loc)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
@@ -153,6 +157,55 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
}
Task { @MainActor in self.availableChannels = result }
}
private func reverseGeocodeIfNeeded(location: CLLocation) {
// Always cancel previous to keep latest fresh while user moves
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.namesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
// Country
if let country = pm.country, !country.isEmpty {
dict[.country] = country
}
// Region (state/province or county)
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.region] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.region] = subAdmin
}
// City (locality)
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
// Neighborhood
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
// Block: reuse neighborhood/locality granularity without exposing street level
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
return dict
}
}
#endif
+72 -4
View File
@@ -93,7 +93,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View {
List {
// Mesh option first
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected, titleColor: standardBlue) {
manager.select(ChannelID.mesh)
isPresented = false
}
@@ -101,7 +101,11 @@ struct LocationChannelsSheet: View {
// Nearby options
if !manager.availableChannels.isEmpty {
ForEach(manager.availableChannels) { channel in
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) {
let coverage = coverageString(forPrecision: channel.geohash.count)
let name = locationName(for: channel.level)
let namePart = name.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitle = ["#\(channel.geohash)", coverage, namePart].compactMap { $0 }.joined(separator: "")
channelRow(title: geohashTitleWithCount(for: channel), subtitle: subtitle, isSelected: isSelected(channel)) {
manager.select(ChannelID.location(channel))
isPresented = false
}
@@ -198,7 +202,7 @@ struct LocationChannelsSheet: View {
return false
}
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
private func channelRow(title: String, subtitle: String, isSelected: Bool, titleColor: Color? = nil, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
VStack(alignment: .leading) {
@@ -207,6 +211,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 4) {
Text(parts.base)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.font(.system(size: 11, design: .monospaced))
@@ -270,7 +275,7 @@ struct LocationChannelsSheet: View {
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .street
default: return .block
}
}
}
@@ -280,6 +285,69 @@ extension LocationChannelsSheet {
private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
}
// MARK: - Coverage helpers
extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String {
// Approximate max cell dimension at equator for a given geohash length.
// Values sourced from common geohash dimension tables.
let maxMeters: Double = {
switch len {
case 2: return 1_250_000
case 3: return 156_000
case 4: return 39_100
case 5: return 4_890
case 6: return 1_220
case 7: return 153
case 8: return 38.2
case 9: return 4.77
case 10: return 1.19
default:
if len <= 1 { return 5_000_000 }
// For >10, scale down conservatively by ~1/4 each char
let over = len - 10
return 1.19 * pow(0.25, Double(over))
}
}()
let usesMetric: Bool = {
if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
if usesMetric {
let km = maxMeters / 1000.0
return "~\(formatDistance(km)) km"
} else {
let miles = maxMeters / 1609.344
return "~\(formatDistance(miles)) mi"
}
}
private func formatDistance(_ value: Double) -> String {
if value >= 100 { return String(format: "%.0f", value.rounded()) }
if value >= 10 { return String(format: "%.1f", value) }
return String(format: "%.1f", value)
}
private func locationName(for level: GeohashChannelLevel) -> String? {
manager.locationNames[level]
}
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
switch level {
case .country:
return ""
default:
return "around "
}
}
}
#endif
-3
View File
@@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase {
// Sanity: known coords (Statue of Liberty approx)
let lat = 40.6892
let lon = -74.0445
let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision)
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
XCTAssertEqual(street.count, 8)
XCTAssertEqual(block.count, 7)
XCTAssertEqual(neighborhood.count, 6)
XCTAssertEqual(city.count, 5)
@@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase {
XCTAssertEqual(country.count, 2)
// All prefixes must match progressively
XCTAssertTrue(street.hasPrefix(block))
XCTAssertTrue(block.hasPrefix(neighborhood))
XCTAssertTrue(neighborhood.hasPrefix(city))
XCTAssertTrue(city.hasPrefix(region))