// // MainTabView.swift // StikJIT // // Created by Stephen on 3/27/25. // import SwiftUI import Foundation extension Notification.Name { static let switchToTab = Notification.Name("MainTabSwitchNotification") } struct MainTabView: View { @AppStorage("primaryTabSelection") private var selection: String = AppFeature.home.id @State private var switchObserver: Any? @State private var detachedFeature: AppFeature? @State private var didSetInitialHome = false var body: some View { ZStack { Color.clear.ignoresSafeArea() TabView(selection: $selection) { ForEach(AppFeature.mainTabs) { feature in feature.destination .tabItem { Label(feature.title, systemImage: feature.systemImage) } .tag(feature.id) } } .onAppear { ensureSelectionIsValid() if !didSetInitialHome { selection = AppFeature.home.id didSetInitialHome = true } guard switchObserver == nil else { return } switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in guard let id = note.object as? String else { return } openFeature(id: id) } } .onDisappear { if let observer = switchObserver { NotificationCenter.default.removeObserver(observer) switchObserver = nil } } .onOpenURL { url in handleURL(url) } .sheet(item: $detachedFeature) { feature in NavigationStack { feature.destination .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { detachedFeature = nil } } } } } } } private func ensureSelectionIsValid() { let ids = AppFeature.mainTabs.map { $0.id } if ids.contains(selection) { return } selection = AppFeature.home.id } private func handleURL(_ url: URL) { guard let host = url.host()?.lowercased() else { return } switch host { case "simulate-location", "set-location": simulateLocation(from: url) case "location", "location-simulation": if coordinate(from: url) == nil { openFeature(id: AppFeature.location.id) } else { simulateLocation(from: url) } case "clear-location", "stop-location": clearSimulatedLocation() default: break } } private func openFeature(id: String) { guard let feature = AppFeature(rawValue: id) else { return } if AppFeature.mainTabs.contains(feature) { selection = feature.id } else { detachedFeature = feature } } private func simulateLocation(from url: URL) { guard let coordinate = coordinate(from: url) else { showAlert( title: "Invalid Location URL", message: "Use stikdebug://simulate-location?lat=37.3349&lon=-122.0090", showOk: true ) return } guard coordinateIsValid(latitude: coordinate.latitude, longitude: coordinate.longitude) else { showAlert( title: "Invalid Coordinates", message: "Latitude must be between -90 and 90. Longitude must be between -180 and 180.", showOk: true ) return } let pairingFile = PairingFileStore.prepareURL() guard FileManager.default.fileExists(atPath: pairingFile.path) else { showAlert( title: "Pairing File Required", message: "Import a pairing file before simulating location from a URL.", showOk: true ) return } LocationSimulationCommandQueue.shared.async { let code = simulate_location( DeviceConnectionContext.targetIPAddress, coordinate.latitude, coordinate.longitude, pairingFile.path ) DispatchQueue.main.async { if code == 0 { BackgroundLocationManager.shared.requestStart() LogManager.shared.addInfoLog( String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude) ) } else { showAlert( title: "Location Simulation Failed", message: "Could not simulate location from URL (error \(code)). Make sure the device is connected and the DDI is mounted.", showOk: true ) } } } } private func clearSimulatedLocation() { LocationSimulationCommandQueue.shared.async { let code = clear_simulated_location() DispatchQueue.main.async { if code == 0 { BackgroundLocationManager.shared.requestStop() LogManager.shared.addInfoLog("Cleared simulated location from URL") } else { showAlert( title: "Clear Location Failed", message: "Could not clear simulated location from URL (error \(code)).", showOk: true ) } } } } private func coordinate(from url: URL) -> (latitude: Double, longitude: Double)? { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryItems = components?.queryItems ?? [] func queryValue(_ names: [String]) -> String? { for name in names { if let value = queryItems.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value { return value } } return nil } if let latitudeText = queryValue(["lat", "latitude"]), let longitudeText = queryValue(["lon", "lng", "long", "longitude"]), let latitude = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)), let longitude = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) { return (latitude, longitude) } let coordinateText = queryValue(["coordinate", "coordinates", "coords", "q", "ll"]) ?? components?.path ?? "" let values = numbers(in: coordinateText) guard values.count >= 2 else { return nil } return (values[0], values[1]) } private func coordinateIsValid(latitude: Double, longitude: Double) -> Bool { (-90.0...90.0).contains(latitude) && (-180.0...180.0).contains(longitude) } private func numbers(in text: String) -> [Double] { let pattern = #"[-+]?(?:\d+(?:\.\d*)?|\.\d+)"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(text.startIndex..