Files
wristivo/Apps/Watch/WatchRemoteDemoView.swift
T

342 lines
15 KiB
Swift

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 {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var model: WatchRemoteViewModel
init(model: WatchRemoteViewModel = WatchRemoteViewModel()) { _model = StateObject(wrappedValue: model) }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("Wristivo").font(.headline)
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)
.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 { 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"
}
}
}