Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -1,37 +1,341 @@
|
||||
import SwiftUI
|
||||
import RemoteCore
|
||||
import GestureCore
|
||||
import WatchKit
|
||||
import MotionCapture
|
||||
import WatchBridgeWatch
|
||||
import WatchBridgeCommon
|
||||
|
||||
enum WatchCommandSendResult: Sendable, Equatable {
|
||||
case transmitted
|
||||
case failed(String)
|
||||
case uncertain(String)
|
||||
case demo
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchRemoteViewModel: ObservableObject {
|
||||
enum Status: Equatable { case idle, calibrating, ready, armed, paused }
|
||||
|
||||
@Published private(set) var status: Status = .idle
|
||||
@Published private(set) var message = "Touch controls are ready"
|
||||
@Published private(set) var lastCommand: RemoteCommand?
|
||||
@Published private(set) var selectedTVName: String
|
||||
@Published private(set) var relayReachable: Bool
|
||||
@Published private(set) var calibrationComplete = false
|
||||
@Published private(set) var motionAvailable: Bool
|
||||
@Published var wrist: WristSide = .right { didSet { orientationChanged() } }
|
||||
@Published var crown: CrownOrientation = .towardHand { didSet { orientationChanged() } }
|
||||
|
||||
@Published private(set) var isDemo = false
|
||||
private let motion = MotionCaptureService()
|
||||
private var calibration: MotionCalibration?
|
||||
private var relay: WatchWCSessionDelegate?
|
||||
private var selectedTVID: UUID?
|
||||
private var sessionID: UUID?
|
||||
private var active = true
|
||||
private var uiGeneration = UUID()
|
||||
|
||||
init() {
|
||||
selectedTVName = "No TV selected"
|
||||
relayReachable = false
|
||||
motionAvailable = motion.isAvailable
|
||||
let relay = WatchWCSessionDelegate()
|
||||
self.relay = relay
|
||||
relay.bridge.onStateChange = { [weak self] state in
|
||||
guard let self, !self.isDemo else { return }
|
||||
switch state {
|
||||
case .armed: self.setRelayReachable(true); self.message = "Connected — touch controls ready"
|
||||
case .ready(let reachable): self.setRelayReachable(reachable)
|
||||
case .sessionNegotiating: self.message = "Connecting to iPhone…"
|
||||
default: self.setRelayReachable(false)
|
||||
}
|
||||
}
|
||||
relay.onContext = { [weak self] context in
|
||||
guard let self, !self.isDemo else { return }
|
||||
if self.selectedTVID != context.selectedTelevisionID {
|
||||
self.stopMotionOnly(); self.relay?.bridge.endSession()
|
||||
self.selectedTVID = context.selectedTelevisionID
|
||||
self.calibration = nil; self.calibrationComplete = false
|
||||
self.selectedTVName = context.displayName ?? "No TV selected"
|
||||
}
|
||||
if !context.isConnectionReady { self.stopMotionOnly(); self.relay?.bridge.endSession(); self.message = "Connect a TV on iPhone" }
|
||||
}
|
||||
}
|
||||
|
||||
static func demo() -> WatchRemoteViewModel { let result = WatchRemoteViewModel(); result.setDemo(true); return result }
|
||||
func setDemo(_ value: Bool) {
|
||||
stopMotionOnly(); relay?.bridge.endSession(); isDemo = value
|
||||
calibration = nil; calibrationComplete = false; status = .idle
|
||||
message = value ? "Demo only — nothing will be sent" : "Live prototype — connect using iPhone"
|
||||
}
|
||||
func connect() {
|
||||
guard active else { return }
|
||||
if isDemo { message = "Demo touch controls ready"; return }
|
||||
relay?.bridge.beginSession()
|
||||
}
|
||||
private func orientationChanged() {
|
||||
stopMotionOnly(); calibration = nil; calibrationComplete = false; status = .idle
|
||||
message = "Orientation changed — calibrate again"
|
||||
}
|
||||
func calibrate() {
|
||||
stopMotionOnly(); calibrationComplete = false; calibration = nil
|
||||
guard active, !isDemo else { message = "Motion calibration is available in live prototype mode"; return }
|
||||
status = .calibrating; message = "Hold still in a neutral position for one second"
|
||||
let generation = uiGeneration
|
||||
motion.calibrate(wrist: wrist, crown: crown) { [weak self] result in
|
||||
guard let self, self.uiGeneration == generation, self.active else { return }
|
||||
switch result {
|
||||
case .success(let value): self.calibration = value; self.calibrationComplete = true; self.status = .ready; self.message = "Calibration saved — press Start"; self.haptic(.success)
|
||||
case .failure: self.status = .idle; self.message = "Calibration failed. Hold still and retry."; self.haptic(.failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
func start() {
|
||||
guard active, !isDemo, let calibration, calibrationComplete else { message = "Calibrate before starting"; return }
|
||||
guard let grant = relay?.bridge.grant, relayReachable else { message = "Press Connect before starting"; return }
|
||||
stopMotionOnly()
|
||||
let generation = uiGeneration
|
||||
do {
|
||||
try motion.start(sessionID: grant.sessionID, calibration: calibration, onRecognition: { [weak self] result in
|
||||
Task { @MainActor [weak self] in
|
||||
guard self?.uiGeneration == generation else { return }; self?.recognized(result)
|
||||
}
|
||||
}, onFailure: { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard self?.uiGeneration == generation else { return }; self?.motionFailed("Sensor unavailable")
|
||||
}
|
||||
}, onExpiry: { [weak self] in self?.stop(); self?.message = "10-second motion session expired — press Start again" })
|
||||
sessionID = grant.sessionID; status = .armed; message = "Armed for 10 seconds — move deliberately"; haptic(.start)
|
||||
} catch { motionFailed("Sensor unavailable") }
|
||||
}
|
||||
private func sendCommand(_ command: RemoteCommand) async -> WatchCommandSendResult {
|
||||
guard active else { return .failed("Watch app inactive") }
|
||||
if isDemo { return .demo }
|
||||
guard let relay else { return .failed("Phone unavailable") }
|
||||
switch await relay.bridge.sendCommandResult(command) {
|
||||
case .success(let receipt):
|
||||
if receipt.status == .unavailable { return .uncertain("Transport could not confirm delivery") }
|
||||
return receipt.status == .sentToTransport ? .transmitted : .failed(receipt.status.rawValue)
|
||||
case .failure: return .uncertain("No validated receipt; reconnect before trying another action")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopMotionOnly()
|
||||
status = calibrationComplete ? .ready : .idle
|
||||
message = "Stopped — no movement will be sent"
|
||||
haptic(.stop)
|
||||
}
|
||||
|
||||
func setSceneActive(_ active: Bool) {
|
||||
self.active = active
|
||||
if !active { relay?.bridge.endSession() }
|
||||
guard !active else {
|
||||
if status == .paused { message = "Paused — press Start to arm again" }
|
||||
return
|
||||
}
|
||||
stopMotionOnly()
|
||||
status = .paused
|
||||
message = "Paused because the watch app is inactive"
|
||||
}
|
||||
|
||||
func setRelayReachable(_ reachable: Bool) {
|
||||
relayReachable = reachable
|
||||
guard reachable else {
|
||||
stopMotionOnly()
|
||||
status = .paused
|
||||
message = "Phone unavailable — motion is disarmed"
|
||||
return
|
||||
}
|
||||
if status == .paused { message = "Phone reachable — press Start to arm again" }
|
||||
}
|
||||
|
||||
func selectTV(_ name: String) {
|
||||
guard selectedTVName != name else { return }
|
||||
stopMotionOnly()
|
||||
selectedTVName = name
|
||||
status = .idle
|
||||
calibrationComplete = false
|
||||
message = "TV changed — calibrate and start again"
|
||||
}
|
||||
|
||||
func tap(_ command: RemoteCommand) {
|
||||
guard active else {
|
||||
message = "Paused — press Start to use the remote"
|
||||
return
|
||||
}
|
||||
lastCommand = command
|
||||
haptic(.click)
|
||||
let generation = uiGeneration
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.uiGeneration == generation else { return }
|
||||
let result = await self.sendCommand(command)
|
||||
guard self.uiGeneration == generation else { return }
|
||||
switch result {
|
||||
case .transmitted:
|
||||
message = "Transmitted: \(command.title) (TV effect not confirmed)"; haptic(.success)
|
||||
case .failed(let detail):
|
||||
message = "Send failed: \(detail)"; haptic(.failure)
|
||||
case .uncertain(let detail):
|
||||
message = "Send uncertain: \(detail) — do not repeat automatically"; haptic(.warning)
|
||||
case .demo: message = "Demo only: \(command.title) — nothing sent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recognized(_ recognition: GestureRecognition) {
|
||||
guard status == .armed, recognition.sessionID == sessionID else { return }
|
||||
lastCommand = recognition.command
|
||||
message = "Recognized \(recognition.command.title) — sending"
|
||||
haptic(.recognition)
|
||||
let generation = uiGeneration
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.uiGeneration == generation, self.status == .armed else { return }
|
||||
let result = await self.sendCommand(recognition.command)
|
||||
guard self.uiGeneration == generation else { return }
|
||||
switch result {
|
||||
case .transmitted:
|
||||
message = "Transmitted: \(recognition.command.title) (TV effect not confirmed)"; haptic(.success)
|
||||
case .failed(let detail):
|
||||
message = "Send failed: \(detail)"; haptic(.failure)
|
||||
case .uncertain(let detail):
|
||||
message = "Send uncertain: \(detail) — do not repeat automatically"; haptic(.warning)
|
||||
case .demo: message = "Demo only: recognized \(recognition.command.title); nothing sent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func motionFailed(_ error: String) {
|
||||
stopMotionOnly(); status = .paused; message = "Motion stopped: \(error)"; haptic(.failure)
|
||||
}
|
||||
|
||||
private func stopMotionOnly() {
|
||||
uiGeneration = UUID(); motion.stop(reason: .user); sessionID = nil
|
||||
status = calibrationComplete ? .ready : .idle
|
||||
}
|
||||
|
||||
private enum Haptic { case click, recognition, success, warning, failure, start, stop }
|
||||
private func haptic(_ kind: Haptic) {
|
||||
#if os(watchOS)
|
||||
let type: WKHapticType = switch kind {
|
||||
case .click: .click
|
||||
case .recognition: .directionUp
|
||||
case .success, .start: .success
|
||||
case .warning: .retry
|
||||
case .failure: .failure
|
||||
case .stop: .stop
|
||||
}
|
||||
WKInterfaceDevice.current().play(type)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchRemoteDemoView: View {
|
||||
@State private var lastCommand = "No TV connected"
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var model: WatchRemoteViewModel
|
||||
|
||||
init(model: WatchRemoteViewModel = WatchRemoteViewModel()) { _model = StateObject(wrappedValue: model) }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Wristivo").font(.headline)
|
||||
Text("DEMO").font(.caption2).foregroundStyle(.secondary)
|
||||
commandButton(.up)
|
||||
HStack(spacing: 4) {
|
||||
commandButton(.left)
|
||||
commandButton(.select)
|
||||
commandButton(.right)
|
||||
}
|
||||
commandButton(.down)
|
||||
Text(lastCommand).font(.caption2)
|
||||
HStack(spacing: 4) {
|
||||
commandButton(.back)
|
||||
commandButton(.home)
|
||||
}
|
||||
Text("Wrist gestures are coming in the hardware prototype.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
Text(model.isDemo ? "DEMO — nothing sent" : "LIVE PROTOTYPE — hardware validation pending")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Toggle("Demo mode", isOn: Binding(get: { model.isDemo }, set: { model.setDemo($0) }))
|
||||
Button("Connect to iPhone") { model.connect() }
|
||||
statusPanel
|
||||
setupPanel
|
||||
remotePanel
|
||||
Text(model.message).font(.caption2).foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Status: \(model.message)")
|
||||
}.padding(.horizontal, 4)
|
||||
}.tint(.teal)
|
||||
}
|
||||
.tint(.teal)
|
||||
.onAppear { model.setSceneActive(scenePhase == .active) }
|
||||
.onChange(of: scenePhase) { _, phase in model.setSceneActive(phase == .active) }
|
||||
}
|
||||
|
||||
private var statusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Label(model.selectedTVName, systemImage: "tv")
|
||||
Label(model.relayReachable ? "Phone reachable" : "Phone unavailable",
|
||||
systemImage: model.relayReachable ? "iphone" : "iphone.slash")
|
||||
Label(statusTitle, systemImage: statusSymbol)
|
||||
}.font(.caption)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Setup status. \(model.selectedTVName). \(statusTitle). \(model.relayReachable ? "Phone reachable" : "Phone unavailable")")
|
||||
}
|
||||
|
||||
private var setupPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Setup").font(.subheadline)
|
||||
Text("1. Select a TV on iPhone. 2. Choose wrist and crown orientation. 3. Hold neutral and calibrate. 4. Press Start when ready.")
|
||||
.font(.caption2)
|
||||
Picker("Wrist", selection: $model.wrist) {
|
||||
Text("Right wrist").tag(WristSide.right)
|
||||
Text("Left wrist").tag(WristSide.left)
|
||||
}
|
||||
Picker("Crown", selection: $model.crown) {
|
||||
Text("Crown toward hand").tag(CrownOrientation.towardHand)
|
||||
Text("Crown away from hand").tag(CrownOrientation.awayFromHand)
|
||||
}
|
||||
Button("Calibrate") { model.calibrate() }.buttonStyle(.bordered)
|
||||
.disabled(model.status == .armed)
|
||||
.accessibilityHint("Hold your wrist neutral before calibrating")
|
||||
HStack {
|
||||
Button("Start") { model.start() }.buttonStyle(.borderedProminent)
|
||||
.disabled(model.status == .armed || !model.calibrationComplete)
|
||||
Button("Stop") { model.stop() }.buttonStyle(.bordered)
|
||||
.disabled(model.status != .armed && model.status != .calibrating)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var remotePanel: some View {
|
||||
VStack(spacing: 5) {
|
||||
Text("Remote").font(.subheadline).frame(maxWidth: .infinity, alignment: .leading)
|
||||
commandButton(.up)
|
||||
HStack(spacing: 4) { commandButton(.left); selectButton; commandButton(.right) }
|
||||
commandButton(.down)
|
||||
HStack(spacing: 4) { commandButton(.back); commandButton(.home) }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectButton: some View {
|
||||
let button = Button { model.tap(.select) } label: {
|
||||
Image(systemName: RemoteCommand.select.symbol).frame(minWidth: 34, minHeight: 34)
|
||||
}.buttonStyle(.borderedProminent)
|
||||
.accessibilityLabel("Select")
|
||||
.accessibilityHint("Select the focused TV item")
|
||||
if #available(watchOS 11.0, *) { return AnyView(button.handGestureShortcut(.primaryAction)) }
|
||||
return AnyView(button)
|
||||
}
|
||||
|
||||
private func commandButton(_ command: RemoteCommand) -> some View {
|
||||
Button { lastCommand = "Demo: \(command.title)" } label: {
|
||||
Image(systemName: command.symbol).frame(minWidth: 24, minHeight: 30)
|
||||
Button { model.tap(command) } label: {
|
||||
Image(systemName: command.symbol).frame(minWidth: 34, minHeight: 34)
|
||||
}.buttonStyle(.bordered)
|
||||
.accessibilityLabel(command.title)
|
||||
.accessibilityHint("Send \(command.title) to the selected TV")
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
switch model.status {
|
||||
case .idle: "Not calibrated"
|
||||
case .calibrating: "Calibrating"
|
||||
case .ready: "Ready, stopped"
|
||||
case .armed: "Armed"
|
||||
case .paused: "Paused and disarmed"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSymbol: String {
|
||||
switch model.status {
|
||||
case .armed: "dot.radiowaves.left.and.right"
|
||||
case .paused: "pause.circle"
|
||||
default: "circle"
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel(command.title)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user