saltylab-ios/SulTee/SulTee/ContentView.swift
sl-ios f954b844d4 feat: Add Pilot tab — MJPEG camera feed + virtual joystick RC control
- New PilotView.swift: full-screen MJPEG stream via WKWebView, virtual
  joystick overlay (bottom-right, semi-transparent), camera switcher
  pill (top-right, hidden when single source)
- Dead-man switch: finger lift snaps joystick to zero; next 10 Hz tick
  publishes {linear_x:0, angular_z:0} within 100 ms
- cmd_vel published to saltybot/cmd_vel at 10 Hz max via MQTT
- SensorManager: add ensureMQTTConnected(), releaseMQTTIfIdle(),
  publishCmdVel(linearX:angularZ:) so Pilot tab can use MQTT
  independently of Follow-Me streaming
- ContentView: add Pilot tab (camera.fill icon, last tab)
- xcodeproj: register PilotView.swift in Sources build phase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:47:30 -04:00

224 lines
6.9 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") }
PilotView()
.tabItem { Label("Pilot", systemImage: "camera.fill") }
}
}
}
// 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())
}