StikDebug/StikJIT/Views/MainTabView.swift

322 lines
11 KiB
Swift

//
// MainTabView.swift
// StikJIT
//
// Created by Stephen on 3/27/25.
//
import SwiftUI
import Foundation
extension Notification.Name {
static let switchToTab = Notification.Name("MainTabSwitchNotification")
}
private enum ExternalLocationAction: Identifiable {
case simulate(URL, Double, Double)
case clear
var id: String {
switch self {
case .simulate(let url, _, _):
return "simulate-\(url.absoluteString)"
case .clear:
return "clear-location"
}
}
var title: String {
switch self {
case .simulate:
return "Simulate Location?"
case .clear:
return "Clear Location?"
}
}
var message: String {
switch self {
case .simulate(_, let latitude, let longitude):
return String(format: "An external link wants to set the simulated location to %.6f, %.6f.", latitude, longitude)
case .clear:
return "An external link wants to clear the simulated location."
}
}
var confirmationTitle: String {
switch self {
case .simulate:
return "Set Location"
case .clear:
return "Clear Location"
}
}
}
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
@State private var pendingLocationAction: ExternalLocationAction?
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)
}
.confirmationDialog(
pendingLocationAction?.title ?? "External Location Request",
isPresented: Binding(
get: { pendingLocationAction != nil },
set: { isPresented in
if !isPresented {
pendingLocationAction = nil
}
}
),
titleVisibility: .visible,
presenting: pendingLocationAction
) { action in
Button(action.confirmationTitle, role: .destructive) {
performLocationAction(action)
pendingLocationAction = nil
}
Button("Cancel", role: .cancel) {
pendingLocationAction = nil
}
} message: { action in
Text(action.message)
}
.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":
confirmSimulatedLocation(from: url)
case "location", "location-simulation":
if coordinate(from: url) == nil {
openFeature(id: AppFeature.location.id)
} else {
confirmSimulatedLocation(from: url)
}
case "clear-location", "stop-location":
pendingLocationAction = .clear
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 confirmSimulatedLocation(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
}
pendingLocationAction = .simulate(url, coordinate.latitude, coordinate.longitude)
}
private func performLocationAction(_ action: ExternalLocationAction) {
switch action {
case .simulate(let url, _, _):
simulateLocation(from: url)
case .clear:
clearSimulatedLocation()
}
}
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..<text.endIndex, in: text)
return regex.matches(in: text, range: range).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Double(text[matchRange])
}
}
}
struct MainTabView_Previews: PreviewProvider {
static var previews: some View {
MainTabView()
}
}