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 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 grantToFind: LocalizedStringKey = "location_channels.grant_to_find" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") 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(palette.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(palette.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 { SheetCloseButton { isPresented = false } } 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) .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark) }, accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark, accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) } ) { locationChannelsModel.markTeleported(for: channel.geohash, false) locationChannelsModel.select(ChannelID.location(channel)) isPresented = false } .padding(.vertical, 6) } } else if locationChannelsModel.permissionState == .authorized { sectionDivider HStack(spacing: 8) { ProgressView() Text(Strings.loadingNearby) .bitchatFont(size: 12) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) } else { // No permission means no fix is coming: an honest hint // beats a spinner that would never finish. sectionDivider Text(Strings.grantToFind) .bitchatFont(size: 12) .foregroundColor(palette.secondary) .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) } } .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(palette.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(palette.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(palette.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) .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark) }, accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark, accessoryAction: { locationChannelsModel.toggleBookmark(gh) } ) { 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() }, accessoryActionTitle: String? = nil, accessoryAction: (() -> Void)? = nil, 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 ?? palette.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .bitchatFont(size: 11) .foregroundColor(palette.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) .bitchatFont(size: 12) .foregroundColor(palette.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) // The row is a plain HStack with a tap gesture, which VoiceOver reads // as disconnected static text. Expose it as one activatable button; // the visible bookmark accessory is mirrored as a named action. .accessibilityElement(children: .ignore) .accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))")) .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton]) .accessibilityHint(Strings.switchChannelHint) .accessibilityAction(.default, action) .accessibilityActions { if let accessoryActionTitle, let accessoryAction { Button(accessoryActionTitle, action: accessoryAction) } } } // 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[.. 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: - Standardized Colors // (The tor and internet-gateway toggles moved to AppInfoView's Settings pane; // IRCToggleStyle now lives in Views/Components.) extension LocationChannelsSheet { private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } // 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 "~" } } }