From ffdd1eb0e259b65bb9df4421f9d0285bc6fabd42 Mon Sep 17 00:00:00 2001 From: mimivu Date: Sat, 14 Mar 2026 17:15:21 +1100 Subject: [PATCH] fixed ui, added onboarding and messages + images under alerts --- .../BluetoothMeshService+Comments.swift | 65 ++ MeshChatMVP/BluetoothMeshService.swift | 84 ++ MeshChatMVP/ChatView.swift | 269 +++--- MeshChatMVP/ContentView.swift | 325 ++++++- MeshChatMVP/DebugDashboardView.swift | 136 +-- MeshChatMVP/MapLabelModels.swift | 144 ++- MeshChatMVP/MapTabView.swift | 913 ++++++++++++------ 7 files changed, 1371 insertions(+), 565 deletions(-) create mode 100644 MeshChatMVP/BluetoothMeshService+Comments.swift diff --git a/MeshChatMVP/BluetoothMeshService+Comments.swift b/MeshChatMVP/BluetoothMeshService+Comments.swift new file mode 100644 index 0000000..4460acb --- /dev/null +++ b/MeshChatMVP/BluetoothMeshService+Comments.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Extension that wires label comment state + sending into the existing mesh service. +/// Add the `@Published var labelComments` property to BluetoothMeshService directly, +/// or use this approach: we store comments in a shared store accessed via this extension. +/// +/// TO INTEGRATE: Add the following to BluetoothMeshService's stored properties: +/// @Published var labelComments: [UUID: [LabelComment]] = [:] +/// +/// And in the envelope-handling switch add a case for `.labelComment` that calls `handleLabelComment(_:)`. +/// +/// The stubs below compile against the existing type; replace with real BLE send when ready. + +extension BluetoothMeshService { + + /// Send a comment on an alert label. + /// Wire this to a new `.labelComment` MessageType for real mesh delivery. + func sendLabelComment(labelId: UUID, text: String, imageJPEGBase64: String? = nil) { + let comment = LabelComment( + id: UUID(), + labelId: labelId, + senderID: identity.deviceID, + senderName: identity.nickname, + text: text, + imageJPEGBase64: imageJPEGBase64, + date: Date(), + isLocal: true + ) + DispatchQueue.main.async { + var map = self.labelComments + var list = map[labelId] ?? [] + list.append(comment) + map[labelId] = list + self.labelComments = map + } + + // TODO: encode as LabelCommentPayload and relay via BLE + // let payload = LabelCommentPayload(...) + // enqueue(payload, type: .labelComment) + } + + /// Ingest a comment received from a peer (call this from your BLE receive handler). + func handleLabelComment(_ payload: LabelCommentPayload) { + let comment = LabelComment( + id: payload.commentId, + labelId: payload.labelId, + senderID: payload.senderID, + senderName: payload.senderName, + text: payload.text, + imageJPEGBase64: payload.imageJPEGBase64, + date: Date(timeIntervalSince1970: Double(payload.timestamp) / 1000), + isLocal: false + ) + DispatchQueue.main.async { + var map = self.labelComments + var list = map[payload.labelId] ?? [] + // Deduplicate by commentId + guard !list.contains(where: { $0.id == payload.commentId }) else { return } + list.append(comment) + list.sort { $0.date < $1.date } + map[payload.labelId] = list + self.labelComments = map + } + } +} diff --git a/MeshChatMVP/BluetoothMeshService.swift b/MeshChatMVP/BluetoothMeshService.swift index 5c798ef..7e65a09 100644 --- a/MeshChatMVP/BluetoothMeshService.swift +++ b/MeshChatMVP/BluetoothMeshService.swift @@ -46,6 +46,9 @@ final class BluetoothMeshService: NSObject, ObservableObject { /// Label votes: labelId → (voterID → 1 or -1); shared via .mapLabelVote envelopes. @Published var labelVotes: [UUID: [String: Int]] = [:] + /// Comments (forum-style thread) per label: labelId → sorted array of LabelComment. + @Published var labelComments: [UUID: [LabelComment]] = [:] + /// Seconds remaining before this user can post another map label (30s cooldown). @Published var mapLabelCooldownRemaining: Double = 0 @@ -86,6 +89,14 @@ final class BluetoothMeshService: NSObject, ObservableObject { private var pruneTimer: Timer? /// Single upsert map for discovered peers (avoids duplicate rows from concurrent main-queue updates). private var discoveredPeerById: [UUID: DiscoveredPeer] = [:] + /// Chat history TTL (persisted messages older than this are dropped). + private let chatHistoryTTLSeconds: TimeInterval = 20 * 60 + private var chatHistoryURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("MeshChatMVP", isDirectory: true) + .appendingPathComponent("chat_history.json") + } + private var scanIdleWorkItem: DispatchWorkItem? private var scanCountdownTimer: Timer? private var nextScanDeadline: Date? @@ -121,7 +132,9 @@ final class BluetoothMeshService: NSObject, ObservableObject { locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters locationManager.requestWhenInUseAuthorization() startPruneTimer() + loadChatHistory() requestNotificationAuthIfNeeded() + startChatHistoryPruneTimer() } deinit { @@ -897,6 +910,77 @@ final class BluetoothMeshService: NSObject, ObservableObject { private func appendChatMessage(_ m: ChatMessage) { chatMessages.append(m) + pruneChatByTTL() + saveChatHistory() + } + + private func pruneChatByTTL() { + let cutoff = Date().addingTimeInterval(-chatHistoryTTLSeconds) + chatMessages.removeAll { $0.date < cutoff } + } + + private func loadChatHistory() { + let dir = chatHistoryURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + guard let data = try? Data(contentsOf: chatHistoryURL), + let decoded = try? JSONDecoder().decode([ChatMessage].self, from: data) + else { return } + let cutoff = Date().addingTimeInterval(-chatHistoryTTLSeconds) + let fresh = decoded.filter { $0.date >= cutoff }.map { m -> ChatMessage in + guard m.imageJPEGBase64 != nil else { return m } + let label = m.text.isEmpty ? "[photo]" : m.text + return ChatMessage( + id: m.id, envelopeId: m.envelopeId, senderID: m.senderID, senderName: m.senderName, + text: label, date: m.date, isLocal: m.isLocal, distanceFromMe: m.distanceFromMe, + imageJPEGBase64: nil + ) + } + DispatchQueue.main.async { [weak self] in + self?.chatMessages = fresh + } + } + + private func saveChatHistory() { + pruneChatByTTL() + let dir = chatHistoryURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Never persist image bytes — only placeholders (text) go to disk. + let forDisk: [ChatMessage] = chatMessages.map { m in + let label: String + if m.imageJPEGBase64 != nil { + if m.text == "Photo" || m.text == "[photo]" || m.text.hasPrefix("Photo") { + label = "[photo]" + } else { + label = m.text.isEmpty ? "[photo]" : m.text + } + } else { + label = m.text + } + return ChatMessage( + id: m.id, + envelopeId: m.envelopeId, + senderID: m.senderID, + senderName: m.senderName, + text: label, + date: m.date, + isLocal: m.isLocal, + distanceFromMe: m.distanceFromMe, + imageJPEGBase64: nil + ) + } + if let data = try? JSONEncoder().encode(forDisk) { + try? data.write(to: chatHistoryURL, options: .atomic) + } + } + + private func startChatHistoryPruneTimer() { + DispatchQueue.main.async { [weak self] in + Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + guard let self else { return } + self.pruneChatByTTL() + self.saveChatHistory() + } + } } private func requestNotificationAuthIfNeeded() { diff --git a/MeshChatMVP/ChatView.swift b/MeshChatMVP/ChatView.swift index 35aad28..d08f479 100644 --- a/MeshChatMVP/ChatView.swift +++ b/MeshChatMVP/ChatView.swift @@ -1,68 +1,27 @@ import SwiftUI import PhotosUI -/// Chat-only UI (mesh status strip + transcript + send + simple photo send). +/// Chat UI — clean Apple Messages-inspired design. struct ChatView: View { @EnvironmentObject var mesh: BluetoothMeshService @State private var draft = "" @FocusState private var messageFocused: Bool @State private var sendCooldown = false - @State private var showPhotoPicker = false @State private var pickedItem: PhotosPickerItem? @State private var imageBusy = false + private var isConnected: Bool { + mesh.readyRemoteCount > 0 || mesh.subscribedCentralCount > 0 + } + var body: some View { NavigationStack { VStack(spacing: 0) { - statusStrip + statusBanner Divider() - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(mesh.chatMessages) { m in - bubble(m) - .id(m.id) - } - } - .padding() - .contentShape(Rectangle()) - .onTapGesture { messageFocused = false } - } - .onChange(of: mesh.chatMessages.count) { _ in - if let last = mesh.chatMessages.last { - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } - } - } - } + messageList Divider() - HStack(alignment: .bottom, spacing: 10) { - PhotosPicker(selection: $pickedItem, matching: .images, photoLibrary: .shared()) { - Image(systemName: "photo.on.rectangle.angled") - .font(.title2) - .foregroundColor(imageBusy ? .secondary : .accentColor) - } - .disabled(imageBusy || sendCooldown) - TextField("Message", text: $draft, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(1...4) - .focused($messageFocused) - Button { - let t = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !t.isEmpty, !sendCooldown else { return } - sendCooldown = true - mesh.sendChat(text: t) - draft = "" - messageFocused = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - sendCooldown = false - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.title2) - } - .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sendCooldown) - } - .padding() + inputBar } .navigationTitle(mesh.identity.nickname) .navigationBarTitleDisplayMode(.inline) @@ -72,10 +31,9 @@ struct ChatView: View { Button("Done") { messageFocused = false } } ToolbarItem(placement: .topBarTrailing) { - Button { - messageFocused = false - } label: { - Label("Hide keyboard", systemImage: "keyboard.chevron.compact.down") + Button { messageFocused = false } label: { + Image(systemName: "keyboard.chevron.compact.down") + .foregroundStyle(.secondary) } .opacity(messageFocused ? 1 : 0) .disabled(!messageFocused) @@ -85,104 +43,191 @@ struct ChatView: View { guard let newItem else { return } imageBusy = true Task { - defer { - Task { @MainActor in - imageBusy = false - pickedItem = nil - } - } + defer { Task { @MainActor in imageBusy = false; pickedItem = nil } } guard let data = try? await newItem.loadTransferable(type: Data.self), let ui = UIImage(data: data), - let jpeg = try? MeshImageUtils.jpegDataForMesh(from: ui) - else { return } + let jpeg = try? MeshImageUtils.jpegDataForMesh(from: ui) else { return } await MainActor.run { sendCooldown = true mesh.sendImage(jpegData: jpeg) messageFocused = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - sendCooldown = false - } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { sendCooldown = false } } } } } } - private var statusStrip: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 12) { - Label( - mesh.readyRemoteCount > 0 || mesh.subscribedCentralCount > 0 ? "Linked" : "Finding peers…", - systemImage: mesh.readyRemoteCount > 0 || mesh.subscribedCentralCount > 0 - ? "link.circle.fill" : "antenna.radiowaves.left.and.right" - ) - .font(.subheadline.weight(.medium)) - .foregroundStyle( - mesh.readyRemoteCount > 0 || mesh.subscribedCentralCount > 0 ? Color.green : Color.secondary - ) - Spacer() - if mesh.isScanning { + // MARK: - Status Banner + + private var statusBanner: some View { + HStack(spacing: 8) { + Circle() + .fill(isConnected ? Color.green : Color(.systemGray4)) + .frame(width: 7, height: 7) + Text(isConnected ? "Connected" : "Searching for peers") + .font(.subheadline) + .foregroundStyle(isConnected ? .primary : .secondary) + Spacer() + if mesh.isScanning { + HStack(spacing: 5) { + ProgressView().scaleEffect(0.65) Text("Scanning") - .font(.caption2) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color.green.opacity(0.2))) - } else if mesh.secondsUntilNextScan > 0 { - Text("Next scan \(Int(mesh.secondsUntilNextScan))s") - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) } + } else if mesh.secondsUntilNextScan > 0 { + Text("Scan in \(Int(mesh.secondsUntilNextScan))s") + .font(.caption) + .foregroundStyle(.tertiary) + .monospacedDigit() } - Text("Photos: small JPEG chunks over BLE (~12KB max). Switch tab → Dashboard to leave chat.") - .font(.caption2) - .foregroundStyle(.tertiary) } - .padding(.horizontal) + .padding(.horizontal, 16) .padding(.vertical, 10) - .background(Color(.secondarySystemBackground)) } - private func bubble(_ m: ChatMessage) -> some View { - HStack { - if m.isLocal { Spacer(minLength: 48) } - VStack(alignment: m.isLocal ? .trailing : .leading, spacing: 4) { - HStack(spacing: 6) { - if !m.isLocal { Text(m.senderName).font(.caption.weight(.semibold)) } - Text(m.date, style: .time).font(.caption2).foregroundStyle(.secondary) - if m.isLocal { Text("You").font(.caption.weight(.semibold)) } + // MARK: - Message List + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + if mesh.chatMessages.isEmpty { + emptyState + } else { + LazyVStack(spacing: 2) { + ForEach(mesh.chatMessages) { m in + messageBubble(m).id(m.id) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) } - if !m.isLocal, let dist = m.distanceFromMe { - Text(distanceString(dist)).font(.caption2).foregroundStyle(.secondary) + } + .background(Color(.systemGroupedBackground)) + .contentShape(Rectangle()) + .onTapGesture { messageFocused = false } + .onChange(of: mesh.chatMessages.count) { _ in + if let last = mesh.chatMessages.last { + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Spacer(minLength: 80) + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 40, weight: .light)) + .foregroundStyle(Color(.systemGray4)) + Text("No messages yet") + .font(.headline) + .foregroundStyle(.secondary) + Text("Messages sent over the Bluetooth mesh appear here.") + .font(.subheadline) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + Spacer() + } + } + + // MARK: - Message Bubble + + private func messageBubble(_ m: ChatMessage) -> some View { + HStack(alignment: .bottom) { + if m.isLocal { Spacer(minLength: 60) } + VStack(alignment: m.isLocal ? .trailing : .leading, spacing: 3) { + if !m.isLocal { + HStack(spacing: 4) { + Text(m.senderName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if let dist = m.distanceFromMe { + Text("·").font(.caption2).foregroundStyle(.tertiary) + Text(distanceString(dist)).font(.caption2).foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 4) } Group { - if let b64 = m.imageJPEGBase64, let imgData = Data(base64Encoded: b64), let ui = UIImage(data: imgData) { + if let b64 = m.imageJPEGBase64, let data = Data(base64Encoded: b64), let ui = UIImage(data: data) { Image(uiImage: ui) - .resizable() - .scaledToFit() + .resizable().scaledToFit() .frame(maxWidth: 220, maxHeight: 220) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } else { Text(m.text) .font(.body) + .foregroundStyle(m.isLocal ? .white : .primary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(m.isLocal ? Color.primary : Color(.systemBackground)) + ) } } - .padding(12) + Text(m.date, style: .time) + .font(.caption2).foregroundStyle(.tertiary) + .padding(.horizontal, 4) + } + if !m.isLocal { Spacer(minLength: 60) } + } + .padding(.vertical, 2) + } + + // MARK: - Input Bar + + private var inputBar: some View { + HStack(alignment: .bottom, spacing: 10) { + PhotosPicker(selection: $pickedItem, matching: .images, photoLibrary: .shared()) { + Image(systemName: "photo") + .font(.system(size: 20)) + .foregroundStyle(imageBusy ? Color(.systemGray4) : .secondary) + .frame(width: 34, height: 34) + } + .disabled(imageBusy || sendCooldown) + + TextField("Message", text: $draft, axis: .vertical) + .font(.body) + .lineLimit(1...4) + .focused($messageFocused) + .padding(.horizontal, 12) + .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: 16) - .fill(m.isLocal ? Color.accentColor.opacity(0.2) : Color(.secondarySystemBackground)) + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(.systemGray6)) ) + + Button { + let t = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !t.isEmpty, !sendCooldown else { return } + sendCooldown = true + mesh.sendChat(text: t) + draft = "" + messageFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { sendCooldown = false } + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 30)) + .foregroundStyle( + draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sendCooldown + ? Color(.systemGray4) : Color.primary + ) } - if !m.isLocal { Spacer(minLength: 48) } + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sendCooldown) } + .padding(.horizontal, 12) + .padding(.vertical, 10) } private func distanceString(_ meters: Double) -> String { - if meters < 1000 { return "~\(Int(round(meters))) m away" } - return String(format: "~%.1f km away", meters / 1000) + meters < 1000 ? "~\(Int(round(meters)))m" : String(format: "~%.1fkm", meters / 1000) } } #Preview { - ChatView() - .environmentObject(BluetoothMeshService()) + ChatView().environmentObject(BluetoothMeshService()) } diff --git a/MeshChatMVP/ContentView.swift b/MeshChatMVP/ContentView.swift index 803d31c..c0d4692 100644 --- a/MeshChatMVP/ContentView.swift +++ b/MeshChatMVP/ContentView.swift @@ -2,69 +2,328 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var mesh: BluetoothMeshService - @State private var nicknameEditor = "" + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + + var body: some View { + Group { + if !hasCompletedOnboarding { + OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding) + } else { + MainTabView() + } + } + .onAppear { + mesh.syncScanTimingFromUI() + applyGlobalAppearance() + } + } + + private func applyGlobalAppearance() { + let tabAppearance = UITabBarAppearance() + tabAppearance.configureWithOpaqueBackground() + tabAppearance.backgroundColor = UIColor.systemBackground + tabAppearance.shadowColor = UIColor.separator + UITabBar.appearance().standardAppearance = tabAppearance + UITabBar.appearance().scrollEdgeAppearance = tabAppearance + + let navAppearance = UINavigationBarAppearance() + navAppearance.configureWithOpaqueBackground() + navAppearance.backgroundColor = UIColor.systemBackground + navAppearance.shadowColor = UIColor.separator + navAppearance.titleTextAttributes = [.foregroundColor: UIColor.label] + navAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.label] + UINavigationBar.appearance().standardAppearance = navAppearance + UINavigationBar.appearance().scrollEdgeAppearance = navAppearance + } +} + +// MARK: - Onboarding + +struct OnboardingView: View { + @Binding var hasCompletedOnboarding: Bool + @EnvironmentObject var mesh: BluetoothMeshService + @State private var currentPage = 0 + @State private var nickname = "" + + private let pages: [OnboardingPage] = [ + OnboardingPage( + icon: "antenna.radiowaves.left.and.right", + title: "Mesh Communication", + body: "MeshMap works without internet or cell service. Messages travel peer-to-peer over Bluetooth, hopping between nearby devices." + ), + OnboardingPage( + icon: "map", + title: "Alert the Network", + body: "Place geo-tagged alerts directly on the map. Others can verify events with comments and votes, building a real-time picture of the ground situation." + ), + OnboardingPage( + icon: "lock.shield", + title: "Privacy First", + body: "Location sharing is opt-in. Your device ID is generated locally and never leaves the mesh. No accounts, no servers, no tracking." + ) + ] + + var body: some View { + ZStack { + Color(.systemBackground).ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // Page content + TabView(selection: $currentPage) { + ForEach(Array(pages.enumerated()), id: \.offset) { index, page in + pageView(page) + .tag(index) + } + nicknameSetupPage + .tag(pages.count) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.easeInOut, value: currentPage) + + Spacer() + + // Page dots + HStack(spacing: 6) { + ForEach(0.. some View { + VStack(spacing: 24) { + Image(systemName: page.icon) + .font(.system(size: 52, weight: .light)) + .foregroundStyle(Color.primary) + .frame(width: 96, height: 96) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color(.systemGray6)) + ) + VStack(spacing: 12) { + Text(page.title) + .font(.system(size: 26, weight: .semibold)) + .multilineTextAlignment(.center) + Text(page.body) + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + .padding(.horizontal, 8) + } + } + .padding(.horizontal, 32) + } + + private var nicknameSetupPage: some View { + VStack(spacing: 24) { + Image(systemName: "person.circle") + .font(.system(size: 52, weight: .light)) + .foregroundStyle(Color.primary) + .frame(width: 96, height: 96) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color(.systemGray6)) + ) + VStack(spacing: 12) { + Text("Choose a Callsign") + .font(.system(size: 26, weight: .semibold)) + Text("This is how others on the mesh will identify you. It does not need to be your real name.") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + .padding(.horizontal, 8) + } + TextField("e.g. Delta-7 or Rania", text: $nickname) + .font(.system(size: 17)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(.systemGray6)) + ) + .padding(.horizontal, 24) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onAppear { + nickname = mesh.identity.nickname + } + } + .padding(.horizontal, 32) + } + + private func finishOnboarding() { + let name = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + var id = mesh.identity + id.nickname = name + mesh.updateIdentity(id) + } + withAnimation { hasCompletedOnboarding = true } + } +} + +private struct OnboardingPage { + let icon: String + let title: String + let body: String +} + +// MARK: - Main Tab View + +private struct MainTabView: View { + @EnvironmentObject var mesh: BluetoothMeshService var body: some View { TabView { ChatView() .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } - MapTabView() .tabItem { Label("Map", systemImage: "map") } - DebugDashboardView() - .tabItem { Label("Dashboard", systemImage: "square.grid.2x2") } - + .tabItem { Label("Network", systemImage: "antenna.radiowaves.left.and.right") } ProfileView() - .tabItem { Label("You", systemImage: "person.circle") } - } - .onAppear { - nicknameEditor = mesh.identity.nickname - mesh.syncScanTimingFromUI() + .tabItem { Label("Profile", systemImage: "person.circle") } } + .tint(.primary) } } -private struct ProfileView: View { +// MARK: - Profile + +struct ProfileView: View { @EnvironmentObject var mesh: BluetoothMeshService @State private var nicknameEditor = "" + @State private var isSaved = false var body: some View { NavigationStack { - Form { + List { + Section { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color(.systemGray6)) + .frame(width: 58, height: 58) + Text(String(mesh.identity.nickname.prefix(1)).uppercased()) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.primary) + } + VStack(alignment: .leading, spacing: 3) { + Text(mesh.identity.nickname) + .font(.headline) + Text("Mesh Node") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 6) + } + Section("Identity") { - TextField("Nickname", text: $nicknameEditor) - Button("Save nickname") { + HStack(spacing: 12) { + Image(systemName: "person") + .foregroundStyle(.secondary) + .frame(width: 20) + TextField("Callsign", text: $nicknameEditor) + .autocorrectionDisabled() + } + Button { + guard !nicknameEditor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } var id = mesh.identity - id.nickname = nicknameEditor.isEmpty ? id.nickname : nicknameEditor + id.nickname = nicknameEditor.trimmingCharacters(in: .whitespacesAndNewlines) mesh.updateIdentity(id) + isSaved = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { isSaved = false } + } label: { + HStack(spacing: 12) { + Image(systemName: isSaved ? "checkmark.circle" : "square.and.arrow.down") + .foregroundStyle(isSaved ? .green : .accentColor) + .frame(width: 20) + Text(isSaved ? "Saved" : "Save Callsign") + .foregroundStyle(isSaved ? .green : .accentColor) + } } - LabeledContent("Device ID") { - Text(mesh.identity.deviceID) - .font(.caption) - .textSelection(.enabled) + HStack(spacing: 12) { + Image(systemName: "number") + .foregroundStyle(.secondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text("Device ID") + .font(.caption) + .foregroundStyle(.secondary) + Text(mesh.identity.deviceID) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } } + .padding(.vertical, 2) } + Section("Privacy") { - Toggle("Share my location with peers", isOn: Binding( - get: { mesh.identity.shareLocation }, - set: { newValue in - var id = mesh.identity - id.shareLocation = newValue - mesh.updateIdentity(id) - } - )) - Text("When on, your coordinates are included in messages so others can see approximate distance. You can turn this off anytime.") + HStack(spacing: 12) { + Image(systemName: "location") + .foregroundStyle(.secondary) + .frame(width: 20) + Toggle("Share Location with Peers", isOn: Binding( + get: { mesh.identity.shareLocation }, + set: { v in + var id = mesh.identity + id.shareLocation = v + mesh.updateIdentity(id) + } + )) + .tint(.primary) + } + Text("When enabled, your approximate coordinates are broadcast so nearby peers can see distance. You can disable this at any time.") .font(.caption) .foregroundStyle(.secondary) } - Section("Tips") { - Text("Open Chat on both phones. Dashboard shows scan windows and auto-connect. Keep apps in foreground for best results.") - .font(.caption) - .foregroundStyle(.secondary) + + Section("About") { + HStack(spacing: 12) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + .frame(width: 20) + Text("Messages are relayed over Bluetooth and do not require internet. Keep the app in the foreground for best results.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) } } - .navigationTitle("You") + .navigationTitle("Profile") .onAppear { nicknameEditor = mesh.identity.nickname } } } diff --git a/MeshChatMVP/DebugDashboardView.swift b/MeshChatMVP/DebugDashboardView.swift index 40a5bbd..65ea320 100644 --- a/MeshChatMVP/DebugDashboardView.swift +++ b/MeshChatMVP/DebugDashboardView.swift @@ -1,100 +1,123 @@ import SwiftUI import CoreBluetooth -/// BLE + mesh diagnostics: scan cycle, peers, log (separate from chat UX). +/// Network diagnostics panel. struct DebugDashboardView: View { @EnvironmentObject var mesh: BluetoothMeshService var body: some View { NavigationStack { List { - Section("Bluetooth") { - LabeledContent("Central / peripheral") { - Text(stateLabel(mesh.bluetoothState)) - .foregroundStyle(mesh.bluetoothState == .poweredOn ? .green : .orange) - } - LabeledContent("Subscribers (others linked to you)") { - Text("\(mesh.subscribedCentralCount)") + Section { + connectionRow + LabeledContent("Subscribers") { + Text("\(mesh.subscribedCentralCount)").foregroundStyle(.secondary) } - LabeledContent("Outbound links ready") { - Text("\(mesh.readyRemoteCount) / \(mesh.connectedPeerNames.count)") + LabeledContent("Outbound Links") { + Text("\(mesh.readyRemoteCount) / \(mesh.connectedPeerNames.count)").foregroundStyle(.secondary) } + } header: { + Label("Bluetooth", systemImage: "bolt").font(.footnote.weight(.medium)).textCase(nil) } - Section("Scan cycle (saves battery)") { - Toggle("Auto-connect when a peer is seen", isOn: $mesh.autoConnectEnabled) - Toggle("Auto-reconnect after drop (off = calmer)", isOn: $mesh.autoReconnectEnabled) - HStack { - Text("Scan ON") - Slider(value: $mesh.scanWindowSeconds, in: 4...30, step: 1) - Text("\(Int(mesh.scanWindowSeconds))s") - .monospacedDigit() - .frame(width: 36, alignment: .trailing) - } - HStack { - Text("Scan OFF") - Slider(value: $mesh.scanIdleSeconds, in: 15...120, step: 5) - Text("\(Int(mesh.scanIdleSeconds))s") - .monospacedDigit() - .frame(width: 36, alignment: .trailing) - } - Button("Apply timing (next cycle)") { - mesh.syncScanTimingFromUI() + Section { + Toggle("Auto-Connect", isOn: $mesh.autoConnectEnabled).tint(.primary) + Toggle("Auto-Reconnect", isOn: $mesh.autoReconnectEnabled).tint(.primary) + sliderRow(label: "Scan On", value: $mesh.scanWindowSeconds, range: 4...30, step: 1) + sliderRow(label: "Scan Off", value: $mesh.scanIdleSeconds, range: 15...120, step: 5) + Button { mesh.syncScanTimingFromUI() } label: { + Label("Apply Timing", systemImage: "checkmark.circle").foregroundStyle(.primary) } LabeledContent("Status") { - HStack { + HStack(spacing: 6) { Circle() - .fill(mesh.isScanning ? Color.green : Color.orange) - .frame(width: 10, height: 10) + .fill(mesh.isScanning ? Color.green : Color(.systemGray4)) + .frame(width: 7, height: 7) Text(mesh.isScanning ? "Scanning" : "Idle") + .foregroundStyle(.secondary) } } if !mesh.isScanning, mesh.secondsUntilNextScan > 0 { - LabeledContent("Next scan in") { - Text("\(Int(mesh.secondsUntilNextScan))s") + LabeledContent("Next Scan") { + Text("\(Int(mesh.secondsUntilNextScan))s").monospacedDigit().foregroundStyle(.secondary) } } - Button("Scan now") { mesh.scanNow() } - Text("Disconnect loop fix: GATT is published once — repeated setup no longer kicks subscribers off.") - .font(.caption2) - .foregroundStyle(.secondary) + Button { mesh.scanNow() } label: { + Label("Scan Now", systemImage: "antenna.radiowaves.left.and.right").foregroundStyle(.primary) + } + } header: { + Label("Scan Cycle", systemImage: "timer").font(.footnote.weight(.medium)).textCase(nil) + } footer: { + Text("Shorter scan windows save battery. Tap Scan Now to connect immediately.") } - Section("Peers (one row per device — discovery + link)") { + Section { if mesh.discoveredPeers.isEmpty { - Text("No peers in this scan window — both apps must be open; wait for next scan or tap Scan now.") - .font(.caption) - .foregroundStyle(.secondary) + Text("No peers visible in the current scan window. Keep both apps open and wait for the next cycle.") + .font(.subheadline).foregroundStyle(.secondary) } else { ForEach(mesh.discoveredPeers) { p in VStack(alignment: .leading, spacing: 4) { - Text(p.name).font(.headline) - let centralState = mesh.debugConnectionRows.first(where: { $0.id == p.id })?.state - Text("RSSI \(p.rssi) · \(p.linkState)" + (centralState.map { " · GATT: \($0)" } ?? "")) - .font(.caption) - .foregroundStyle(.secondary) + Text(p.name).font(.subheadline.weight(.semibold)) + let state = mesh.debugConnectionRows.first(where: { $0.id == p.id })?.state + Text("RSSI \(p.rssi) · \(p.linkState)" + (state.map { " · \($0)" } ?? "")) + .font(.caption).foregroundStyle(.secondary) Text(p.id.uuidString) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary).lineLimit(1) } - .padding(.vertical, 4) + .padding(.vertical, 3) } } + } header: { + Label("Peers", systemImage: "person.2").font(.footnote.weight(.medium)).textCase(nil) } Section { - Button("Clear log", role: .destructive) { mesh.clearDebugLog() } + Button("Clear Log", role: .destructive) { mesh.clearDebugLog() } } - Section("Log") { + Section { Text(mesh.debugLines.joined(separator: "\n")) .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) .textSelection(.enabled) + } header: { + Label("Log", systemImage: "doc.text").font(.footnote.weight(.medium)).textCase(nil) } } - .navigationTitle("Dashboard") + .navigationTitle("Network") + } + } + + private var connectionRow: some View { + HStack { + Text("Bluetooth") + Spacer() + HStack(spacing: 6) { + Circle() + .fill(mesh.bluetoothState == .poweredOn ? Color.green : Color.orange) + .frame(width: 7, height: 7) + Text(stateLabel(mesh.bluetoothState)) + .foregroundStyle(mesh.bluetoothState == .poweredOn ? .primary : .orange) + } + } + } + + @ViewBuilder + private func sliderRow(label: String, value: Binding, range: ClosedRange, step: Double) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label).font(.subheadline) + Spacer() + Text("\(Int(value.wrappedValue))s") + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + Slider(value: value, in: range, step: step).tint(.primary) } + .padding(.vertical, 4) } private func stateLabel(_ s: CBManagerState) -> String { @@ -104,12 +127,11 @@ struct DebugDashboardView: View { case .unauthorized: return "Denied" case .unsupported: return "Unsupported" case .resetting: return "Resetting" - default: return "…" + default: return "Unknown" } } } #Preview { - DebugDashboardView() - .environmentObject(BluetoothMeshService()) + DebugDashboardView().environmentObject(BluetoothMeshService()) } diff --git a/MeshChatMVP/MapLabelModels.swift b/MeshChatMVP/MapLabelModels.swift index 0bb5422..f29490e 100644 --- a/MeshChatMVP/MapLabelModels.swift +++ b/MeshChatMVP/MapLabelModels.swift @@ -1,7 +1,7 @@ import Foundation import CoreLocation -/// Category for user-placed map labels (shared over mesh). +/// Category for user-placed map alerts (shared over mesh). public enum LabelCategory: String, Codable, CaseIterable { case armedConflict = "armed_conflict" case explosion = "explosion" @@ -13,29 +13,44 @@ public enum LabelCategory: String, Codable, CaseIterable { public var displayName: String { switch self { - case .armedConflict: return "Armed conflict / gunfire" - case .explosion: return "Explosion / bombing" - case .drone: return "Drone / airstrike" - case .militaryMovement: return "Military movement" - case .policeCrackdown: return "Police crackdown" - case .arrests: return "Arrests / detention" - case .checkpoint: return "Checkpoint / roadblock" + case .armedConflict: return "Armed Conflict / Gunfire" + case .explosion: return "Explosion / Bombing" + case .drone: return "Drone / Airstrike" + case .militaryMovement: return "Military Movement" + case .policeCrackdown: return "Police Crackdown" + case .arrests: return "Arrests / Detention" + case .checkpoint: return "Checkpoint / Roadblock" } } public var systemImage: String { switch self { - case .armedConflict: return "bolt.fill" - case .explosion: return "flame.fill" - case .drone: return "airplane" - case .militaryMovement: return "figure.march" - case .policeCrackdown: return "shield.fill" - case .arrests: return "hand.raised.fill" - case .checkpoint: return "road.lanes" + case .armedConflict: return "exclamationmark.triangle.fill" + case .explosion: return "flame.fill" + case .drone: return "paperplane.fill" + case .militaryMovement: return "person.3.fill" + case .policeCrackdown: return "shield.fill" + case .arrests: return "lock.fill" + case .checkpoint: return "minus.circle.fill" + } + } + + /// How long (seconds) before this alert type automatically fades from the map. + public var expiryDuration: TimeInterval { + switch self { + case .armedConflict: return 2 * 3600 // 2 h + case .explosion: return 3 * 3600 // 3 h + case .drone: return 1 * 3600 // 1 h + case .militaryMovement: return 4 * 3600 // 4 h + case .policeCrackdown: return 3 * 3600 // 3 h + case .arrests: return 6 * 3600 // 6 h + case .checkpoint: return 8 * 3600 // 8 h } } } +// MARK: - Wire payloads + /// Wire payload for a map label (place on map, share with peers). /// Uses short keys and omits sender (use envelope.senderID/senderName) to stay under 512 bytes. public struct MapLabelPayload: Codable, Equatable { @@ -51,17 +66,12 @@ public struct MapLabelPayload: Codable, Equatable { case id = "i", category = "c", lat = "a", lon = "o", timestamp = "t" } - public init(id: UUID, category: String, lat: Double, lon: Double, senderID: String, senderName: String, timestamp: UInt64) { - self.id = id - self.category = category - self.lat = lat - self.lon = lon - self.senderID = senderID - self.senderName = senderName - self.timestamp = timestamp + public init(id: UUID, category: String, lat: Double, lon: Double, + senderID: String, senderName: String, timestamp: UInt64) { + self.id = id; self.category = category; self.lat = lat; self.lon = lon + self.senderID = senderID; self.senderName = senderName; self.timestamp = timestamp } - /// Decode from wire (short keys); caller must set senderID/senderName from envelope when storing. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) @@ -69,8 +79,7 @@ public struct MapLabelPayload: Codable, Equatable { lat = try c.decode(Double.self, forKey: .lat) lon = try c.decode(Double.self, forKey: .lon) timestamp = try c.decode(UInt64.self, forKey: .timestamp) - senderID = "" - senderName = "" + senderID = ""; senderName = "" } public func encode(to encoder: Encoder) throws { @@ -80,7 +89,6 @@ public struct MapLabelPayload: Codable, Equatable { try c.encode(lat, forKey: .lat) try c.encode(lon, forKey: .lon) try c.encode(timestamp, forKey: .timestamp) - // Omit senderID/senderName on wire; receiver uses envelope } } @@ -95,13 +103,56 @@ public struct MapLabelVotePayload: Codable, Equatable { } public init(labelId: UUID, vote: Int, voterID: String) { - self.labelId = labelId - self.vote = vote - self.voterID = voterID + self.labelId = labelId; self.vote = vote; self.voterID = voterID + } +} + +/// Wire payload for a comment on an alert/label. +public struct LabelCommentPayload: Codable, Equatable { + public let commentId: UUID + public let labelId: UUID + public let senderID: String + public let senderName: String + public let text: String + /// Optional JPEG image as base64 (small, compressed for BLE). + public let imageJPEGBase64: String? + public let timestamp: UInt64 + + private enum CodingKeys: String, CodingKey { + case commentId = "ci", labelId = "li", senderID = "si", + senderName = "sn", text = "tx", imageJPEGBase64 = "im", timestamp = "ts" + } + + public init(commentId: UUID, labelId: UUID, senderID: String, senderName: String, + text: String, imageJPEGBase64: String? = nil, timestamp: UInt64) { + self.commentId = commentId; self.labelId = labelId; self.senderID = senderID + self.senderName = senderName; self.text = text + self.imageJPEGBase64 = imageJPEGBase64; self.timestamp = timestamp } } -/// In-memory label with vote counts for display (confidence score). +// MARK: - In-memory models + +/// One comment thread entry on an alert. +public struct LabelComment: Identifiable, Equatable { + public let id: UUID + public let labelId: UUID + public let senderID: String + public let senderName: String + public let text: String + public let imageJPEGBase64: String? + public let date: Date + public var isLocal: Bool + + public init(id: UUID, labelId: UUID, senderID: String, senderName: String, + text: String, imageJPEGBase64: String? = nil, date: Date, isLocal: Bool) { + self.id = id; self.labelId = labelId; self.senderID = senderID + self.senderName = senderName; self.text = text + self.imageJPEGBase64 = imageJPEGBase64; self.date = date; self.isLocal = isLocal + } +} + +/// In-memory label with vote counts and expiry. public struct MapLabelRecord: Identifiable, Equatable { public let id: UUID public let category: LabelCategory @@ -113,6 +164,13 @@ public struct MapLabelRecord: Identifiable, Equatable { public var upVotes: Int public var downVotes: Int + /// Date after which the alert disappears from the map. + public var expiresAt: Date { + date.addingTimeInterval(category.expiryDuration) + } + + public var isExpired: Bool { Date() > expiresAt } + public var confidenceScore: Double { let total = upVotes + downVotes guard total > 0 else { return 0.5 } @@ -123,15 +181,19 @@ public struct MapLabelRecord: Identifiable, Equatable { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } - public init(id: UUID, category: LabelCategory, latitude: Double, longitude: Double, senderID: String, senderName: String, date: Date, upVotes: Int, downVotes: Int) { - self.id = id - self.category = category - self.latitude = latitude - self.longitude = longitude - self.senderID = senderID - self.senderName = senderName - self.date = date - self.upVotes = upVotes - self.downVotes = downVotes + /// Opacity fades in the last 20% of the expiry window. + public var mapOpacity: Double { + let total = category.expiryDuration + let remaining = expiresAt.timeIntervalSinceNow + guard remaining > 0 else { return 0 } + let fraction = remaining / total + return fraction < 0.2 ? fraction / 0.2 : 1.0 + } + + public init(id: UUID, category: LabelCategory, latitude: Double, longitude: Double, + senderID: String, senderName: String, date: Date, upVotes: Int, downVotes: Int) { + self.id = id; self.category = category; self.latitude = latitude; self.longitude = longitude + self.senderID = senderID; self.senderName = senderName; self.date = date + self.upVotes = upVotes; self.downVotes = downVotes } } diff --git a/MeshChatMVP/MapTabView.swift b/MeshChatMVP/MapTabView.swift index 2894849..6f2eaf9 100644 --- a/MeshChatMVP/MapTabView.swift +++ b/MeshChatMVP/MapTabView.swift @@ -1,55 +1,52 @@ import SwiftUI import MapKit import CoreLocation +import PhotosUI + +// MARK: - MapTabView -/// Map tab: shows positions of transmitters using existing coordinate exchange data. -/// Reads only from mesh.lastKnownLocation, mesh.senderCoordinates, mesh.announceNicknames, mesh.identity. struct MapTabView: View { @EnvironmentObject var mesh: BluetoothMeshService @StateObject private var headingProvider = LocationHeadingProvider() private static let defaultCenter = CLLocationCoordinate2D(latitude: -33.8688, longitude: 151.2093) - private static let defaultSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - - @State private var region = MKCoordinateRegion( - center: defaultCenter, - span: defaultSpan - ) - @State private var showOfflineInfo = false - @State private var isCaching = false - @State private var showAddLabelSheet = false - @State private var showCooldownAlert = false + private static let defaultSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + + @State private var region = MKCoordinateRegion(center: defaultCenter, span: defaultSpan) + @State private var showOfflineInfo = false + @State private var isCaching = false + @State private var showAddLabelSheet = false + @State private var showCooldownAlert = false @State private var selectedLabel: MapLabelRecord? - /// Labels built from mesh.mapLabels + mesh.labelVotes for display and voting. + /// Drives the expiry-pruning timer. + @State private var now = Date() + + /// Active (non-expired) label records. + private var activeRecords: [MapLabelRecord] { + labelRecords.filter { !$0.isExpired } + } + private var labelRecords: [MapLabelRecord] { mesh.mapLabels.map { id, payload in - let votes = mesh.labelVotes[id] ?? [:] - let upVotes = votes.values.filter { $0 == 1 }.count + let votes = mesh.labelVotes[id] ?? [:] + let upVotes = votes.values.filter { $0 == 1 }.count let downVotes = votes.values.filter { $0 == -1 }.count let category = LabelCategory(rawValue: payload.category) ?? .checkpoint return MapLabelRecord( - id: id, - category: category, - latitude: payload.lat, - longitude: payload.lon, - senderID: payload.senderID, - senderName: payload.senderName, + id: id, category: category, + latitude: payload.lat, longitude: payload.lon, + senderID: payload.senderID, senderName: payload.senderName, date: Date(timeIntervalSince1970: Double(payload.timestamp) / 1000), - upVotes: upVotes, - downVotes: downVotes + upVotes: upVotes, downVotes: downVotes ) } } - /// Combined annotations: transmitters + labels (single list for Map). private var combinedAnnotations: [MapAnnotationItem] { - let transmitterItems = annotationItems.map { MapAnnotationItem.transmitter($0) } - let labelItems = labelRecords.map { MapAnnotationItem.label($0) } - return transmitterItems + labelItems + annotationItems.map { .transmitter($0) } + activeRecords.map { .label($0) } } - /// Pins to show: remote senders from senderCoordinates plus current user if sharing. private var annotationItems: [TransmitterPin] { var items: [TransmitterPin] = [] for (senderID, coords) in mesh.senderCoordinates { @@ -57,16 +54,14 @@ struct MapTabView: View { items.append(TransmitterPin( id: "sender-\(senderID)", coordinate: CLLocationCoordinate2D(latitude: coords.lat, longitude: coords.lon), - displayName: name, - isCurrentUser: false + displayName: name, isCurrentUser: false )) } if mesh.identity.shareLocation, let my = mesh.lastKnownLocation { items.append(TransmitterPin( id: "me", coordinate: CLLocationCoordinate2D(latitude: my.lat, longitude: my.lon), - displayName: mesh.identity.nickname, - isCurrentUser: true + displayName: mesh.identity.nickname, isCurrentUser: true )) } return items @@ -75,121 +70,31 @@ struct MapTabView: View { var body: some View { NavigationStack { ZStack(alignment: .top) { - if combinedAnnotations.isEmpty { - Map(coordinateRegion: $region) - .ignoresSafeArea(edges: .all) - VStack(spacing: 8) { - Text("No positions or labels yet") - .font(.headline) - Text("Turn on \"Share location\" to show your position. Tap \"Add label\" to place an incident label; others can vote on validity.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.top, 60) - } else { - Map(coordinateRegion: $region, annotationItems: combinedAnnotations) { item in - MapAnnotation(coordinate: item.coordinate) { - switch item { - case .transmitter(let p): - VStack(spacing: 2) { - Image(systemName: p.isCurrentUser ? "person.circle.fill" : "antenna.radiowaves.left.and.right") - .font(.title2) - .foregroundStyle(p.isCurrentUser ? .blue : .orange) - Text(p.displayName) - .font(.caption2) - .lineLimit(1) - } - .padding(6) - .background(.background, in: RoundedRectangle(cornerRadius: 8)) - case .label(let record): - Button { - selectedLabel = record - } label: { - VStack(spacing: 2) { - Image(systemName: record.category.systemImage) - .font(.title2) - .foregroundStyle(.red) - Text(record.category.displayName) - .font(.caption2) - .lineLimit(1) - .multilineTextAlignment(.center) - } - .padding(6) - .background(.background, in: RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - } - .ignoresSafeArea(edges: .all) - .onAppear { fitRegionToAnnotations() } - .onChange(of: mesh.senderCoordinates.count) { _ in fitRegionToAnnotations() } - .onChange(of: mesh.identity.shareLocation) { _ in fitRegionToAnnotations() } - .onChange(of: mesh.mapLabels.count) { _ in fitRegionToAnnotations() } - } + mapLayer - // Compass: direction the user is pointed (magnetic heading) + // Compass overlay if let heading = headingProvider.headingDegrees { VStack { Spacer() HStack { Spacer() CompassView(headingDegrees: heading) - .padding(.trailing, 16) - .padding(.bottom, 100) + .padding(.trailing, 14) + .padding(.bottom, 110) } } .allowsHitTesting(false) } - // Top controls: share toggle + offline + add label - VStack(spacing: 12) { - HStack { - Toggle(isOn: shareLocationBinding) { - Label("Share location", systemImage: "location.fill") - .font(.subheadline.weight(.medium)) - } - .toggleStyle(.button) - .tint(.accentColor) - Spacer() - Button { - if mesh.mapLabelCooldownRemaining > 0 { - showCooldownAlert = true - } else { - showAddLabelSheet = true - } - } label: { - if mesh.mapLabelCooldownRemaining > 0 { - Text("\(Int(ceil(mesh.mapLabelCooldownRemaining)))s") - .font(.caption.monospacedDigit()) - } else { - Image(systemName: "mappin.circle.fill") - .font(.body) - } - } - .disabled(mesh.mapLabelCooldownRemaining > 0) - Button { - showOfflineInfo = true - } label: { - Image(systemName: "map.fill") - .font(.body) - } - } - .padding(10) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - .padding(.top, 8) - Spacer() - } + // Control bar + topControls } .navigationTitle("Map") .navigationBarTitleDisplayMode(.inline) - .alert("Label cooldown", isPresented: $showCooldownAlert) { + .alert("Cooldown", isPresented: $showCooldownAlert) { Button("OK", role: .cancel) {} } message: { - Text("Please wait \(Int(ceil(mesh.mapLabelCooldownRemaining))) seconds before placing another label.") + Text("Please wait \(Int(ceil(mesh.mapLabelCooldownRemaining)))s before placing another alert.") } .sheet(isPresented: $showOfflineInfo) { OfflineMapSheet(isCaching: $isCaching, region: region, onCache: cacheCurrentRegion) @@ -198,263 +103,557 @@ struct MapTabView: View { AddLabelSheet(regionCenter: region.center) { category in mesh.sendMapLabel(category: category, lat: region.center.latitude, lon: region.center.longitude) showAddLabelSheet = false - } onCancel: { - showAddLabelSheet = false - } + } onCancel: { showAddLabelSheet = false } } .sheet(item: $selectedLabel) { record in - LabelVoteSheet( + AlertThreadSheet( record: record, myVote: mesh.labelVotes[record.id]?[mesh.identity.deviceID], - onVote: { up in - mesh.voteForLabel(labelId: record.id, up: up) + comments: mesh.labelComments[record.id] ?? [], + onVote: { mesh.voteForLabel(labelId: record.id, up: $0) }, + onComment: { text, image in + mesh.sendLabelComment(labelId: record.id, text: text, imageJPEGBase64: image) }, selectedLabel: $selectedLabel ) } } + // Expire stale alerts every 60s + .onReceive(Timer.publish(every: 60, on: .main, in: .common).autoconnect()) { now in + self.now = now + } } - /// Binding that updates identity via existing updateIdentity (no change to identity logic). - private var shareLocationBinding: Binding { - Binding( - get: { mesh.identity.shareLocation }, - set: { newValue in - var id = mesh.identity - id.shareLocation = newValue - mesh.updateIdentity(id) + // MARK: - Map layer + + @ViewBuilder + private var mapLayer: some View { + if combinedAnnotations.isEmpty { + Map(coordinateRegion: $region).ignoresSafeArea(edges: .all) + emptyMapOverlay + } else { + Map(coordinateRegion: $region, annotationItems: combinedAnnotations) { item in + MapAnnotation(coordinate: item.coordinate) { + switch item { + case .transmitter(let p): transmitterPin(p) + case .label(let record): alertPin(record) + } + } } - ) + .ignoresSafeArea(edges: .all) + .onAppear { fitRegion() } + .onChange(of: mesh.senderCoordinates.count) { _ in fitRegion() } + .onChange(of: mesh.identity.shareLocation) { _ in fitRegion() } + .onChange(of: mesh.mapLabels.count) { _ in fitRegion() } + } } - /// Preload/cache current map region for better offline use (MapKit caches tiles when rendered). + private var emptyMapOverlay: some View { + VStack(spacing: 10) { + Spacer(minLength: 120) + Image(systemName: "map") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(Color(.systemGray4)) + Text("No alerts or positions") + .font(.headline).foregroundStyle(.secondary) + Text("Enable location sharing or place an alert\nto populate the map.") + .font(.subheadline).foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + Spacer() + } + } + + // MARK: - Annotation views + + private func transmitterPin(_ p: TransmitterPin) -> some View { + VStack(spacing: 3) { + ZStack { + Circle() + .fill(p.isCurrentUser ? Color.primary : Color(.systemBackground)) + .frame(width: 30, height: 30) + .shadow(color: .black.opacity(0.12), radius: 3, y: 1) + Image(systemName: p.isCurrentUser ? "person.fill" : "antenna.radiowaves.left.and.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(p.isCurrentUser ? Color(.systemBackground) : .primary) + } + Text(p.displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(.ultraThinMaterial)) + } + } + + private func alertPin(_ record: MapLabelRecord) -> some View { + let commentCount = (mesh.labelComments[record.id] ?? []).count + return Button { selectedLabel = record } label: { + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + ZStack { + Circle() + .fill(Color(.systemBackground)) + .frame(width: 28, height: 28) + .shadow(color: .black.opacity(0.14), radius: 3, y: 1) + Image(systemName: record.category.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.red) + } + if commentCount > 0 { + Text("\(min(commentCount, 99))") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background(Capsule().fill(Color.red)) + .offset(x: 6, y: -4) + } + } + Text(record.category.displayName) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Capsule().fill(.ultraThinMaterial)) + } + .opacity(record.mapOpacity) + } + .buttonStyle(.plain) + } + + // MARK: - Top controls + + private var topControls: some View { + VStack { + HStack(spacing: 10) { + // Location sharing pill + Button { + var id = mesh.identity + id.shareLocation.toggle() + mesh.updateIdentity(id) + } label: { + HStack(spacing: 6) { + Image(systemName: mesh.identity.shareLocation ? "location.fill" : "location.slash") + .font(.caption.weight(.semibold)) + Text(mesh.identity.shareLocation ? "Sharing" : "Location Off") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(mesh.identity.shareLocation ? Color(.systemBackground) : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().fill(mesh.identity.shareLocation ? Color.primary : Color(.systemBackground))) + } + + Spacer() + + // Place alert + Button { + mesh.mapLabelCooldownRemaining > 0 ? (showCooldownAlert = true) : (showAddLabelSheet = true) + } label: { + Group { + if mesh.mapLabelCooldownRemaining > 0 { + Text("\(Int(ceil(mesh.mapLabelCooldownRemaining)))s") + .font(.caption.monospacedDigit().weight(.semibold)) + } else { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 16, weight: .semibold)) + } + } + .foregroundStyle(.primary) + .frame(width: 34, height: 34) + .background(Circle().fill(Color(.systemBackground))) + .shadow(color: .black.opacity(0.1), radius: 3, y: 1) + } + .disabled(mesh.mapLabelCooldownRemaining > 0) + + // Offline maps + Button { showOfflineInfo = true } label: { + Image(systemName: "arrow.down.to.line") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 34, height: 34) + .background(Circle().fill(Color(.systemBackground))) + .shadow(color: .black.opacity(0.1), radius: 3, y: 1) + } + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.07), radius: 6, y: 2) + .padding(.horizontal, 14) + .padding(.top, 8) + Spacer() + } + } + + // MARK: - Helpers + private func cacheCurrentRegion() { guard !isCaching else { return } isCaching = true - let options = MKMapSnapshotter.Options() - options.region = region - options.size = CGSize(width: 512, height: 512) - let snapshotter = MKMapSnapshotter(options: options) - snapshotter.start { [self] _, _ in + let opts = MKMapSnapshotter.Options() + opts.region = region + opts.size = CGSize(width: 512, height: 512) + MKMapSnapshotter(options: opts).start { _, _ in DispatchQueue.main.async { isCaching = false } } } - private func fitRegionToAnnotations() { + private func fitRegion() { guard !combinedAnnotations.isEmpty else { return } let coords = combinedAnnotations.map(\.coordinate) - let lats = coords.map(\.latitude) - let lons = coords.map(\.longitude) - let minLat = lats.min() ?? 0 - let maxLat = lats.max() ?? 0 - let minLon = lons.min() ?? 0 - let maxLon = lons.max() ?? 0 - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - let span = MKCoordinateSpan( - latitudeDelta: max(0.005, (maxLat - minLat) * 1.4), - longitudeDelta: max(0.005, (maxLon - minLon) * 1.4) + let lats = coords.map(\.latitude); let lons = coords.map(\.longitude) + region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: ((lats.min() ?? 0) + (lats.max() ?? 0)) / 2, + longitude: ((lons.min() ?? 0) + (lons.max() ?? 0)) / 2), + span: MKCoordinateSpan( + latitudeDelta: max(0.005, ((lats.max() ?? 0) - (lats.min() ?? 0)) * 1.4), + longitudeDelta: max(0.005, ((lons.max() ?? 0) - (lons.min() ?? 0)) * 1.4) + ) ) - region = MKCoordinateRegion(center: center, span: span) } } -/// One pin on the map (remote transmitter or current user). -private struct TransmitterPin: Identifiable { - let id: String - let coordinate: CLLocationCoordinate2D - let displayName: String - let isCurrentUser: Bool -} +// MARK: - Alert Thread Sheet (forum-style) -/// Unified map annotation: transmitter or label (for single Map annotationItems array). -private enum MapAnnotationItem: Identifiable { - case transmitter(TransmitterPin) - case label(MapLabelRecord) +struct AlertThreadSheet: View { + let record: MapLabelRecord + let myVote: Int? + let comments: [LabelComment] + let onVote: (Bool) -> Void + let onComment: (String, String?) -> Void + @Binding var selectedLabel: MapLabelRecord? - var id: String { - switch self { - case .transmitter(let p): return "tx-\(p.id)" - case .label(let l): return "label-\(l.id.uuidString)" - } + @State private var draft = "" + @FocusState private var inputFocused: Bool + @State private var pickedItem: PhotosPickerItem? + @State private var pendingImage: UIImage? + @State private var isSending = false + + private var timeRemaining: String { + let remaining = record.expiresAt.timeIntervalSinceNow + if remaining <= 0 { return "Expired" } + if remaining < 3600 { return "Expires in \(Int(remaining / 60))m" } + return "Expires in \(Int(remaining / 3600))h" } - var coordinate: CLLocationCoordinate2D { - switch self { - case .transmitter(let p): return p.coordinate - case .label(let l): return l.coordinate - } - } -} + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Alert header + alertHeader + Divider() + + // Vote bar + voteBar + + Divider() + + // Comment thread + ScrollViewReader { proxy in + ScrollView { + if comments.isEmpty { + VStack(spacing: 8) { + Spacer(minLength: 40) + Image(systemName: "text.bubble") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(Color(.systemGray4)) + Text("No reports yet") + .font(.subheadline).foregroundStyle(.secondary) + Text("Be the first to verify or add context to this alert.") + .font(.caption).foregroundStyle(.tertiary) + .multilineTextAlignment(.center).padding(.horizontal, 40) + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.top, 20) + } else { + LazyVStack(spacing: 0) { + ForEach(comments) { comment in + commentRow(comment) + Divider().padding(.leading, 52) + } + } + } + } + .background(Color(.systemGroupedBackground)) + .onChange(of: comments.count) { _ in + if let last = comments.last { proxy.scrollTo(last.id, anchor: .bottom) } + } + } -// MARK: - Compass (direction user is pointed) + // Pending image preview + if let img = pendingImage { + HStack(spacing: 10) { + Image(uiImage: img) + .resizable().scaledToFill() + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text("Photo attached") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button { pendingImage = nil } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color(.systemGray3)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(.systemBackground)) + Divider() + } -/// Shows device heading: arrow points in the direction the user is facing (0° = North). -private struct CompassView: View { - let headingDegrees: Double - var body: some View { - ZStack { - Circle() - .fill(.ultraThinMaterial) - .frame(width: 56, height: 56) - // Arrow points upward when facing North; rotate so North is at top - Image(systemName: "location.north.fill") - .font(.title) - .foregroundStyle(.blue) - .rotationEffect(.degrees(-headingDegrees)) - Circle() - .strokeBorder(.secondary, lineWidth: 1) - .frame(width: 56, height: 56) + // Comment input bar + commentInputBar + } + .navigationTitle(record.category.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Close") { selectedLabel = nil } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { inputFocused = false } + } + } + .onChange(of: pickedItem) { item in + guard let item else { return } + Task { + if let data = try? await item.loadTransferable(type: Data.self), + let ui = UIImage(data: data) { + await MainActor.run { pendingImage = ui; pickedItem = nil } + } + } + } } - .overlay(alignment: .top) { - Text("N") - .font(.system(size: 10, weight: .bold)) - .offset(y: -28) + } + + // MARK: Header + + private var alertHeader: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(.systemGray6)) + .frame(width: 44, height: 44) + Image(systemName: record.category.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.red) + } + VStack(alignment: .leading, spacing: 3) { + Text(record.senderName) + .font(.subheadline.weight(.semibold)) + HStack(spacing: 6) { + Text(record.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption).foregroundStyle(.secondary) + Text("·").font(.caption).foregroundStyle(.tertiary) + Text(timeRemaining) + .font(.caption) + .foregroundStyle(record.expiresAt.timeIntervalSinceNow < 3600 ? .orange : .secondary) + } + } + Spacer() + confidenceBadge } + .padding(.horizontal, 16) + .padding(.vertical, 14) } -} -// MARK: - Device heading provider (no change to mesh; map-only) + private var confidenceBadge: some View { + let score = record.confidenceScore + let color: Color = score > 0.6 ? .red : (score < 0.4 ? Color(.systemGray3) : .orange) + return VStack(spacing: 2) { + Text(String(format: "%.0f%%", score * 100)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(color) + Text("confidence") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } -/// Provides device magnetic heading for compass. Uses its own CLLocationManager for heading only. -private final class LocationHeadingProvider: NSObject, ObservableObject { - @Published private(set) var headingDegrees: Double? - private let manager = CLLocationManager() + // MARK: Vote bar - override init() { - super.init() - manager.delegate = self - manager.headingFilter = 5 - if CLLocationManager.headingAvailable() { - manager.startUpdatingHeading() + private var voteBar: some View { + HStack(spacing: 0) { + voteButton(up: true, label: "Confirmed", icon: "checkmark.circle", count: record.upVotes, selected: myVote == 1) + Divider().frame(height: 36) + voteButton(up: false, label: "Unverified", icon: "questionmark.circle", count: record.downVotes, selected: myVote == -1) } + .background(Color(.systemBackground)) } - deinit { - manager.stopUpdatingHeading() + private func voteButton(up: Bool, label: String, icon: String, count: Int, selected: Bool) -> some View { + Button { onVote(up) } label: { + HStack(spacing: 6) { + Image(systemName: selected ? icon.replacingOccurrences(of: ".circle", with: ".circle.fill") : icon) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(selected ? (up ? .green : .orange) : .secondary) + Text(label) + .font(.subheadline.weight(selected ? .semibold : .regular)) + .foregroundStyle(selected ? .primary : .secondary) + Text("(\(count))") + .font(.caption).foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) } -} -extension LocationHeadingProvider: CLLocationManagerDelegate { - func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { - guard newHeading.headingAccuracy >= 0 else { return } - DispatchQueue.main.async { [weak self] in - self?.headingDegrees = newHeading.trueHeading >= 0 ? newHeading.trueHeading : newHeading.magneticHeading + // MARK: Comment row + + private func commentRow(_ c: LabelComment) -> some View { + HStack(alignment: .top, spacing: 12) { + // Avatar + ZStack { + Circle() + .fill(c.isLocal ? Color.primary : Color(.systemGray5)) + .frame(width: 32, height: 32) + Text(String(c.senderName.prefix(1)).uppercased()) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(c.isLocal ? Color(.systemBackground) : .primary) + } + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text(c.senderName) + .font(.caption.weight(.semibold)) + .foregroundStyle(c.isLocal ? .primary : .secondary) + Text(c.date, style: .relative) + .font(.caption2).foregroundStyle(.tertiary) + } + if !c.text.isEmpty { + Text(c.text) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + if let b64 = c.imageJPEGBase64, + let data = Data(base64Encoded: b64), + let ui = UIImage(data: data) { + Image(uiImage: ui) + .resizable().scaledToFit() + .frame(maxWidth: 200, maxHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.top, 2) + } + } + Spacer() } + .id(c.id) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color(.systemBackground)) } -} - -// MARK: - Add label (category picker) -private struct AddLabelSheet: View { - let regionCenter: CLLocationCoordinate2D - let onSelect: (LabelCategory) -> Void - let onCancel: () -> Void - @Environment(\.dismiss) private var dismiss + // MARK: Comment input - var body: some View { - NavigationStack { - List { - Text("Place a label at the current map center. Others can vote on validity.") - .font(.subheadline) + private var commentInputBar: some View { + HStack(alignment: .bottom, spacing: 10) { + PhotosPicker(selection: $pickedItem, matching: .images, photoLibrary: .shared()) { + Image(systemName: "photo") + .font(.system(size: 18)) .foregroundStyle(.secondary) - ForEach(LabelCategory.allCases, id: \.rawValue) { category in - Button { - onSelect(category) - dismiss() - } label: { - Label(category.displayName, systemImage: category.systemImage) - } - } + .frame(width: 32, height: 32) } - .navigationTitle("Add label") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - onCancel() - dismiss() - } + + TextField("Add a report or context…", text: $draft, axis: .vertical) + .font(.subheadline) + .lineLimit(1...4) + .focused($inputFocused) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(.systemGray6)) + ) + + Button { + guard !isSending else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty || pendingImage != nil else { return } + isSending = true + var b64: String? = nil + if let img = pendingImage, + let compressed = try? MeshImageUtils.jpegDataForMesh(from: img) { + b64 = compressed.base64EncodedString() } + onComment(text, b64) + draft = "" + pendingImage = nil + inputFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { isSending = false } + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) + .foregroundStyle( + (draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && pendingImage == nil) || isSending + ? Color(.systemGray4) : Color.primary + ) } + .disabled((draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && pendingImage == nil) || isSending) } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.systemBackground)) } } -// MARK: - Label detail & vote (confidence score) +// MARK: - Add Label Sheet -private struct LabelVoteSheet: View { - let record: MapLabelRecord - let myVote: Int? - let onVote: (Bool) -> Void - @Binding var selectedLabel: MapLabelRecord? +private struct AddLabelSheet: View { + let regionCenter: CLLocationCoordinate2D + let onSelect: (LabelCategory) -> Void + let onCancel: () -> Void + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { Section { - Label(record.category.displayName, systemImage: record.category.systemImage) - .font(.headline) - Text("By \(record.senderName)") - .font(.subheadline) - .foregroundStyle(.secondary) - Text(record.date.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundStyle(.tertiary) - } - Section("Confidence (votes)") { - HStack { - Text("Score") - Spacer() - Text(String(format: "%.0f%%", record.confidenceScore * 100)) - .fontWeight(.medium) - } - HStack { - Text("↑ Valid") - Spacer() - Text("\(record.upVotes)") - } - HStack { - Text("↓ Not valid") - Spacer() - Text("\(record.downVotes)") - } + Text("Place an alert at the current map center. Nearby peers will receive it and can verify with comments and votes.") + .font(.subheadline).foregroundStyle(.secondary) } - Section("Your vote") { - HStack(spacing: 20) { - Button { - onVote(true) - } label: { - Label("Valid", systemImage: "hand.thumbsup.fill") - .foregroundStyle(myVote == 1 ? .green : .secondary) - } - .buttonStyle(.borderless) + Section("Alert Type") { + ForEach(LabelCategory.allCases, id: \.rawValue) { category in Button { - onVote(false) + onSelect(category) + dismiss() } label: { - Label("Not valid", systemImage: "hand.thumbsdown.fill") - .foregroundStyle(myVote == -1 ? .red : .secondary) + HStack(spacing: 12) { + Image(systemName: category.systemImage) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.red) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(category.displayName) + .foregroundStyle(.primary) + Text("Auto-expires in \(expiryLabel(category))") + .font(.caption).foregroundStyle(.tertiary) + } + } } - .buttonStyle(.borderless) } } } - .navigationTitle("Label") + .navigationTitle("Place Alert") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - selectedLabel = nil - } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { onCancel(); dismiss() } } } } } + + private func expiryLabel(_ c: LabelCategory) -> String { + let h = Int(c.expiryDuration / 3600) + return h == 1 ? "1 hour" : "\(h) hours" + } } -// MARK: - Offline map info & cache +// MARK: - Offline Map Sheet private struct OfflineMapSheet: View { @Binding var isCaching: Bool @@ -466,26 +665,23 @@ private struct OfflineMapSheet: View { NavigationStack { List { Section { - Text("Map tiles are cached as you pan and zoom. To preload the current area, tap below. For full offline use, download regions in the Apple Maps app: Settings → Maps → turn on Offline, or open Maps and download areas before going offline.") - .font(.subheadline) - .foregroundStyle(.secondary) + Text("Map tiles are cached as you pan and zoom. Tap below to pre-load the current area. For extended offline use, download regions in the Apple Maps app before going offline.") + .font(.subheadline).foregroundStyle(.secondary) } - Section("This area") { + Section("This Area") { Button { onCache() } label: { HStack { - Label("Cache current map area", systemImage: "square.and.arrow.down") - if isCaching { - Spacer() - ProgressView() - } + Label("Cache Current Area", systemImage: "square.and.arrow.down") + .foregroundStyle(.primary) + if isCaching { Spacer(); ProgressView() } } } .disabled(isCaching) } } - .navigationTitle("Offline maps") + .navigationTitle("Offline Maps") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -496,7 +692,80 @@ private struct OfflineMapSheet: View { } } +// MARK: - Compass + +private struct CompassView: View { + let headingDegrees: Double + var body: some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .frame(width: 44, height: 44) + .shadow(color: .black.opacity(0.1), radius: 3, y: 1) + Image(systemName: "location.north.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.primary) + .rotationEffect(.degrees(-headingDegrees)) + } + .overlay(alignment: .top) { + Text("N") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .offset(y: -20) + } + } +} + +// MARK: - Location heading provider + +private final class LocationHeadingProvider: NSObject, ObservableObject { + @Published private(set) var headingDegrees: Double? + private let manager = CLLocationManager() + override init() { + super.init() + manager.delegate = self + manager.headingFilter = 5 + if CLLocationManager.headingAvailable() { manager.startUpdatingHeading() } + } + deinit { manager.stopUpdatingHeading() } +} + +extension LocationHeadingProvider: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateHeading h: CLHeading) { + guard h.headingAccuracy >= 0 else { return } + DispatchQueue.main.async { [weak self] in + self?.headingDegrees = h.trueHeading >= 0 ? h.trueHeading : h.magneticHeading + } + } +} + +// MARK: - Supporting types + +private struct TransmitterPin: Identifiable { + let id: String + let coordinate: CLLocationCoordinate2D + let displayName: String + let isCurrentUser: Bool +} + +private enum MapAnnotationItem: Identifiable { + case transmitter(TransmitterPin) + case label(MapLabelRecord) + + var id: String { + switch self { + case .transmitter(let p): return "tx-\(p.id)" + case .label(let l): return "label-\(l.id.uuidString)" + } + } + var coordinate: CLLocationCoordinate2D { + switch self { + case .transmitter(let p): return p.coordinate + case .label(let l): return l.coordinate + } + } +} + #Preview { - MapTabView() - .environmentObject(BluetoothMeshService()) + MapTabView().environmentObject(BluetoothMeshService()) }