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>
222 lines
6.8 KiB
Swift
222 lines
6.8 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Root (tab container)
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var sensor: SensorManager
|
|
|
|
var body: some View {
|
|
TabView {
|
|
StatusView()
|
|
.tabItem { Label("Status", systemImage: "antenna.radiowaves.left.and.right") }
|
|
MapContentView()
|
|
.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") }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Status tab
|
|
|
|
private struct StatusView: View {
|
|
@EnvironmentObject var sensor: SensorManager
|
|
@AppStorage("orinURL") private var orinURL: String = SensorManager.defaultOrinURL
|
|
@State private var editingURL: String = ""
|
|
@FocusState private var urlFieldFocused: Bool
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 24) {
|
|
connectionBanner
|
|
orinURLField
|
|
Divider()
|
|
sensorRatesGrid
|
|
Divider()
|
|
if let dist = sensor.distanceToRobot {
|
|
distanceRow(dist)
|
|
}
|
|
Spacer()
|
|
followMeButton
|
|
}
|
|
.padding()
|
|
.navigationTitle("SAUL-T-MOTE")
|
|
.onAppear { editingURL = orinURL }
|
|
}
|
|
}
|
|
|
|
// MARK: Connection banner
|
|
|
|
private var connectionBanner: some View {
|
|
HStack(spacing: 12) {
|
|
Circle()
|
|
.fill(wsColor)
|
|
.frame(width: 14, height: 14)
|
|
Text(wsLabel)
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// MARK: Orin URL field
|
|
|
|
private var orinURLField: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Orin WebSocket URL")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack {
|
|
TextField("ws://host:port", text: $editingURL)
|
|
.textFieldStyle(.roundedBorder)
|
|
.keyboardType(.URL)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.focused($urlFieldFocused)
|
|
.disabled(sensor.isStreaming)
|
|
.onSubmit { applyURL() }
|
|
if !sensor.isStreaming {
|
|
Button("Apply") { applyURL() }
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyURL() {
|
|
urlFieldFocused = false
|
|
orinURL = editingURL
|
|
sensor.updateURL(editingURL)
|
|
}
|
|
|
|
// MARK: Sensor rates grid
|
|
|
|
private var sensorRatesGrid: some View {
|
|
Grid(horizontalSpacing: 20, verticalSpacing: 12) {
|
|
GridRow {
|
|
rateCell(icon: "location.fill", label: "GPS", rate: sensor.gpsRate)
|
|
rateCell(icon: "gyroscope", label: "IMU", rate: sensor.imuRate)
|
|
}
|
|
GridRow {
|
|
rateCell(icon: "location.north.fill", label: "Heading", rate: sensor.headingRate)
|
|
rateCell(icon: "barometer", label: "Baro", rate: sensor.baroRate)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rateCell(icon: String, label: String, rate: Double) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(rate > 0 ? .green : .secondary)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text("\(Int(rate)) Hz")
|
|
.font(.title3.monospacedDigit())
|
|
.bold()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// MARK: Distance row
|
|
|
|
private func distanceRow(_ dist: Double) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: distanceIcon)
|
|
.foregroundStyle(distanceColor)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(distanceLabel(dist))
|
|
.font(.title2).bold()
|
|
Text(distanceSourceLabel)
|
|
.font(.caption).foregroundStyle(distanceColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func distanceLabel(_ dist: Double) -> String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB, .mqttUWB:
|
|
return dist < 10
|
|
? String(format: "%.2f m", dist)
|
|
: String(format: "%.1f m", dist)
|
|
case .gps:
|
|
return dist < 1000
|
|
? "~\(Int(dist)) m"
|
|
: String(format: "~%.1f km", dist / 1000)
|
|
}
|
|
}
|
|
|
|
private var distanceSourceLabel: String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB: return "UWB (BLE tag)"
|
|
case .mqttUWB: return "UWB (Orin anchors)"
|
|
case .gps: return "GPS estimate"
|
|
}
|
|
}
|
|
|
|
private var distanceIcon: String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB, .mqttUWB: return "dot.radiowaves.left.and.right"
|
|
case .gps: return "location.fill"
|
|
}
|
|
}
|
|
|
|
private var distanceColor: Color {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB: return .green
|
|
case .mqttUWB: return .mint
|
|
case .gps: return .orange
|
|
}
|
|
}
|
|
|
|
// MARK: Follow-Me button
|
|
|
|
private var followMeButton: some View {
|
|
Button {
|
|
sensor.isStreaming ? sensor.stopStreaming() : sensor.startStreaming()
|
|
} label: {
|
|
Label(
|
|
sensor.isStreaming ? "Stop Follow-Me" : "Start Follow-Me",
|
|
systemImage: sensor.isStreaming ? "stop.circle.fill" : "play.circle.fill"
|
|
)
|
|
.font(.title2.bold())
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(sensor.isStreaming ? Color.red : Color.accentColor,
|
|
in: RoundedRectangle(cornerRadius: 16))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private var wsColor: Color {
|
|
switch sensor.wsState {
|
|
case .connected: return .green
|
|
case .connecting: return .yellow
|
|
case .disconnected: return .red
|
|
}
|
|
}
|
|
|
|
private var wsLabel: String {
|
|
switch sensor.wsState {
|
|
case .connected: return "Connected — \(orinURL)"
|
|
case .connecting: return "Connecting…"
|
|
case .disconnected: return "Disconnected"
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.environmentObject(SensorManager())
|
|
}
|