mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:05:18 +00:00
* Centralize UI theme colors into semantic ThemePalette tokens Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment key and @ThemedPalette property wrapper, persisted via @AppStorage. Views now resolve background/primary/secondary/accentBlue/alertRed/ divider tokens from the environment instead of computing colors inline, removing the backgroundColor/textColor/secondaryTextColor prop-drilling through the header, composer, and people-sheet hierarchy. Matrix theme output is pixel-identical; this is groundwork for a user-selectable theme switcher. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add liquid glass theme with in-app appearance switcher Add a .liquidGlass AppTheme alongside the matrix terminal theme, selectable from a one-line APPEARANCE row in the app info sheet (persisted via @AppStorage, applies live). Liquid glass renders system fonts and colors over a subtle gradient backdrop, with the header and composer floating as Liquid Glass panels (real .glassEffect() on iOS/macOS 26, compiler-gated with an ultraThinMaterial fallback for older SDKs). The message list scrolls underneath the chrome via safe-area insets, and all sheets share the same backdrop and surface language. The matrix theme is unchanged. Details: - Theme is threaded through ChatMessageFormatter so message AttributedStrings switch font design per theme; the per-message format cache gains a variant key so themes never serve each other's cached strings - New palette tokens: accent (interactive tint) and locationAccent (geohash green), replacing scattered hardcoded greens/blues in the voice note, waveform, verification, and people-sheet views - Header controls get full-height tap targets and the people count becomes a real Button (previously a tap gesture on the cluster) - CommandSuggestionsView renders nothing when empty instead of a zero-height view that pushed the composer input off-center - New appearance strings localized for all 29 catalog languages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
598 lines
25 KiB
Swift
598 lines
25 KiB
Swift
import SwiftUI
|
||
import CoreLocation
|
||
#if os(iOS)
|
||
import UIKit
|
||
#else
|
||
import AppKit
|
||
#endif
|
||
struct LocationChannelsSheet: View {
|
||
@Binding var isPresented: Bool
|
||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||
@EnvironmentObject private var peerListModel: PeerListModel
|
||
@ThemedPalette private var palette
|
||
@State private var customGeohash: String = ""
|
||
@State private var customError: String? = nil
|
||
|
||
private var backgroundColor: Color { palette.background }
|
||
|
||
private enum Strings {
|
||
static let title: LocalizedStringKey = "location_channels.title"
|
||
static let description: LocalizedStringKey = "location_channels.description"
|
||
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||
|
||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||
|
||
static func meshTitle(_ count: Int) -> String {
|
||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||
return rowTitle(label: label, count: count)
|
||
}
|
||
|
||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
||
// show "?" because presence broadcasting is disabled for privacy.
|
||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
||
if isHighPrecision && count == 0 {
|
||
return String(
|
||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||
locale: .current,
|
||
level.displayName
|
||
)
|
||
}
|
||
return rowTitle(label: level.displayName, count: count)
|
||
}
|
||
|
||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||
// Check precision for bookmarks too
|
||
let len = geohash.count
|
||
// Neighborhood=6, Block=7, Building=8+
|
||
let isHighPrecision = (len >= 6)
|
||
if isHighPrecision && count == 0 {
|
||
return String(
|
||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||
locale: .current,
|
||
"#\(geohash)"
|
||
)
|
||
}
|
||
return rowTitle(label: "#\(geohash)", count: count)
|
||
}
|
||
|
||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||
String(
|
||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||
locale: .current,
|
||
geohash, coverage
|
||
)
|
||
}
|
||
|
||
static func subtitle(prefix: String, name: String?) -> String {
|
||
guard let name, !name.isEmpty else { return prefix }
|
||
return String(
|
||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||
locale: .current,
|
||
prefix, name
|
||
)
|
||
}
|
||
|
||
private static func rowTitle(label: String, count: Int) -> String {
|
||
String(
|
||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||
locale: .current,
|
||
label, count
|
||
)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 12) {
|
||
Text(Strings.title)
|
||
.bitchatFont(size: 18)
|
||
Spacer()
|
||
closeButton
|
||
}
|
||
Text(Strings.description)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(.secondary)
|
||
|
||
Group {
|
||
switch locationChannelsModel.permissionState {
|
||
case .notDetermined:
|
||
Button(action: { locationChannelsModel.enableLocationChannels() }) {
|
||
Text(Strings.requestPermissions)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(standardGreen)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 6)
|
||
.background(standardGreen.opacity(0.12))
|
||
.cornerRadius(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
case .denied, .restricted:
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(Strings.permissionDenied)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(.secondary)
|
||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||
.buttonStyle(.plain)
|
||
}
|
||
case .authorized:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
channelList
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.themedSurface()
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.navigationBarHidden(true)
|
||
#else
|
||
.navigationTitle("")
|
||
#endif
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 420, minHeight: 520)
|
||
#endif
|
||
.themedSheetBackground()
|
||
.onAppear {
|
||
// Refresh channels when opening
|
||
if locationChannelsModel.permissionState == .authorized {
|
||
locationChannelsModel.refreshChannels()
|
||
}
|
||
// Begin periodic refresh while sheet is open
|
||
locationChannelsModel.beginLiveRefresh()
|
||
// Geohash sampling is now managed by ChatViewModel globally
|
||
}
|
||
.onDisappear {
|
||
locationChannelsModel.endLiveRefresh()
|
||
}
|
||
.onChange(of: locationChannelsModel.permissionState) { newValue in
|
||
if newValue == .authorized {
|
||
locationChannelsModel.refreshChannels()
|
||
}
|
||
}
|
||
.onChange(of: locationChannelsModel.availableChannels) { _ in }
|
||
}
|
||
|
||
private var closeButton: some View {
|
||
Button(action: { isPresented = false }) {
|
||
Image(systemName: "xmark")
|
||
.bitchatFont(size: 13, weight: .semibold)
|
||
.frame(width: 32, height: 32)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Close")
|
||
}
|
||
|
||
private var channelList: some View {
|
||
ScrollView {
|
||
LazyVStack(spacing: 0) {
|
||
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
|
||
locationChannelsModel.select(ChannelID.mesh)
|
||
isPresented = false
|
||
}
|
||
.padding(.vertical, 6)
|
||
|
||
let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building }
|
||
if !nearby.isEmpty {
|
||
ForEach(nearby) { channel in
|
||
sectionDivider
|
||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||
let nameBase = locationName(for: channel.level)
|
||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||
let participantCount = peerListModel.participantCount(for: channel.geohash)
|
||
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||
let highlight = participantCount > 0
|
||
channelRow(
|
||
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||
subtitlePrefix: subtitlePrefix,
|
||
subtitleName: namePart,
|
||
isSelected: isSelected(channel),
|
||
titleBold: highlight,
|
||
trailingAccessory: {
|
||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||
.font(.bitchatSystem(size: 14))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.leading, 8)
|
||
}
|
||
) {
|
||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||
locationChannelsModel.select(ChannelID.location(channel))
|
||
isPresented = false
|
||
}
|
||
.padding(.vertical, 6)
|
||
}
|
||
} else {
|
||
sectionDivider
|
||
HStack(spacing: 8) {
|
||
ProgressView()
|
||
Text(Strings.loadingNearby)
|
||
.bitchatFont(size: 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 10)
|
||
}
|
||
|
||
sectionDivider
|
||
customTeleportSection
|
||
.padding(.vertical, 8)
|
||
|
||
let bookmarkedList = locationChannelsModel.bookmarks
|
||
if !bookmarkedList.isEmpty {
|
||
sectionDivider
|
||
bookmarkedSection(bookmarkedList)
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
if locationChannelsModel.permissionState == .authorized {
|
||
sectionDivider
|
||
torToggleSection
|
||
.padding(.top, 12)
|
||
Button(action: SystemSettings.location.open) {
|
||
Text(Strings.removeAccess)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(palette.alertRed)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 6)
|
||
.background(Color.red.opacity(0.08))
|
||
.cornerRadius(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 6)
|
||
.themedSurface()
|
||
}
|
||
.themedSurface()
|
||
}
|
||
|
||
private var sectionDivider: some View {
|
||
Rectangle()
|
||
.fill(dividerColor)
|
||
.frame(height: 1)
|
||
}
|
||
|
||
private var dividerColor: Color { palette.divider }
|
||
|
||
private var customTeleportSection: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 2) {
|
||
Text(verbatim: "#")
|
||
.bitchatFont(size: 14)
|
||
.foregroundColor(.secondary)
|
||
TextField("geohash", text: $customGeohash)
|
||
#if os(iOS)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.keyboardType(.asciiCapable)
|
||
#endif
|
||
.bitchatFont(size: 14)
|
||
.onChange(of: customGeohash) { newValue in
|
||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||
let filtered = newValue
|
||
.lowercased()
|
||
.replacingOccurrences(of: "#", with: "")
|
||
.filter { allowed.contains($0) }
|
||
if filtered.count > 12 {
|
||
customGeohash = String(filtered.prefix(12))
|
||
} else if filtered != newValue {
|
||
customGeohash = filtered
|
||
}
|
||
}
|
||
let normalized = customGeohash
|
||
.trimmed
|
||
.lowercased()
|
||
.replacingOccurrences(of: "#", with: "")
|
||
let isValid = validateGeohash(normalized)
|
||
Button(action: {
|
||
let gh = normalized
|
||
guard isValid else { customError = Strings.invalidGeohash; return }
|
||
locationChannelsModel.teleport(to: gh)
|
||
isPresented = false
|
||
}) {
|
||
HStack(spacing: 6) {
|
||
Text(Strings.teleport)
|
||
.bitchatFont(size: 14)
|
||
Image(systemName: "face.dashed")
|
||
.font(.bitchatSystem(size: 14))
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.bitchatFont(size: 14)
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal, 10)
|
||
.background(Color.secondary.opacity(0.12))
|
||
.cornerRadius(6)
|
||
.opacity(isValid ? 1.0 : 0.4)
|
||
.disabled(!isValid)
|
||
}
|
||
if let err = customError {
|
||
Text(err)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(Strings.bookmarked)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(.secondary)
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||
let level = levelForLength(gh.count)
|
||
let channel = GeohashChannel(level: level, geohash: gh)
|
||
let coverage = coverageString(forPrecision: gh.count)
|
||
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||
let name = locationChannelsModel.bookmarkNames[gh]
|
||
let participantCount = peerListModel.participantCount(for: gh)
|
||
channelRow(
|
||
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||
subtitlePrefix: subtitle,
|
||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||
isSelected: locationChannelsModel.isSelected(channel),
|
||
trailingAccessory: {
|
||
Button(action: { locationChannelsModel.toggleBookmark(gh) }) {
|
||
Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||
.font(.bitchatSystem(size: 14))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.leading, 8)
|
||
}
|
||
) {
|
||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||
locationChannelsModel.markTeleported(for: gh, true)
|
||
} else {
|
||
locationChannelsModel.markTeleported(for: gh, false)
|
||
}
|
||
locationChannelsModel.select(ChannelID.location(channel))
|
||
isPresented = false
|
||
}
|
||
.padding(.vertical, 6)
|
||
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
|
||
|
||
if index < entries.count - 1 {
|
||
sectionDivider
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||
locationChannelsModel.isSelected(channel)
|
||
}
|
||
|
||
private var isMeshSelected: Bool {
|
||
if case .mesh = locationChannelsModel.selectedChannel { return true }
|
||
return false
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func channelRow(
|
||
title: String,
|
||
subtitlePrefix: String,
|
||
subtitleName: String? = nil,
|
||
subtitleNameBold: Bool = false,
|
||
isSelected: Bool,
|
||
titleColor: Color? = nil,
|
||
titleBold: Bool = false,
|
||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
VStack(alignment: .leading) {
|
||
// Render title with smaller font for trailing count in parentheses
|
||
let parts = splitTitleAndCount(title)
|
||
HStack(spacing: 4) {
|
||
Text(parts.base)
|
||
.bitchatFont(size: 14)
|
||
.fontWeight(titleBold ? .bold : .regular)
|
||
.foregroundColor(titleColor ?? Color.primary)
|
||
if let count = parts.countSuffix, !count.isEmpty {
|
||
Text(count)
|
||
.bitchatFont(size: 11)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||
Text(subtitleFull)
|
||
.bitchatFont(size: 12)
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
Spacer()
|
||
if isSelected {
|
||
Text(verbatim: "✔︎")
|
||
.bitchatFont(size: 16)
|
||
.foregroundColor(standardGreen)
|
||
}
|
||
trailingAccessory()
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: action)
|
||
}
|
||
|
||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||
let prefix = String(s[..<idx]).trimmed
|
||
let suffix = String(s[idx...])
|
||
return (prefix, suffix)
|
||
}
|
||
private func validateGeohash(_ s: String) -> Bool {
|
||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||
return s.allSatisfy { allowed.contains($0) }
|
||
}
|
||
|
||
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||
switch len {
|
||
case 0...2: return .region
|
||
case 3...4: return .province
|
||
case 5: return .city
|
||
case 6: return .neighborhood
|
||
case 7: return .block
|
||
case 8: return .building
|
||
default: return .block
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - TOR Toggle & Standardized Colors
|
||
extension LocationChannelsSheet {
|
||
private var torToggleBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { locationChannelsModel.userTorEnabled },
|
||
set: { locationChannelsModel.setUserTorEnabled($0) }
|
||
)
|
||
}
|
||
|
||
private var torToggleSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Toggle(isOn: torToggleBinding) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(Strings.torTitle)
|
||
.bitchatFont(size: 12, weight: .semibold)
|
||
.foregroundColor(.primary)
|
||
Text(Strings.torSubtitle)
|
||
.bitchatFont(size: 11)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||
}
|
||
.padding(12)
|
||
.background(Color.secondary.opacity(0.12))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
private var standardGreen: Color { palette.primary }
|
||
private var standardBlue: Color { palette.accentBlue }
|
||
}
|
||
|
||
private struct IRCToggleStyle: ToggleStyle {
|
||
let accent: Color
|
||
let onLabel: LocalizedStringKey
|
||
let offLabel: LocalizedStringKey
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
Button(action: { configuration.isOn.toggle() }) {
|
||
HStack(spacing: 12) {
|
||
configuration.label
|
||
Spacer()
|
||
Text(configuration.isOn ? onLabel : offLabel)
|
||
.textCase(.uppercase)
|
||
.bitchatFont(size: 12, weight: .semibold)
|
||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||
.padding(.vertical, 4)
|
||
.padding(.horizontal, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// 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, macOS 13.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, macOS 13.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? {
|
||
locationChannelsModel.locationName(for: level)
|
||
}
|
||
|
||
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
||
switch level {
|
||
case .region:
|
||
return ""
|
||
case .building, .block, .neighborhood, .city, .province:
|
||
return "~"
|
||
}
|
||
}
|
||
}
|