diff --git a/bitchat/Resources/geohash-map.html b/bitchat/Resources/geohash-map.html new file mode 100644 index 000000000..54068c053 --- /dev/null +++ b/bitchat/Resources/geohash-map.html @@ -0,0 +1,397 @@ + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 1a5ee44eb..0f5c9448e 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -56,6 +56,7 @@ struct ContentView: View { @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] @State private var showLocationNotes = false + @State private var customGeohash: String = "" @State private var notesGeohash: String? = nil @State private var sheetNotesCount: Int = 0 @State private var imagePreviewURL: URL? = nil @@ -1379,8 +1380,11 @@ struct ContentView: View { .frame(height: headerHeight) .padding(.horizontal, 12) .sheet(isPresented: $showLocationChannelsSheet) { - LocationChannelsSheet(isPresented: $showLocationChannelsSheet) - .onAppear { viewModel.isLocationChannelsSheetPresented = true } + LocationChannelsSheet(isPresented: $showLocationChannelsSheet, customGeohash: $customGeohash) + .onAppear { + viewModel.isLocationChannelsSheetPresented = true + customGeohash = "" + } .onDisappear { viewModel.isLocationChannelsSheetPresented = false } } .sheet(isPresented: $showLocationNotes, onDismiss: { diff --git a/bitchat/Views/GeohashMapView.swift b/bitchat/Views/GeohashMapView.swift new file mode 100644 index 000000000..2b2592cb2 --- /dev/null +++ b/bitchat/Views/GeohashMapView.swift @@ -0,0 +1,501 @@ +import SwiftUI +import WebKit + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +// MARK: - Geohash Picker using Leaflet + +struct GeohashMapView: View { + @Binding var selectedGeohash: String + let initialGeohash: String + let showFloatingControls: Bool + @Binding var precision: Int? + @Environment(\.colorScheme) var colorScheme + @State private var webViewCoordinator: GeohashWebView.Coordinator? + @State private var currentPrecision: Int = 6 // Default to neighborhood level + @State private var isPinned: Bool = false + + init(selectedGeohash: Binding, initialGeohash: String = "", showFloatingControls: Bool = true, precision: Binding = .constant(nil)) { + self._selectedGeohash = selectedGeohash + self.initialGeohash = initialGeohash + self.showFloatingControls = showFloatingControls + self._precision = precision + } + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + var body: some View { + ZStack { + // Full-screen map + GeohashWebView( + selectedGeohash: $selectedGeohash, + initialGeohash: initialGeohash, + colorScheme: colorScheme, + currentPrecision: $currentPrecision, + isPinned: $isPinned, + onCoordinatorCreated: { coordinator in + DispatchQueue.main.async { + self.webViewCoordinator = coordinator + } + } + ) + .ignoresSafeArea() + + // Floating precision controls + if showFloatingControls { + VStack { + HStack { + Spacer() + VStack(spacing: 8) { + // Plus button + Button(action: { + if currentPrecision < 12 { + currentPrecision += 1 + isPinned = true + webViewCoordinator?.setPrecision(currentPrecision) + } + }) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .frame(width: 48, height: 48) + .background( + Circle() + .fill(colorScheme == .dark ? Color.black.opacity(0.8) : Color.white.opacity(0.9)) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + ) + } + .disabled(currentPrecision >= 12) + .opacity(currentPrecision >= 12 ? 0.5 : 1.0) + + // Minus button + Button(action: { + if currentPrecision > 1 { + currentPrecision -= 1 + isPinned = true + webViewCoordinator?.setPrecision(currentPrecision) + } + }) { + Image(systemName: "minus") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .frame(width: 48, height: 48) + .background( + Circle() + .fill(colorScheme == .dark ? Color.black.opacity(0.8) : Color.white.opacity(0.9)) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + ) + } + .disabled(currentPrecision <= 1) + .opacity(currentPrecision <= 1 ? 0.5 : 1.0) + } + .padding(.trailing, 16) + .padding(.top, 80) + } + Spacer() + } + } + + // Bottom geohash info overlay + if showFloatingControls { + VStack { + Spacer() + HStack { + VStack(alignment: .leading, spacing: 4) { + if !selectedGeohash.isEmpty { + Text("#\(selectedGeohash)") + .font(.bitchatSystem(size: 16, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + } else { + Text(String(localized: "geohash_picker.instruction", comment: "Instruction text for geohash map picker")) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(.secondary) + } + + Text("precision: \(currentPrecision) • \(levelName(for: currentPrecision))") + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + Rectangle() + .fill(colorScheme == .dark ? Color.black.opacity(0.9) : Color.white.opacity(0.9)) + .overlay( + Rectangle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + ) + } + } + } + .onAppear { + // Set initial precision based on selected geohash length + if !selectedGeohash.isEmpty { + currentPrecision = selectedGeohash.count + } else if !initialGeohash.isEmpty { + currentPrecision = initialGeohash.count + } + } + .onChange(of: selectedGeohash) { newValue in + if !newValue.isEmpty && newValue.count != currentPrecision && !isPinned { + currentPrecision = newValue.count + } + } + .onChange(of: precision) { newValue in + if let newPrecision = newValue, newPrecision != currentPrecision { + currentPrecision = newPrecision + isPinned = true + webViewCoordinator?.setPrecision(currentPrecision) + } + } + .onChange(of: currentPrecision) { newValue in + precision = newValue + } + } + + private func levelName(for precision: Int) -> String { + let level = levelForPrecision(precision) + return level.displayName.lowercased() + } + + private func levelForPrecision(_ precision: Int) -> GeohashChannelLevel { + switch precision { + case 8: return .building + case 7: return .block + case 6: return .neighborhood + case 5: return .city + case 4: return .province + case 0...3: return .region + default: return .neighborhood // Default fallback + } + } + +} + + + +// MARK: - WebKit Bridge + +#if os(iOS) +struct GeohashWebView: UIViewRepresentable { + @Binding var selectedGeohash: String + let initialGeohash: String + let colorScheme: ColorScheme + @Binding var currentPrecision: Int + @Binding var isPinned: Bool + let onCoordinatorCreated: (Coordinator) -> Void + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + + // Configure to allow all touch events to pass through to web content + config.allowsInlineMediaPlayback = true + config.mediaTypesRequiringUserActionForPlayback = [] + + let webView = WKWebView(frame: .zero, configuration: config) + + // Store webView reference in coordinator + context.coordinator.webView = webView + + // Notify parent of coordinator creation + onCoordinatorCreated(context.coordinator) + + // Enable JavaScript and configure touch gestures + webView.isOpaque = false + webView.backgroundColor = UIColor.clear + + // Enable touch gestures and zoom + webView.scrollView.isScrollEnabled = true + webView.scrollView.bounces = false + webView.scrollView.bouncesZoom = false + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.showsVerticalScrollIndicator = false + + // Allow multiple touch gestures and disable WebView's native zoom to let Leaflet handle it + webView.allowsBackForwardNavigationGestures = false + webView.isMultipleTouchEnabled = true + webView.isUserInteractionEnabled = true + + // Disable WebView's native zoom so Leaflet can handle double-tap zoom + webView.scrollView.minimumZoomScale = 1.0 + webView.scrollView.maximumZoomScale = 1.0 + webView.scrollView.zoomScale = 1.0 + + // Add JavaScript interface + let contentController = webView.configuration.userContentController + contentController.add(context.coordinator, name: "iOS") + + // Set navigation delegate to handle page load completion + webView.navigationDelegate = context.coordinator + + // Load the HTML content from Resources folder + if let path = Bundle.main.path(forResource: "geohash-map", ofType: "html"), + let htmlString = try? String(contentsOfFile: path) { + let theme = colorScheme == .dark ? "dark" : "light" + let processedHTML = htmlString.replacingOccurrences(of: "{{THEME}}", with: theme) + webView.loadHTMLString(processedHTML, baseURL: Bundle.main.bundleURL) + } + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Update theme if needed + let theme = colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + // Focus on geohash if it changed + if !selectedGeohash.isEmpty && context.coordinator.lastGeohash != selectedGeohash { + // Use setTimeout to ensure map is ready + webView.evaluateJavaScript(""" + setTimeout(function() { + if (window.focusGeohash) { + window.focusGeohash('\(selectedGeohash)'); + } + }, 100); + """) + context.coordinator.lastGeohash = selectedGeohash + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} +#elseif os(macOS) +struct GeohashWebView: NSViewRepresentable { + @Binding var selectedGeohash: String + let initialGeohash: String + let colorScheme: ColorScheme + @Binding var currentPrecision: Int + @Binding var isPinned: Bool + let onCoordinatorCreated: (Coordinator) -> Void + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + + // Store webView reference in coordinator + context.coordinator.webView = webView + + // Notify parent of coordinator creation + onCoordinatorCreated(context.coordinator) + + // Add JavaScript interface + let contentController = webView.configuration.userContentController + contentController.add(context.coordinator, name: "macOS") + + webView.navigationDelegate = context.coordinator + + // Load the HTML content from Resources folder + if let path = Bundle.main.path(forResource: "geohash-map", ofType: "html"), + let htmlString = try? String(contentsOfFile: path) { + let theme = colorScheme == .dark ? "dark" : "light" + let processedHTML = htmlString.replacingOccurrences(of: "{{THEME}}", with: theme) + webView.loadHTMLString(processedHTML, baseURL: Bundle.main.bundleURL) + } + + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + let theme = colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + // Focus on geohash if it changed + if !selectedGeohash.isEmpty && context.coordinator.lastGeohash != selectedGeohash { + // Use setTimeout to ensure map is ready + webView.evaluateJavaScript(""" + setTimeout(function() { + if (window.focusGeohash) { + window.focusGeohash('\(selectedGeohash)'); + } + }, 100); + """) + context.coordinator.lastGeohash = selectedGeohash + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} +#endif + +extension GeohashWebView { + class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + let parent: GeohashWebView + var webView: WKWebView? + var hasLoadedOnce = false + var lastGeohash: String = "" + var isInitializing = true + + // Map state persistence + private let mapStateKey = "GeohashMapView.lastMapState" + + init(_ parent: GeohashWebView) { + self.parent = parent + super.init() + } + + private func saveMapState(lat: Double, lng: Double, zoom: Double, precision: Int?) { + var state: [String: Any] = [ + "lat": lat, + "lng": lng, + "zoom": zoom + ] + if let precision = precision { + state["precision"] = precision + } + UserDefaults.standard.set(state, forKey: mapStateKey) + } + + private func loadMapState() -> (lat: Double, lng: Double, zoom: Double, precision: Int?)? { + guard let state = UserDefaults.standard.dictionary(forKey: mapStateKey), + let lat = state["lat"] as? Double, + let lng = state["lng"] as? Double, + let zoom = state["zoom"] as? Double else { + return nil + } + let precision = state["precision"] as? Int + return (lat, lng, zoom, precision) + } + + func focusOnCurrentGeohash() { + guard let webView = webView, !parent.selectedGeohash.isEmpty else { + return + } + webView.evaluateJavaScript(""" + setTimeout(function() { + if (window.focusGeohash) { + window.focusGeohash('\(parent.selectedGeohash)'); + } + }, 100); + """) + } + + func setPrecision(_ precision: Int) { + guard let webView = webView else { return } + webView.evaluateJavaScript(""" + setTimeout(function() { + if (window.setPrecision) { + window.setPrecision(\(precision)); + } + }, 100); + """) + } + + func restoreMapState(lat: Double, lng: Double, zoom: Double, precision: Int?) { + guard let webView = webView else { return } + let precisionValue = precision != nil ? "\(precision!)" : "null" + webView.evaluateJavaScript(""" + setTimeout(function() { + if (window.restoreMapState) { + window.restoreMapState(\(lat), \(lng), \(zoom), \(precisionValue)); + } + }, 100); + """) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + var geohashToFocus: String? = nil + + if !parent.initialGeohash.isEmpty { + geohashToFocus = parent.initialGeohash + // Update selectedGeohash to match the initial geohash + DispatchQueue.main.async { + self.parent.selectedGeohash = self.parent.initialGeohash + } + } + else if !parent.selectedGeohash.isEmpty { + geohashToFocus = parent.selectedGeohash + } + else if !hasLoadedOnce { + if let state = loadMapState() { + restoreMapState(lat: state.lat, lng: state.lng, zoom: state.zoom, precision: state.precision) + hasLoadedOnce = true + + let theme = parent.colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + isInitializing = false + return + } + else if let currentChannel = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .city || $0.level == .neighborhood }) { + geohashToFocus = currentChannel.geohash + } + } + + hasLoadedOnce = true + + if let geohash = geohashToFocus { + lastGeohash = geohash + webView.evaluateJavaScript("window.focusGeohash && window.focusGeohash('\(geohash)')") + } + + let theme = parent.colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + isInitializing = false + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "iOS" || message.name == "macOS" { + if let geohash = message.body as? String { + DispatchQueue.main.async { + self.parent.selectedGeohash = geohash + self.lastGeohash = geohash + } + } else if let dict = message.body as? [String: Any], + let type = dict["type"] as? String { + if type == "precision", let precision = dict["value"] as? Int { + DispatchQueue.main.async { + if !self.parent.isPinned { + self.parent.currentPrecision = precision + } + } + } else if type == "geohash", let geohash = dict["value"] as? String { + // Only update selectedGeohash if this isn't just an automatic center change + // during focusing on a specific geohash or during initialization + if geohash != self.lastGeohash && !self.isInitializing { + DispatchQueue.main.async { + self.parent.selectedGeohash = geohash + self.lastGeohash = geohash + } + } + } else if type == "saveMapState", + let stateData = dict["value"] as? [String: Any], + let lat = stateData["lat"] as? Double, + let lng = stateData["lng"] as? Double, + let zoom = stateData["zoom"] as? Double { + let precision = stateData["precision"] as? Int + DispatchQueue.main.async { + self.saveMapState(lat: lat, lng: lng, zoom: zoom, precision: precision) + } + } + } + } + } + } +} + +#Preview { + GeohashMapView(selectedGeohash: .constant(""), initialGeohash: "") +} diff --git a/bitchat/Views/GeohashPickerSheet.swift b/bitchat/Views/GeohashPickerSheet.swift new file mode 100644 index 000000000..a7e6c1692 --- /dev/null +++ b/bitchat/Views/GeohashPickerSheet.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct GeohashPickerSheet: View { + @Binding var isPresented: Bool + let onGeohashSelected: (String) -> Void + let initialGeohash: String + @State private var selectedGeohash: String = "" + @State private var currentPrecision: Int? = 6 + @Environment(\.colorScheme) var colorScheme + + init(isPresented: Binding, initialGeohash: String = "", onGeohashSelected: @escaping (String) -> Void) { + self._isPresented = isPresented + self.initialGeohash = initialGeohash + self.onGeohashSelected = onGeohashSelected + self._selectedGeohash = State(initialValue: initialGeohash) + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white + } + + private enum Strings { + static let instruction = String(localized: "geohash_picker.instruction", comment: "Instruction text for geohash map picker") + static let selectButton = String(localized: "geohash_picker.select_button", comment: "Select button text in geohash picker") + } + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + var body: some View { + ZStack { + // Full-screen map + GeohashMapView( + selectedGeohash: $selectedGeohash, + initialGeohash: initialGeohash, + showFloatingControls: false, + precision: $currentPrecision + ) + .ignoresSafeArea() + + // Top instruction banner + VStack { + HStack { + Text(Strings.instruction) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95)) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 2) + ) + } + .padding(.horizontal, 16) + .padding(.top, 20) // Smaller top padding and more on top + Spacer() + } + + // Current geohash display + VStack { + Spacer() + HStack { + Spacer() + Text("#\(selectedGeohash.isEmpty ? "" : selectedGeohash)") + .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 22) + .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95)) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + ) + Spacer() + } + .padding(.bottom, 120) // Position geohash display a bit down + } + + // Bottom controls bar + VStack { + Spacer() + HStack(spacing: 12) { + // Minus button + Button(action: { + if let precision = currentPrecision, precision > 1 { + currentPrecision = precision - 1 + } + }) { + Image(systemName: "minus") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor((currentPrecision ?? 6) <= 1 ? Color.secondary : textColor) + .frame(width: 60, height: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(textColor.opacity(0.15)) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + ) + } + .disabled((currentPrecision ?? 6) <= 1) + + // Plus button + Button(action: { + if let precision = currentPrecision, precision < 12 { + currentPrecision = precision + 1 + } + }) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor((currentPrecision ?? 6) >= 12 ? Color.secondary : textColor) + .frame(width: 60, height: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(textColor.opacity(0.15)) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + ) + } + .disabled((currentPrecision ?? 6) >= 12) + + // Select button + Button(action: { + if !selectedGeohash.isEmpty { + onGeohashSelected(selectedGeohash) + } + }) { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + Text(Strings.selectButton) + .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) + } + .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : Color.secondary) + .frame(minWidth: 100, minHeight: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.secondary.opacity(0.15)) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + ) + } + .disabled(selectedGeohash.isEmpty) + .opacity(selectedGeohash.isEmpty ? 0.6 : 1.0) + } + .padding(.horizontal, 20) + .padding(.bottom, 40) // Move buttons more up in the screen + } + } + .background(backgroundColor) + #if os(macOS) + .frame(minWidth: 800, minHeight: 600) + #endif + .onAppear { + // Always sync selectedGeohash with initialGeohash when view appears + // This ensures we restore the last selected geohash from LocationChannelsSheet + if !initialGeohash.isEmpty { + selectedGeohash = initialGeohash + currentPrecision = initialGeohash.count + } + } + } +} + +#Preview { + GeohashPickerSheet( + isPresented: .constant(true), + initialGeohash: "", + onGeohashSelected: { _ in } + ) +} diff --git a/bitchat/Views/GeohashPickerWindow.swift b/bitchat/Views/GeohashPickerWindow.swift new file mode 100644 index 000000000..716a4c738 --- /dev/null +++ b/bitchat/Views/GeohashPickerWindow.swift @@ -0,0 +1,204 @@ +#if os(macOS) +import SwiftUI +import AppKit + +private var geohashWindowController: GeohashPickerWindowController? + +func openGeohashPickerWindow(initialGeohash: String, onSelection: @escaping (String) -> Void) { + geohashWindowController = GeohashPickerWindowController(initialGeohash: initialGeohash, onSelection: onSelection) + geohashWindowController?.showWindow(nil) + geohashWindowController?.window?.makeKeyAndOrderFront(nil) +} + +class GeohashPickerWindowController: NSWindowController { + let onSelection: (String) -> Void + let initialGeohash: String + + init(initialGeohash: String, onSelection: @escaping (String) -> Void) { + self.initialGeohash = initialGeohash + self.onSelection = onSelection + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + super.init(window: window) + + window.center() + window.setFrameAutosaveName("GeohashPickerWindow") + + // Create the SwiftUI view + let contentView = GeohashPickerWindowView( + initialGeohash: initialGeohash, + onSelection: { [weak self] selectedGeohash in + self?.onSelection(selectedGeohash) + self?.window?.close() + geohashWindowController = nil + } + ) + + window.contentView = NSHostingView(rootView: contentView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct GeohashPickerWindowView: View { + let initialGeohash: String + let onSelection: (String) -> Void + @State private var selectedGeohash: String = "" + @State private var currentPrecision: Int? = 6 + @Environment(\.colorScheme) var colorScheme + + init(initialGeohash: String, onSelection: @escaping (String) -> Void) { + self.initialGeohash = initialGeohash + self.onSelection = onSelection + self._selectedGeohash = State(initialValue: initialGeohash) + } + + private enum Strings { + static let instruction = String(localized: + "geohash_picker.instruction", + comment: "Instruction text for geohash map picker" + ) + + static let selectButton = String(localized: + "geohash_picker.select_button", + comment: "Select button text in geohash picker" + ) + } + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + var body: some View { + ZStack { + // Full-screen map + GeohashMapView( + selectedGeohash: $selectedGeohash, + initialGeohash: initialGeohash, + showFloatingControls: false, + precision: $currentPrecision + ) + + // Top instruction banner + VStack { + HStack { + Text(Strings.instruction) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95)) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 2) + ) + } + .padding(.horizontal, 16) + .padding(.top, 20) + Spacer() + } + + // Current geohash display + VStack { + Spacer() + HStack { + Spacer() + Text("#\(selectedGeohash.isEmpty ? "" : selectedGeohash)") + .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .white : .black) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 22) + .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95)) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + ) + Spacer() + } + .padding(.bottom, 120) + } + + // Bottom controls bar + VStack { + Spacer() + HStack(spacing: 12) { + // Minus button + Button(action: { + if let precision = currentPrecision, precision > 1 { + currentPrecision = precision - 1 + } + }) { + Image(systemName: "minus") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor((currentPrecision ?? 6) <= 1 ? Color.secondary : textColor) + .frame(width: 60, height: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(textColor.opacity(0.15)) + ) + } + .disabled((currentPrecision ?? 6) <= 1) + .buttonStyle(.plain) + + // Plus button + Button(action: { + if let precision = currentPrecision, precision < 12 { + currentPrecision = precision + 1 + } + }) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor((currentPrecision ?? 6) >= 12 ? Color.secondary : textColor) + .frame(width: 60, height: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(textColor.opacity(0.15)) + ) + } + .disabled((currentPrecision ?? 6) >= 12) + .buttonStyle(.plain) + + // Select button + Button(action: { + if !selectedGeohash.isEmpty { + onSelection(selectedGeohash) + } + }) { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + Text(Strings.selectButton) + .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) + } + .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : Color.secondary) + .frame(minWidth: 100, minHeight: 50) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color.secondary.opacity(0.15)) + ) + } + .disabled(selectedGeohash.isEmpty) + .opacity(selectedGeohash.isEmpty ? 0.6 : 1.0) + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + .onAppear { + if !initialGeohash.isEmpty { + selectedGeohash = initialGeohash + currentPrecision = initialGeohash.count + } + } + } +} +#endif diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 998e60d75..4497e4cbb 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -7,13 +7,14 @@ import AppKit #endif struct LocationChannelsSheet: View { @Binding var isPresented: Bool + @Binding var customGeohash: String @ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @ObservedObject private var network = NetworkActivationService.shared @EnvironmentObject var viewModel: ChatViewModel @Environment(\.colorScheme) var colorScheme - @State private var customGeohash: String = "" @State private var customError: String? = nil + @State private var showGeohashMap = false private var backgroundColor: Color { colorScheme == .dark ? .black : .white } @@ -126,7 +127,7 @@ struct LocationChannelsSheet: View { #endif } #if os(macOS) - .frame(minWidth: 420, minHeight: 520) + .frame(minWidth: 420, minHeight: 680) #endif .background(backgroundColor) .onAppear { @@ -282,6 +283,42 @@ struct LocationChannelsSheet: View { customGeohash = filtered } } + // Map picker button + Button(action: { showGeohashMap = true }) { + Image(systemName: "map") + .font(.bitchatSystem(size: 14)) + } + .buttonStyle(.plain) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(6) + #if os(iOS) + .sheet(isPresented: $showGeohashMap) { + let processedGeohash = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") + return GeohashPickerSheet( + isPresented: $showGeohashMap, + initialGeohash: processedGeohash + ) { selectedGeohash in + customGeohash = selectedGeohash + showGeohashMap = false + } + .environmentObject(viewModel) + .onAppear { + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + #else + .onChange(of: showGeohashMap) { newValue in + if newValue { + let processedGeohash = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") + openGeohashPickerWindow(initialGeohash: processedGeohash, onSelection: { selectedGeohash in + customGeohash = selectedGeohash + }) + showGeohashMap = false + } + } + #endif let normalized = customGeohash .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -490,6 +527,8 @@ extension LocationChannelsSheet { .padding(12) .background(Color.secondary.opacity(0.12)) .cornerRadius(8) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } private var standardGreen: Color { @@ -616,3 +655,4 @@ private func openSystemLocationSettings() { } #endif } +