feat: Merge SaltyTag BLE — GPS/IMU streaming to UWB tag, anchor display, UWB position authority #5
@ -16,6 +16,10 @@
|
||||
A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; };
|
||||
A10000010000000000000BAA /* UWBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000BAB /* UWBModels.swift */; };
|
||||
A10000010000000000000CAA /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000CAB /* BLEManager.swift */; };
|
||||
A100000100000000000010AB /* RouteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000010BB /* RouteModels.swift */; };
|
||||
A100000100000000000011AB /* RouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000011BB /* RouteStore.swift */; };
|
||||
A100000100000000000012AB /* RouteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000012BB /* RouteRecorder.swift */; };
|
||||
A100000100000000000013AB /* RoutesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000013BB /* RoutesView.swift */; };
|
||||
A10000010000000000000DAA /* BLEPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000DAB /* BLEPackets.swift */; };
|
||||
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
|
||||
A10000010000000000000FAA /* BLEStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000FAB /* BLEStatusView.swift */; };
|
||||
@ -33,6 +37,10 @@
|
||||
A10000010000000000000AAB /* MapContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapContentView.swift; sourceTree = "<group>"; };
|
||||
A10000010000000000000BAB /* UWBModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UWBModels.swift; sourceTree = "<group>"; };
|
||||
A10000010000000000000CAB /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
|
||||
A100000100000000000010BB /* RouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteModels.swift; sourceTree = "<group>"; };
|
||||
A100000100000000000011BB /* RouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStore.swift; sourceTree = "<group>"; };
|
||||
A100000100000000000012BB /* RouteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteRecorder.swift; sourceTree = "<group>"; };
|
||||
A100000100000000000013BB /* RoutesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutesView.swift; sourceTree = "<group>"; };
|
||||
A10000010000000000000DAB /* BLEPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEPackets.swift; sourceTree = "<group>"; };
|
||||
A10000010000000000000EAB /* AnchorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorInfo.swift; sourceTree = "<group>"; };
|
||||
A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = "<group>"; };
|
||||
@ -71,6 +79,10 @@
|
||||
A10000010000000000000DAB /* BLEPackets.swift */,
|
||||
A10000010000000000000EAB /* AnchorInfo.swift */,
|
||||
A10000010000000000000FAB /* BLEStatusView.swift */,
|
||||
A100000100000000000010BB /* RouteModels.swift */,
|
||||
A100000100000000000011BB /* RouteStore.swift */,
|
||||
A100000100000000000012BB /* RouteRecorder.swift */,
|
||||
A100000100000000000013BB /* RoutesView.swift */,
|
||||
A100000100000000000005AB /* Assets.xcassets */,
|
||||
A100000100000000000006AB /* Info.plist */,
|
||||
);
|
||||
@ -166,6 +178,10 @@
|
||||
A10000010000000000000DAA /* BLEPackets.swift in Sources */,
|
||||
A10000010000000000000EAA /* AnchorInfo.swift in Sources */,
|
||||
A10000010000000000000FAA /* BLEStatusView.swift in Sources */,
|
||||
A100000100000000000010AB /* RouteModels.swift in Sources */,
|
||||
A100000100000000000011AB /* RouteStore.swift in Sources */,
|
||||
A100000100000000000012AB /* RouteRecorder.swift in Sources */,
|
||||
A100000100000000000013AB /* RoutesView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@ -13,6 +13,8 @@ struct ContentView: View {
|
||||
.tabItem { Label("Map", systemImage: "map.fill") }
|
||||
BLEStatusView()
|
||||
.tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") }
|
||||
RoutesView()
|
||||
.tabItem { Label("Routes", systemImage: "point.bottomleft.forward.to.point.topright.scurvepath") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
SulTee/SulTee/RouteModels.swift
Normal file
117
SulTee/SulTee/RouteModels.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - Route data types
|
||||
|
||||
/// A single GPS sample recorded at 1 Hz.
|
||||
struct RoutePoint: Codable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let altitude: Double
|
||||
let speed: Double // m/s
|
||||
let bearing: Double // degrees, 0–360
|
||||
let timestamp: Double // Unix epoch seconds
|
||||
}
|
||||
|
||||
/// A named marker added by the user during recording.
|
||||
struct Waypoint: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let altitude: Double
|
||||
let timestamp: Double
|
||||
var label: String
|
||||
var action: WaypointAction
|
||||
|
||||
init(location: CLLocation, label: String, action: WaypointAction = .none) {
|
||||
self.id = UUID()
|
||||
self.latitude = location.coordinate.latitude
|
||||
self.longitude = location.coordinate.longitude
|
||||
self.altitude = location.altitude
|
||||
self.timestamp = location.timestamp.timeIntervalSince1970
|
||||
self.label = label
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
enum WaypointAction: String, Codable, CaseIterable, Identifiable {
|
||||
case none = "none"
|
||||
case stop = "stop"
|
||||
case slow = "slow"
|
||||
case photo = "photo"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return "Marker"
|
||||
case .stop: return "Stop"
|
||||
case .slow: return "Slow down"
|
||||
case .photo: return "Take photo"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .none: return "mappin"
|
||||
case .stop: return "stop.circle"
|
||||
case .slow: return "tortoise"
|
||||
case .photo: return "camera"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete recorded route persisted to disk.
|
||||
struct SavedRoute: Codable, Identifiable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
let date: Date
|
||||
var points: [RoutePoint]
|
||||
var waypoints: [Waypoint]
|
||||
|
||||
/// Total elapsed recording time in seconds.
|
||||
var durationSeconds: Double {
|
||||
guard let first = points.first, let last = points.last else { return 0 }
|
||||
return last.timestamp - first.timestamp
|
||||
}
|
||||
|
||||
/// Approximate distance in metres (sum of point-to-point segments).
|
||||
var distanceMetres: Double {
|
||||
var total = 0.0
|
||||
for i in 1..<points.count {
|
||||
let a = CLLocation(latitude: points[i-1].latitude, longitude: points[i-1].longitude)
|
||||
let b = CLLocation(latitude: points[i].latitude, longitude: points[i].longitude)
|
||||
total += a.distance(from: b)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// MARK: - MQTT payload for robot route-following (Phase 3)
|
||||
//
|
||||
// Topic: saltybot/route/command
|
||||
// {
|
||||
// "action": "start",
|
||||
// "route_id": "<uuid>",
|
||||
// "route_name":"<name>",
|
||||
// "points": [{"lat","lon","alt","speed","bearing","ts"}, ...],
|
||||
// "waypoints": [{"lat","lon","alt","ts","label","action"}, ...]
|
||||
// }
|
||||
|
||||
func mqttPayload(action: String = "start") -> [String: Any] {
|
||||
[
|
||||
"action": action,
|
||||
"route_id": id.uuidString,
|
||||
"route_name": name,
|
||||
"points": points.map {[
|
||||
"lat": $0.latitude, "lon": $0.longitude,
|
||||
"alt": $0.altitude, "speed": $0.speed,
|
||||
"bearing": $0.bearing, "ts": $0.timestamp
|
||||
]},
|
||||
"waypoints": waypoints.map {[
|
||||
"lat": $0.latitude, "lon": $0.longitude,
|
||||
"alt": $0.altitude, "ts": $0.timestamp,
|
||||
"label": $0.label, "action": $0.action.rawValue
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
112
SulTee/SulTee/RouteRecorder.swift
Normal file
112
SulTee/SulTee/RouteRecorder.swift
Normal file
@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
/// Records a GPS track at 1 Hz and collects named waypoints.
|
||||
/// Observes SensorManager.lastKnownLocation — no duplicate location manager needed.
|
||||
final class RouteRecorder: ObservableObject {
|
||||
|
||||
enum State { case idle, recording }
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var points: [RoutePoint] = []
|
||||
@Published private(set) var waypoints: [Waypoint] = []
|
||||
@Published private(set) var elapsedSeconds: Double = 0
|
||||
|
||||
private weak var sensorManager: SensorManager?
|
||||
private var recordTimer: Timer?
|
||||
private var elapsedTimer: Timer?
|
||||
|
||||
init(sensorManager: SensorManager) {
|
||||
self.sensorManager = sensorManager
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func startRecording() {
|
||||
guard state == .idle else { return }
|
||||
points = []
|
||||
waypoints = []
|
||||
elapsedSeconds = 0
|
||||
state = .recording
|
||||
|
||||
// Capture one GPS point immediately, then every 1 s
|
||||
capturePoint()
|
||||
recordTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
self?.capturePoint()
|
||||
}
|
||||
elapsedTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
self?.elapsedSeconds += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops recording and returns the finished SavedRoute (not yet persisted).
|
||||
@discardableResult
|
||||
func stopRecording(name: String? = nil) -> SavedRoute? {
|
||||
guard state == .recording else { return nil }
|
||||
recordTimer?.invalidate(); recordTimer = nil
|
||||
elapsedTimer?.invalidate(); elapsedTimer = nil
|
||||
state = .idle
|
||||
|
||||
guard !points.isEmpty else { return nil }
|
||||
|
||||
let routeName = name ?? defaultName()
|
||||
let route = SavedRoute(
|
||||
id: UUID(),
|
||||
name: routeName,
|
||||
date: Date(),
|
||||
points: points,
|
||||
waypoints: waypoints
|
||||
)
|
||||
points = []
|
||||
waypoints = []
|
||||
return route
|
||||
}
|
||||
|
||||
/// Adds a waypoint at the current GPS position.
|
||||
func addWaypoint(label: String, action: WaypointAction = .none) {
|
||||
guard state == .recording,
|
||||
let loc = sensorManager?.lastKnownLocation else { return }
|
||||
let wp = Waypoint(location: loc, label: label, action: action)
|
||||
waypoints.append(wp)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func capturePoint() {
|
||||
guard let loc = sensorManager?.lastKnownLocation else { return }
|
||||
let bearing = loc.course >= 0 ? loc.course : 0
|
||||
let pt = RoutePoint(
|
||||
latitude: loc.coordinate.latitude,
|
||||
longitude: loc.coordinate.longitude,
|
||||
altitude: loc.altitude,
|
||||
speed: max(0, loc.speed),
|
||||
bearing: bearing,
|
||||
timestamp: loc.timestamp.timeIntervalSince1970
|
||||
)
|
||||
points.append(pt)
|
||||
}
|
||||
|
||||
private func defaultName() -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "MMM d, HH:mm"
|
||||
return "Route \(fmt.string(from: Date()))"
|
||||
}
|
||||
|
||||
// MARK: - Computed helpers for UI
|
||||
|
||||
var distanceSoFar: Double {
|
||||
var total = 0.0
|
||||
for i in 1..<points.count {
|
||||
let a = CLLocation(latitude: points[i-1].latitude, longitude: points[i-1].longitude)
|
||||
let b = CLLocation(latitude: points[i].latitude, longitude: points[i].longitude)
|
||||
total += a.distance(from: b)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
var elapsedString: String {
|
||||
let s = Int(elapsedSeconds)
|
||||
return String(format: "%02d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
63
SulTee/SulTee/RouteStore.swift
Normal file
63
SulTee/SulTee/RouteStore.swift
Normal file
@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
/// Persists SavedRoute objects as individual JSON files in the app's Documents directory.
|
||||
final class RouteStore: ObservableObject {
|
||||
|
||||
@Published private(set) var routes: [SavedRoute] = []
|
||||
|
||||
private let directory: URL
|
||||
|
||||
init() {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
directory = docs.appendingPathComponent("routes", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory,
|
||||
withIntermediateDirectories: true)
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func save(_ route: SavedRoute) {
|
||||
let url = fileURL(for: route.id)
|
||||
if let data = try? JSONEncoder().encode(route) {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
if let idx = routes.firstIndex(where: { $0.id == route.id }) {
|
||||
routes[idx] = route
|
||||
} else {
|
||||
routes.append(route)
|
||||
routes.sort { $0.date > $1.date }
|
||||
}
|
||||
}
|
||||
|
||||
func rename(_ route: SavedRoute, to name: String) {
|
||||
var updated = route
|
||||
updated.name = name
|
||||
save(updated)
|
||||
}
|
||||
|
||||
func delete(_ route: SavedRoute) {
|
||||
try? FileManager.default.removeItem(at: fileURL(for: route.id))
|
||||
routes.removeAll { $0.id == route.id }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func load() {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: nil
|
||||
) else { return }
|
||||
|
||||
routes = files
|
||||
.filter { $0.pathExtension == "json" }
|
||||
.compactMap { url -> SavedRoute? in
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(SavedRoute.self, from: data)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private func fileURL(for id: UUID) -> URL {
|
||||
directory.appendingPathComponent("\(id.uuidString).json")
|
||||
}
|
||||
}
|
||||
304
SulTee/SulTee/RoutesView.swift
Normal file
304
SulTee/SulTee/RoutesView.swift
Normal file
@ -0,0 +1,304 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
|
||||
// MARK: - Routes tab root
|
||||
|
||||
struct RoutesView: View {
|
||||
@EnvironmentObject var sensor: SensorManager
|
||||
@StateObject private var store = RouteStore()
|
||||
|
||||
// Recorder is created lazily in onAppear (needs sensor reference)
|
||||
@State private var recorder: RouteRecorder?
|
||||
|
||||
@State private var showWaypointSheet = false
|
||||
@State private var waypointLabel = ""
|
||||
@State private var waypointAction = WaypointAction.none
|
||||
@State private var showSaveSheet = false
|
||||
@State private var pendingRoute: SavedRoute?
|
||||
@State private var routeName = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let rec = recorder, rec.state == .recording {
|
||||
recordingView(rec)
|
||||
} else {
|
||||
routeListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Routes")
|
||||
.toolbar {
|
||||
if recorder?.state != .recording {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
let rec = recorder ?? RouteRecorder(sensorManager: sensor)
|
||||
recorder = rec
|
||||
rec.startRecording()
|
||||
} label: {
|
||||
Label("Record", systemImage: "record.circle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if recorder == nil { recorder = RouteRecorder(sensorManager: sensor) }
|
||||
}
|
||||
.sheet(isPresented: $showWaypointSheet) { waypointSheet }
|
||||
.sheet(isPresented: $showSaveSheet) {
|
||||
if let route = pendingRoute { saveRouteSheet(route) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route list
|
||||
|
||||
private var routeListView: some View {
|
||||
List {
|
||||
if store.routes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Routes",
|
||||
systemImage: "point.bottomleft.forward.to.point.topright.scurvepath",
|
||||
description: Text("Tap the record button to start capturing a route.")
|
||||
)
|
||||
} else {
|
||||
ForEach(store.routes) { route in
|
||||
routeRow(route)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.map { store.routes[$0] }.forEach { store.delete($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func routeRow(_ route: SavedRoute) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(route.name).font(.headline)
|
||||
HStack(spacing: 12) {
|
||||
Label(formatDate(route.date), systemImage: "calendar")
|
||||
Label(formatDuration(route.durationSeconds), systemImage: "clock")
|
||||
Label(formatDistance(route.distanceMetres), systemImage: "ruler")
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !route.waypoints.isEmpty {
|
||||
Label("\(route.waypoints.count) waypoints", systemImage: "mappin.and.ellipse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// MARK: - Recording view
|
||||
|
||||
private func recordingView(_ rec: RouteRecorder) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
// Live stats bar
|
||||
HStack(spacing: 4) {
|
||||
statBadge(value: rec.elapsedString, label: "elapsed", icon: "clock.fill", color: .red)
|
||||
statBadge(value: "\(rec.points.count)", label: "points", icon: "location", color: .blue)
|
||||
statBadge(value: formatDistance(rec.distanceSoFar), label: "dist", icon: "ruler", color: .green)
|
||||
statBadge(value: "\(rec.waypoints.count)", label: "waypoints", icon: "mappin", color: .orange)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial)
|
||||
|
||||
Divider()
|
||||
|
||||
RecordingMapView(recorder: rec)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
// Controls
|
||||
HStack(spacing: 16) {
|
||||
Button { showWaypointSheet = true } label: {
|
||||
Label("Waypoint", systemImage: "mappin.and.ellipse")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
|
||||
Button(role: .destructive) {
|
||||
if let route = rec.stopRecording() {
|
||||
pendingRoute = route
|
||||
routeName = route.name
|
||||
showSaveSheet = true
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func statBadge(value: String, label: String, icon: String, color: Color) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Label(value, systemImage: icon)
|
||||
.font(.system(.footnote, design: .monospaced).bold())
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
Text(label).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Waypoint sheet
|
||||
|
||||
private var waypointSheet: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Label") {
|
||||
TextField("e.g. Gate, Bench, Corner", text: $waypointLabel)
|
||||
}
|
||||
Section("Robot action at this point") {
|
||||
Picker("Action", selection: $waypointAction) {
|
||||
ForEach(WaypointAction.allCases) { action in
|
||||
Label(action.displayName, systemImage: action.systemImage).tag(action)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Waypoint")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showWaypointSheet = false }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Add") {
|
||||
recorder?.addWaypoint(
|
||||
label: waypointLabel.isEmpty ? "Waypoint" : waypointLabel,
|
||||
action: waypointAction
|
||||
)
|
||||
waypointLabel = ""
|
||||
waypointAction = .none
|
||||
showWaypointSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
// MARK: - Save sheet
|
||||
|
||||
private func saveRouteSheet(_ route: SavedRoute) -> some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Route name") {
|
||||
TextField("Name", text: $routeName)
|
||||
}
|
||||
Section("Summary") {
|
||||
LabeledContent("Points", value: "\(route.points.count)")
|
||||
LabeledContent("Duration", value: formatDuration(route.durationSeconds))
|
||||
LabeledContent("Distance", value: formatDistance(route.distanceMetres))
|
||||
LabeledContent("Waypoints", value: "\(route.waypoints.count)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Save Route")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Discard") { pendingRoute = nil; showSaveSheet = false }
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
if var r = pendingRoute {
|
||||
if !routeName.isEmpty { r.name = routeName }
|
||||
store.save(r)
|
||||
}
|
||||
pendingRoute = nil; showSaveSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
// MARK: - Formatters
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let f = DateFormatter(); f.dateStyle = .short; f.timeStyle = .short
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private func formatDuration(_ s: Double) -> String {
|
||||
let t = Int(s)
|
||||
if t < 60 { return "\(t)s" }
|
||||
if t < 3600 { return "\(t/60)m \(t%60)s" }
|
||||
return "\(t/3600)h \((t%3600)/60)m"
|
||||
}
|
||||
|
||||
private func formatDistance(_ m: Double) -> String {
|
||||
m < 1000 ? String(format: "%.0f m", m) : String(format: "%.2f km", m / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live recording map (UIKit bridge)
|
||||
|
||||
private struct RecordingMapView: UIViewRepresentable {
|
||||
@ObservedObject var recorder: RouteRecorder
|
||||
|
||||
func makeUIView(context: Context) -> MKMapView {
|
||||
let map = MKMapView()
|
||||
map.showsUserLocation = true
|
||||
map.userTrackingMode = .follow
|
||||
map.delegate = context.coordinator
|
||||
return map
|
||||
}
|
||||
|
||||
func updateUIView(_ map: MKMapView, context: Context) {
|
||||
map.removeOverlays(map.overlays)
|
||||
map.removeAnnotations(map.annotations.filter { !($0 is MKUserLocation) })
|
||||
|
||||
let coords = recorder.points.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
if coords.count > 1 {
|
||||
map.addOverlay(MKPolyline(coordinates: coords, count: coords.count))
|
||||
}
|
||||
|
||||
for wp in recorder.waypoints {
|
||||
map.addAnnotation(WaypointAnnotation(waypoint: wp))
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator: NSObject, MKMapViewDelegate {
|
||||
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
|
||||
guard let line = overlay as? MKPolyline else {
|
||||
return MKOverlayRenderer(overlay: overlay)
|
||||
}
|
||||
let r = MKPolylineRenderer(polyline: line)
|
||||
r.strokeColor = .systemRed; r.lineWidth = 3
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class WaypointAnnotation: NSObject, MKAnnotation {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let title: String?
|
||||
let subtitle: String?
|
||||
init(waypoint: Waypoint) {
|
||||
coordinate = CLLocationCoordinate2D(latitude: waypoint.latitude,
|
||||
longitude: waypoint.longitude)
|
||||
title = waypoint.label
|
||||
subtitle = waypoint.action == .none ? nil : waypoint.action.displayName
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RoutesView().environmentObject(SensorManager())
|
||||
}
|
||||
@ -107,7 +107,7 @@ final class SensorManager: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - Internal sensor state
|
||||
|
||||
private var lastKnownLocation: CLLocation?
|
||||
private(set) var lastKnownLocation: CLLocation?
|
||||
private var lastKnownMotion: CMDeviceMotion?
|
||||
|
||||
/// Orin-fused phone absolute position (RTK GPS + UWB offset).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user