feat: Phase 1 — Route Recording with waypoints (GPS track at 1Hz)
New 'Routes' tab added to SAUL-T-MOTE:
RECORDING
- Record button starts 1Hz GPS capture (lat/lon/alt/speed/bearing/ts)
- Live stats bar: elapsed time, point count, distance, waypoint count
- Live map shows recorded polyline + waypoint annotations in real-time
- 'Add Waypoint' sheet: label + robot action (none/stop/slow/photo)
- 'Stop' ends recording → Save sheet to name the route
STORAGE
- JSON files in app Documents/routes/<uuid>.json
- RouteStore: save/rename/delete; auto-sorts newest first
- Route list with duration, distance, waypoint count
MQTT FORMAT DEFINED (Phase 3 playback — robot side TBD)
- Topic: saltybot/route/command
- Payload: {action, route_id, route_name, points:[{lat,lon,alt,speed,bearing,ts}],
waypoints:[{lat,lon,alt,ts,label,action}]}
New files: RouteModels.swift, RouteStore.swift, RouteRecorder.swift, RoutesView.swift
SensorManager: lastKnownLocation promoted to private(set) for recorder access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6fa2a1b03f
commit
cd90d6dbee
@ -16,6 +16,10 @@
|
|||||||
A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; };
|
A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; };
|
||||||
A10000010000000000000BAA /* UWBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000BAB /* UWBModels.swift */; };
|
A10000010000000000000BAA /* UWBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000BAB /* UWBModels.swift */; };
|
||||||
A10000010000000000000CAA /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000CAB /* BLEManager.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 */; };
|
A10000010000000000000DAA /* BLEPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000DAB /* BLEPackets.swift */; };
|
||||||
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
|
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
|
||||||
A10000010000000000000FAA /* BLEStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000FAB /* BLEStatusView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = "<group>"; };
|
||||||
@ -71,6 +79,10 @@
|
|||||||
A10000010000000000000DAB /* BLEPackets.swift */,
|
A10000010000000000000DAB /* BLEPackets.swift */,
|
||||||
A10000010000000000000EAB /* AnchorInfo.swift */,
|
A10000010000000000000EAB /* AnchorInfo.swift */,
|
||||||
A10000010000000000000FAB /* BLEStatusView.swift */,
|
A10000010000000000000FAB /* BLEStatusView.swift */,
|
||||||
|
A100000100000000000010BB /* RouteModels.swift */,
|
||||||
|
A100000100000000000011BB /* RouteStore.swift */,
|
||||||
|
A100000100000000000012BB /* RouteRecorder.swift */,
|
||||||
|
A100000100000000000013BB /* RoutesView.swift */,
|
||||||
A100000100000000000005AB /* Assets.xcassets */,
|
A100000100000000000005AB /* Assets.xcassets */,
|
||||||
A100000100000000000006AB /* Info.plist */,
|
A100000100000000000006AB /* Info.plist */,
|
||||||
);
|
);
|
||||||
@ -166,6 +178,10 @@
|
|||||||
A10000010000000000000DAA /* BLEPackets.swift in Sources */,
|
A10000010000000000000DAA /* BLEPackets.swift in Sources */,
|
||||||
A10000010000000000000EAA /* AnchorInfo.swift in Sources */,
|
A10000010000000000000EAA /* AnchorInfo.swift in Sources */,
|
||||||
A10000010000000000000FAA /* BLEStatusView.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,8 @@ struct ContentView: View {
|
|||||||
.tabItem { Label("Map", systemImage: "map.fill") }
|
.tabItem { Label("Map", systemImage: "map.fill") }
|
||||||
BLEStatusView()
|
BLEStatusView()
|
||||||
.tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") }
|
.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
|
// MARK: - Internal sensor state
|
||||||
|
|
||||||
private var lastKnownLocation: CLLocation?
|
private(set) var lastKnownLocation: CLLocation?
|
||||||
private var lastKnownMotion: CMDeviceMotion?
|
private var lastKnownMotion: CMDeviceMotion?
|
||||||
|
|
||||||
/// Orin-fused phone absolute position (RTK GPS + UWB offset).
|
/// Orin-fused phone absolute position (RTK GPS + UWB offset).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user