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" } } }