mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Fixes/location channels (#465)
* 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 * Project: update Xcode project (auto) * UI: use '~' without trailing space before location name in sheet * Location sheet: poll for location at regular intervals while open (replace significant-move updates) * UI: show Bluetooth range next to #bluetooth in location sheet * UI: remove leading '#' from mesh title in location sheet * UI: bold location name in sheet when geohash has >0 people; refactor row to render subtitle pieces * UI: bold mesh/level titles when participant count > 0; factor meshCount() --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -960,7 +960,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -991,7 +991,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1046,7 +1046,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1078,7 +1078,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1167,7 +1167,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1260,7 +1260,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// Default relay list (can be customized)
|
// Default relay list (can be customized)
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub",
|
||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
/// Levels of location channels mapped to geohash precisions.
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
case street
|
|
||||||
case block
|
case block
|
||||||
case neighborhood
|
case neighborhood
|
||||||
case city
|
case city
|
||||||
@@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
/// Geohash length used for this level.
|
/// Geohash length used for this level.
|
||||||
var precision: Int {
|
var precision: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .street: return 8
|
|
||||||
case .block: return 7
|
case .block: return 7
|
||||||
case .neighborhood: return 6
|
case .neighborhood: return 6
|
||||||
case .city: return 5
|
case .city: return 5
|
||||||
@@ -23,7 +21,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .street: return "Street"
|
|
||||||
case .block: return "Block"
|
case .block: return "Block"
|
||||||
case .neighborhood: return "Neighborhood"
|
case .neighborhood: return "Neighborhood"
|
||||||
case .city: return "City"
|
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 cl = CLLocationManager()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
private var lastLocation: CLLocation?
|
private var lastLocation: CLLocation?
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
private let userDefaultsKey = "locationChannel.selected"
|
private let userDefaultsKey = "locationChannel.selected"
|
||||||
|
private var isGeocoding: Bool = false
|
||||||
|
|
||||||
// Published state for UI bindings
|
// Published state for UI bindings
|
||||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -76,15 +79,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
|
|
||||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||||
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||||
// Prefer continuous updates with a significant distance filter rather than polling
|
|
||||||
guard permissionState == .authorized else { return }
|
guard permissionState == .authorized else { return }
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||||
cl.distanceFilter = 21 // meters; update on small moves
|
refreshTimer?.invalidate()
|
||||||
cl.startUpdatingLocation()
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
self?.requestOneShotLocation()
|
||||||
|
}
|
||||||
|
// Kick off immediately
|
||||||
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop periodic refreshes when selector UI is dismissed.
|
/// Stop periodic refreshes when selector UI is dismissed.
|
||||||
func endLiveRefresh() {
|
func endLiveRefresh() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
cl.stopUpdatingLocation()
|
cl.stopUpdatingLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +131,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
guard let loc = locations.last else { return }
|
guard let loc = locations.last else { return }
|
||||||
lastLocation = loc
|
lastLocation = loc
|
||||||
computeChannels(from: loc.coordinate)
|
computeChannels(from: loc.coordinate)
|
||||||
|
reverseGeocodeIfNeeded(location: loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
@@ -153,6 +162,55 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
Task { @MainActor in self.availableChannels = result }
|
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
|
#endif
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ struct LocationChannelsSheet: View {
|
|||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
List {
|
||||||
// Mesh option first
|
// Mesh option first
|
||||||
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
|
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,12 @@ struct LocationChannelsSheet: View {
|
|||||||
// Nearby options
|
// Nearby options
|
||||||
if !manager.availableChannels.isEmpty {
|
if !manager.availableChannels.isEmpty {
|
||||||
ForEach(manager.availableChannels) { channel in
|
ForEach(manager.availableChannels) { channel in
|
||||||
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) {
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
|
let nameBase = locationName(for: channel.level)
|
||||||
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
|
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||||
|
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||||
|
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, subtitleNameBold: highlight, isSelected: isSelected(channel), titleBold: highlight) {
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
@@ -198,7 +203,7 @@ struct LocationChannelsSheet: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -207,15 +212,28 @@ struct LocationChannelsSheet: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(parts.base)
|
Text(parts.base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
if let count = parts.countSuffix, !count.isEmpty {
|
if let count = parts.countSuffix, !count.isEmpty {
|
||||||
Text(count)
|
Text(count)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(subtitle)
|
HStack(spacing: 0) {
|
||||||
.font(.system(size: 12, design: .monospaced))
|
Text(subtitlePrefix)
|
||||||
.foregroundColor(.secondary)
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
if let name = subtitleName {
|
||||||
|
Text(" • ")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
@@ -241,13 +259,17 @@ struct LocationChannelsSheet: View {
|
|||||||
// MARK: - Helpers for counts
|
// MARK: - Helpers for counts
|
||||||
private func meshTitleWithCount() -> String {
|
private func meshTitleWithCount() -> String {
|
||||||
// Count currently connected mesh peers (excluding self)
|
// Count currently connected mesh peers (excluding self)
|
||||||
|
let meshCount = meshCount()
|
||||||
|
let noun = meshCount == 1 ? "person" : "people"
|
||||||
|
return "mesh [\(meshCount) \(noun)]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func meshCount() -> Int {
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
let meshCount = viewModel.allPeers.reduce(0) { acc, peer in
|
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
let noun = meshCount == 1 ? "person" : "people"
|
|
||||||
return "#mesh [\(meshCount) \(noun)]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||||
@@ -270,7 +292,7 @@ struct LocationChannelsSheet: View {
|
|||||||
case 5: return .city
|
case 5: return .city
|
||||||
case 6: return .neighborhood
|
case 6: return .neighborhood
|
||||||
case 7: return .block
|
case 7: return .block
|
||||||
default: return .street
|
default: return .block
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +302,81 @@ extension LocationChannelsSheet {
|
|||||||
private var standardGreen: Color {
|
private var standardGreen: Color {
|
||||||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
(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 bluetoothRangeString() -> String {
|
||||||
|
let usesMetric: Bool = {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return Locale.current.measurementSystem == .metric
|
||||||
|
} else {
|
||||||
|
return Locale.current.usesMetricSystem
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
|
||||||
|
return usesMetric ? "~10–50 m" : "~30–160 ft"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
|
manager.locationNames[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
||||||
|
switch level {
|
||||||
|
case .country:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "~"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
// Sanity: known coords (Statue of Liberty approx)
|
// Sanity: known coords (Statue of Liberty approx)
|
||||||
let lat = 40.6892
|
let lat = 40.6892
|
||||||
let lon = -74.0445
|
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 block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
|
||||||
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.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 city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
|
||||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.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)
|
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
|
||||||
|
|
||||||
XCTAssertEqual(street.count, 8)
|
|
||||||
XCTAssertEqual(block.count, 7)
|
XCTAssertEqual(block.count, 7)
|
||||||
XCTAssertEqual(neighborhood.count, 6)
|
XCTAssertEqual(neighborhood.count, 6)
|
||||||
XCTAssertEqual(city.count, 5)
|
XCTAssertEqual(city.count, 5)
|
||||||
@@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(country.count, 2)
|
XCTAssertEqual(country.count, 2)
|
||||||
|
|
||||||
// All prefixes must match progressively
|
// All prefixes must match progressively
|
||||||
XCTAssertTrue(street.hasPrefix(block))
|
|
||||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||||
XCTAssertTrue(city.hasPrefix(region))
|
XCTAssertTrue(city.hasPrefix(region))
|
||||||
|
|||||||
Reference in New Issue
Block a user