Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
+326 -22
View File
@@ -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)
}
}
+140
View File
@@ -0,0 +1,140 @@
import SwiftUI
import PhoneControl
import RemoteCore
import TVTransport
struct LivePhoneView: View {
@ObservedObject var controller: LivePhoneController
@Environment(\.scenePhase) private var scenePhase
@State private var address = ""
@State private var confirmForget = false
@State private var reviewingCertificate: CertificateCandidate?
var body: some View {
Group {
if controller.isDemo {
VStack(spacing: 0) {
Button("Leave demo and return to saved TV") { Task { await controller.setDemo(false) } }
.padding().disabled(controller.isBusy)
RemoteDemoView()
}
} else {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Label("Live prototype", systemImage: "tv").font(.headline)
Text("TV compatibility and wrist recognition are awaiting physical testing. Keep your existing remote available.")
.font(.footnote).foregroundStyle(.secondary)
Text(status).font(.title2.bold()).accessibilityAddTraits(.updatesFrequently)
if let error = controller.error { Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.orange) }
if let notice = controller.notice { Text(notice).foregroundStyle(.secondary) }
if let television = controller.television {
Text("\(television.name) · \(television.host)").textSelection(.enabled)
if !controller.isReady {
Button("Reconnect saved TV") { Task { await controller.reconnect() } }
.buttonStyle(.bordered).disabled(controller.isBusy)
}
}
if !controller.isReady { setup }
if let certificate = controller.certificate {
Button("Review TV certificate") { reviewingCertificate = certificate }
.buttonStyle(.bordered).disabled(controller.isBusy)
}
remote
Text("Watch control requires a reachable iPhone relay. Keep the iPhone app open for the first test; locked-phone behavior is not yet verified.")
.font(.footnote).foregroundStyle(.secondary)
if controller.television != nil {
Button("Forget TV", role: .destructive) { confirmForget = true }
.frame(minHeight: 44).disabled(controller.isBusy)
}
Button("Try demo without controlling a TV") { Task { await controller.setDemo(true) } }
.frame(minHeight: 44).disabled(controller.isBusy)
}
.padding().frame(maxWidth: 680).frame(maxWidth: .infinity)
}
.navigationTitle("Wristivo")
.tint(.teal)
.confirmationDialog("Forget this TV?", isPresented: $confirmForget, titleVisibility: .visible) {
Button("Remove saved TV access", role: .destructive) { Task { await controller.forget() } }
} message: {
Text("This disconnects the TV and removes its saved address, token and certificate approval from this iPhone. You may also remove Wristivo from the TV's device list.")
}
.sheet(item: $reviewingCertificate) { candidate in certificateReview(candidate) }
}
}
}
.task { await controller.start() }
.onChange(of: scenePhase) { _, phase in
if phase == .active, !controller.isReady { Task { await controller.reconnect() } }
}
}
private var setup: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Pair your TV").font(.headline)
Text("Find the TV's IP address in its network settings. Use the same home network, then approve the controller named Wristivo on the TV.")
if controller.television == nil {
TextField("TV IP address or hostname", text: $address)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
.textFieldStyle(.roundedBorder).accessibilityLabel("TV local address")
}
Button(controller.isBusy ? "Please wait…" : "Pair / pair again") {
Task { await controller.pair(address: controller.television?.host ?? address) }
}.buttonStyle(.borderedProminent).disabled(controller.isBusy)
Text("Pairing makes a local network connection and may trigger iOS's access prompt. If blocked, check Wristivo's Local Network setting. A failed connection alone cannot tell us whether permission was denied.")
.font(.footnote).foregroundStyle(.secondary)
}
}
private var remote: some View {
VStack(spacing: 12) {
Text("Touch remote").font(.headline).frame(maxWidth: .infinity, alignment: .leading)
key(.up)
HStack { key(.left); key(.select); key(.right) }
key(.down)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 10) {
ForEach([RemoteCommand.back, .home, .playPause, .volumeDown, .volumeUp, .mute], id: \.self) { key($0) }
}
}.disabled(!controller.isReady || controller.isBusy)
}
private func key(_ command: RemoteCommand) -> some View {
Button { Task { await controller.send(command) } } label: {
Label(command.title, systemImage: command.symbol).frame(maxWidth: .infinity, minHeight: 48)
}.buttonStyle(.bordered).accessibilityHint("Sends one \(command.title) command")
}
private func certificateReview(_ candidate: CertificateCandidate) -> some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Approve this TV certificate?").font(.title2.bold())
Text("iOS could not validate the certificate for \(candidate.host), or it differs from the saved certificate. The connection was stopped.")
Text("This fingerprint came from the connection itself. It does not independently prove the device is your TV. Confirm the address using the TV's settings and only approve on a network you trust. A later certificate change will require approval again.")
Text("SHA-256").font(.headline)
Text(candidate.fingerprint).font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
Button("Approve this certificate for this TV") {
reviewingCertificate = nil
Task { await controller.approveCertificate(candidate) }
}.buttonStyle(.borderedProminent)
Button("Cancel", role: .cancel) { reviewingCertificate = nil }
}.padding()
}.navigationTitle("TV certificate").navigationBarTitleDisplayMode(.inline)
}
}
private var status: String {
switch controller.connection {
case .disconnected: "TV disconnected"
case .connecting: "Connecting to TV…"
case .awaitingApproval: "Approve Wristivo on your TV"
case .ready: "TV transport ready"
case .denied: "TV approval was denied"
case .unauthorized: "Pair with the TV again"
case .untrusted: "TV certificate needs review"
case .timedOut: "TV connection timed out"
case .unavailable(_, let failure):
failure == .missingToken ? "Pair with the TV to save access" : "TV unavailable — check the network and permissions"
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
import PhoneControl
import SecureStorage
import TVTransport
import WatchBridgeCommon
import WatchBridgePhone
import WatchConnectivity
@MainActor
final class PhoneAppComposition: ObservableObject {
let controller: LivePhoneController
private let relay: PhoneWCSessionDelegate?
init() {
let authorization = KeychainTVAuthorizationStore()
let certificateReceiver = CertificateReceiver()
let factory = URLSessionSamsungSocketFactory(onUntrustedCertificate: { host, fingerprint in
Task { @MainActor in certificateReceiver.controller?.receiveUntrustedCertificate(host: host, sha256: fingerprint) }
})
let transport = TVConnectionActor(socketFactory: factory, authorizationStore: authorization)
controller = LivePhoneController(transport: transport, authorization: authorization,
selection: KeychainSelectedTelevisionStore())
certificateReceiver.controller = controller
if WCSession.isSupported() {
let relay = PhoneWCSessionDelegate(coordinator: transport)
self.relay = relay
controller.onSelectionChanged = { [weak relay] television, ready in
relay?.bridge.publishContext(.init(selectedTelevisionID: television?.id,
displayName: television?.name, isConnectionReady: ready))
}
} else { relay = nil }
}
}
@MainActor
private final class CertificateReceiver {
weak var controller: LivePhoneController?
}
+67
View File
@@ -0,0 +1,67 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# iPhone experience handoff
`RemoteDemoView` owns the iPhone setup and touch-remote shell for workstream D.
It is intentionally wired to `DemoPhoneService` from `WristivoApp` and visibly
labels the entire flow as Demo mode. The fake does not open a socket, request a
real permission, persist a token, or claim that a TV changed state.
## Production wiring
The integrator should keep `PhoneExperienceService` as the UI boundary and
inject a `@MainActor` production composition from `WristivoApp`:
```swift
WindowGroup {
RemoteDemoView(service: productionPhoneExperienceService,
isDemo: false)
}
```
The production service should own the transport actor, token/trust repository,
and local-network permission request. Views should not gain protocol strings,
envelopes, tokens, addresses beyond the user-entered display value, or network
state. A successful command must remain a transport receipt, not TV confirmation.
The current initializer defaults `isDemo` to `true` for safe review. When live
composition is integrated, pass `isDemo: false`; the view model will then use
truthful live-service copy for pairing and command receipts.
## Fake-state UI checks
Run these checks in Preview or by constructing `DemoPhoneService` with the
listed scenario. These are UI/state checks, not hardware evidence.
1. Default `.success`: begin setup, enter any redacted local address, continue,
start pairing, verify the selected TV and touch remote appear. Press a
directional button and verify the notice says the action was recorded in
Demo mode and that no TV was contacted.
2. `.permissionDenied`: enter an address and continue. Verify the recoverable
local-network error and Try again action. No setup transition should occur.
3. `.networkUnavailable`: continue, start pairing, and verify the network
error. On the remote screen the same scenario returns an unavailable error;
verify no command is queued.
4. `.authorizationDenied` and `.timedOut`: verify pairing remains recoverable,
explains the failure, and does not show a selected TV.
5. On the remote screen, choose Forget this TV, cancel, then confirm. Verify
cancel preserves the selected TV and confirmation returns to setup.
6. With Larger Text and VoiceOver enabled, verify each remote action has a
distinct spoken label, the Demo banner is announced once, and all controls
remain at least 44 points tall. Check iPhone 13 Pro Max, the smallest
supported phone, and an iPad width; the content uses a bounded readable
column and adaptive action grid.
## Validation status
- `git diff --check`: passed.
- `swift test` in `Packages/WristivoKit`: blocked before test execution by the
sandbox's codesign error (`resource fork ... not allowed`) in generated test
bundles.
- iPhone `xcodebuild`: blocked before app compilation by unavailable
CoreSimulator and restricted host Swift/Clang cache paths. The integrator
should run the normal app build on the combined commit.
- Hardware/TV: untested. No physical TV or motion-recognition claim is made.
- Permissions/privacy: this UI adds no manifest entries and requests no camera,
location, tracking, or account access. The production service must add the
local-network usage description only when it is introduced.
+360 -51
View File
@@ -1,67 +1,376 @@
import SwiftUI
import RemoteCore
/// Foundation UI: intentionally never opens a TV connection.
@MainActor
protocol PhoneExperienceService {
func requestLocalNetworkAccess() async -> Result<Void, PhoneExperienceError>
func pair(address: String, controllerName: String) async -> Result<PairedTelevision, PhoneExperienceError>
func send(_ command: RemoteCommand) async -> Result<PhoneCommandOutcome, PhoneExperienceError>
func forget() async -> Result<Void, PhoneExperienceError>
}
struct PairedTelevision: Equatable, Sendable {
let name: String
let address: String
}
enum PhoneCommandOutcome: Equatable, Sendable {
case recordedInDemo
case sentToTransport
}
enum PhoneExperienceError: Error, Equatable, Sendable {
case invalidAddress, localNetworkPermissionDenied, networkUnavailable, authorizationDenied, pairingTimedOut, unavailable
var title: String {
switch self {
case .invalidAddress: "TV address is required"
case .localNetworkPermissionDenied: "Local network access is off"
case .networkUnavailable: "Network unavailable"
case .authorizationDenied: "TV authorization was not completed"
case .pairingTimedOut: "Pairing took too long"
case .unavailable: "The TV is unavailable"
}
}
var message: String {
switch self {
case .invalidAddress: "Enter the TV address shown in its network settings, then try again."
case .localNetworkPermissionDenied: "Allow Wristivo to access your local network in Settings, then try again."
case .networkUnavailable: "Check that the iPhone and TV are on the same reachable home network."
case .authorizationDenied: "Approve the Wristivo controller on the TV, or start pairing again."
case .pairingTimedOut: "The TV did not answer in time. It is safe to try again."
case .unavailable: "The saved TV cannot be reached right now. No command was queued."
}
}
}
/// Explicitly fake: no socket, real permission, token, or TV confirmation.
@MainActor
final class DemoPhoneService: PhoneExperienceService {
enum Scenario: String, CaseIterable, Identifiable {
case success, permissionDenied, networkUnavailable, authorizationDenied, timedOut
var id: String { rawValue }
}
var scenario: Scenario
init(scenario: Scenario = .success) { self.scenario = scenario }
func requestLocalNetworkAccess() async -> Result<Void, PhoneExperienceError> {
scenario == .permissionDenied ? .failure(.localNetworkPermissionDenied) : .success(())
}
func pair(address: String, controllerName: String) async -> Result<PairedTelevision, PhoneExperienceError> {
switch scenario {
case .networkUnavailable: .failure(.networkUnavailable)
case .authorizationDenied: .failure(.authorizationDenied)
case .timedOut: .failure(.pairingTimedOut)
default: .success(PairedTelevision(name: "Demo TV", address: address))
}
}
func send(_ command: RemoteCommand) async -> Result<PhoneCommandOutcome, PhoneExperienceError> {
scenario == .networkUnavailable ? .failure(.unavailable) : .success(.recordedInDemo)
}
func forget() async -> Result<Void, PhoneExperienceError> { .success(()) }
}
@MainActor
final class PhoneExperienceViewModel: ObservableObject {
enum Screen { case welcome, address, pairing, remote }
@Published var screen: Screen = .welcome
@Published var address = ""
@Published var controllerName = "Wristivo"
@Published private(set) var pairedTV: PairedTelevision?
@Published private(set) var error: PhoneExperienceError?
@Published private(set) var notice: String?
@Published private(set) var lastCommand: RemoteCommand?
@Published private(set) var isBusy = false
@Published var showForgetConfirmation = false
let isDemo: Bool
private let service: any PhoneExperienceService
init(service: any PhoneExperienceService, isDemo: Bool = true) {
self.service = service
self.isDemo = isDemo
}
func beginSetup() { clearFeedback(); screen = .address }
func requestPairing() {
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = .invalidAddress
return
}
clearFeedback(); isBusy = true
Task { @MainActor [weak self] in
guard let self else { return }
let result = await service.requestLocalNetworkAccess()
isBusy = false
switch result {
case .success: screen = .pairing
case let .failure(value): error = value
}
}
}
func pair() {
clearFeedback(); isBusy = true
let enteredAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
let enteredName = controllerName.trimmingCharacters(in: .whitespacesAndNewlines)
Task { @MainActor [weak self] in
guard let self else { return }
let result = await service.pair(address: enteredAddress, controllerName: enteredName.isEmpty ? "Wristivo" : enteredName)
isBusy = false
switch result {
case let .success(tv):
pairedTV = tv
notice = isDemo ? "Demo pairing complete. No TV was contacted." : "Pairing complete."
screen = .remote
case let .failure(value): error = value
}
}
}
func send(_ command: RemoteCommand) {
clearFeedback(); isBusy = true
Task { @MainActor [weak self] in
guard let self else { return }
let result = await service.send(command)
isBusy = false
switch result {
case .success:
lastCommand = command
notice = isDemo ? "Demo action recorded. The TV was not contacted." : "Command sent to transport. TV effect is not confirmed."
case let .failure(value): error = value
}
}
}
func forgetTV() {
clearFeedback(); isBusy = true
Task { @MainActor [weak self] in
guard let self else { return }
let result = await service.forget()
isBusy = false
switch result {
case .success:
pairedTV = nil; address = ""; lastCommand = nil
notice = "Demo TV forgotten. No real TV access was stored."
screen = .welcome
case let .failure(value): error = value
}
}
}
func clearFeedback() { error = nil; notice = nil }
func retry() {
if screen == .address { requestPairing() }
else if screen == .pairing { pair() }
else if screen == .remote { notice = "Try the command again when the TV is reachable." }
}
}
/// iPhone setup and remote shell. It is intentionally labelled Demo until the
/// integrator injects the production service.
struct RemoteDemoView: View {
@State private var lastCommand = "Try a button below"
@StateObject private var model: PhoneExperienceViewModel
init(service: any PhoneExperienceService = DemoPhoneService()) {
_model = StateObject(wrappedValue: PhoneExperienceViewModel(service: service))
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Label("Demo mode", systemImage: "play.rectangle")
.font(.headline)
Text("Explore your remote. No television is connected.")
Text("Pairing and wrist movement recognition are still in development.")
.font(.footnote).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 20) {
DemoBanner()
if let error = model.error { FeedbackCard(error: error, retry: model.retry) }
if let notice = model.notice { NoticeCard(text: notice) }
switch model.screen {
case .welcome: WelcomeContent(begin: model.beginSetup)
case .address: AddressContent(model: model)
case .pairing: PairingContent(model: model)
case .remote: RemoteContent(model: model)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20))
VStack(spacing: 12) {
commandButton(.up)
HStack(spacing: 16) {
commandButton(.left)
commandButton(.select)
commandButton(.right)
}
commandButton(.down)
}
Text(lastCommand).font(.callout).accessibilityIdentifier("demo.lastCommand")
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach([RemoteCommand.back, .home, .volumeDown, .volumeUp, .mute, .playPause], id: \.rawValue) { command in
Button { record(command) } label: {
Label(command.title, systemImage: command.symbol)
.frame(maxWidth: .infinity, minHeight: 44)
}.buttonStyle(.bordered)
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Made for a lighter touch").font(.headline)
Text("The planned watch remote translates deliberate wrist movements into directional presses. Your iPhone will relay commands to a paired TV on your home network.")
Text("Gesture control will require an active watch session and a reachable iPhone.")
.foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading)
}
.padding().frame(maxWidth: 560)
.frame(maxWidth: .infinity)
.padding(.horizontal, 20).padding(.vertical, 16)
.frame(maxWidth: 720).frame(maxWidth: .infinity)
}
.navigationTitle("Wristivo")
.navigationBarTitleDisplayMode(.large)
.tint(.teal)
.confirmationDialog("Forget this TV?", isPresented: $model.showForgetConfirmation, titleVisibility: .visible) {
Button("Forget TV", role: .destructive, action: model.forgetTV)
Button("Cancel", role: .cancel) { }
} message: {
Text("This removes the simulated TV and returns to demo setup.")
}
}
}
private func commandButton(_ command: RemoteCommand) -> some View {
Button { record(command) } label: {
Image(systemName: command.symbol)
.font(.title2).frame(width: 64, height: 56)
}
.buttonStyle(.borderedProminent)
.accessibilityLabel(command.title)
}
private func record(_ command: RemoteCommand) {
lastCommand = "Demo: \(command.title)"
}
}
private struct DemoBanner: View {
var body: some View {
Label {
VStack(alignment: .leading, spacing: 3) {
Text("Demo mode").font(.headline)
Text("These demo controls do not contact a TV or use motion data.")
.font(.subheadline).foregroundStyle(.secondary)
}
} icon: { Image(systemName: "play.rectangle.fill").foregroundStyle(.teal) }
.accessibilityElement(children: .combine)
.padding(16).frame(maxWidth: .infinity, alignment: .leading)
.background(.teal.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
}
}
private struct WelcomeContent: View {
let begin: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Set up your TV").font(.largeTitle.weight(.bold)).fixedSize(horizontal: false, vertical: true)
Text("Enter the TV's local address, then approve Wristivo on the TV. You can always use the accessible touch remote on iPhone.")
Button("Begin setup", action: begin).buttonStyle(.borderedProminent).controlSize(.large).frame(minHeight: 52)
.accessibilityHint("Opens manual TV address setup")
Text("Wristivo does not use accounts, cloud services, camera access, or location access.")
.font(.footnote).foregroundStyle(.secondary)
}.accessibilitySortPriority(1)
}
}
private struct AddressContent: View {
@ObservedObject var model: PhoneExperienceViewModel
var body: some View {
VStack(alignment: .leading, spacing: 18) {
StepHeading(step: "Step 1 of 2", title: "Enter TV address")
Text("Find the address in the TV's network settings. Use a local address or hostname; Wristivo will not discover devices automatically in this setup flow.")
TextField("Example: 192.168.1.25", text: $model.address)
.textContentType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
.padding(14).background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("TV local address")
TextField("Controller name", text: $model.controllerName)
.textInputAutocapitalization(.words).padding(14)
.background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("Name shown to the TV")
Button(model.isBusy ? "Checking access…" : "Continue") { model.requestPairing() }
.buttonStyle(.borderedProminent).controlSize(.large).frame(maxWidth: .infinity, minHeight: 52).disabled(model.isBusy)
Text("Local network access is requested only when you continue with setup. If it is denied, you can change it later in Settings.")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
private struct PairingContent: View {
@ObservedObject var model: PhoneExperienceViewModel
var body: some View {
VStack(alignment: .leading, spacing: 18) {
StepHeading(step: "Step 2 of 2", title: "Approve Wristivo")
Label("Use the existing TV remote or TV controls to approve the controller named \"\(model.controllerName.isEmpty ? "Wristivo" : model.controllerName)\".", systemImage: "checkmark.shield")
Button(model.isBusy ? "Pairing…" : "Start pairing") { model.pair() }
.buttonStyle(.borderedProminent).controlSize(.large).frame(maxWidth: .infinity, minHeight: 52).disabled(model.isBusy)
Text("A successful socket connection alone is not treated as authorization. If approval expires, try again; Wristivo will not replay a command.")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
private struct RemoteContent: View {
@ObservedObject var model: PhoneExperienceViewModel
var body: some View {
VStack(alignment: .leading, spacing: 18) {
if let tv = model.pairedTV {
VStack(alignment: .leading, spacing: 4) {
Label("Selected TV", systemImage: "tv").font(.headline)
Text(tv.name).font(.title2.weight(.semibold))
Text(tv.address).font(.footnote).foregroundStyle(.secondary)
}.accessibilityElement(children: .combine)
}
Text("Touch remote").font(.title2.weight(.bold)).accessibilityAddTraits(.isHeader)
DPad(model: model)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 132), spacing: 12)], spacing: 12) {
RemoteAction(command: .back, model: model)
RemoteAction(command: .home, model: model)
RemoteAction(command: .playPause, model: model)
RemoteAction(command: .volumeDown, model: model)
RemoteAction(command: .volumeUp, model: model)
RemoteAction(command: .mute, model: model)
}
Button("Forget this TV", role: .destructive) { model.showForgetConfirmation = true }
.frame(maxWidth: .infinity, minHeight: 52)
.accessibilityHint("Removes this selected TV and returns to setup")
}
}
}
private struct DPad: View {
@ObservedObject var model: PhoneExperienceViewModel
var body: some View {
VStack(spacing: 12) {
RemoteAction(command: .up, model: model, compact: true)
HStack(spacing: 12) {
RemoteAction(command: .left, model: model, compact: true)
RemoteAction(command: .select, model: model, compact: true)
RemoteAction(command: .right, model: model, compact: true)
}
RemoteAction(command: .down, model: model, compact: true)
}.frame(maxWidth: .infinity).padding(16)
.background(Color.secondary.opacity(0.10), in: RoundedRectangle(cornerRadius: 22))
.accessibilityElement(children: .contain)
}
}
private struct RemoteAction: View {
let command: RemoteCommand
@ObservedObject var model: PhoneExperienceViewModel
var compact = false
var body: some View {
Button { model.send(command) } label: {
Label(command.title, systemImage: command.symbol).labelStyle(.titleAndIcon)
.frame(maxWidth: compact ? 82 : .infinity, minHeight: 52).contentShape(Rectangle())
}
.buttonStyle(.bordered).disabled(model.isBusy)
.accessibilityLabel(command.title).accessibilityHint("Sends one \(command.title) command")
}
}
private struct StepHeading: View {
let step: String
let title: String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(step.uppercased()).font(.caption.weight(.semibold)).foregroundStyle(.teal)
Text(title).font(.largeTitle.weight(.bold)).fixedSize(horizontal: false, vertical: true)
}
}
}
private struct FeedbackCard: View {
let error: PhoneExperienceError
let retry: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(error.title, systemImage: "exclamationmark.triangle.fill").font(.headline).foregroundStyle(.orange)
Text(error.message)
Button("Try again", action: retry).buttonStyle(.bordered).frame(minHeight: 44)
}.padding(16).frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .contain)
}
}
private struct NoticeCard: View {
let text: String
var body: some View {
Label(text, systemImage: "info.circle.fill").foregroundStyle(.primary)
.padding(14).frame(maxWidth: .infinity, alignment: .leading)
.background(Color.teal.opacity(0.10), in: RoundedRectangle(cornerRadius: 14))
.accessibilityElement(children: .combine)
}
}
#Preview("Demo setup") { RemoteDemoView() }
#Preview("Permission error") { RemoteDemoView(service: DemoPhoneService(scenario: .permissionDenied)) }
+2 -1
View File
@@ -2,7 +2,8 @@ import SwiftUI
@main
struct WristivoApp: App {
@StateObject private var composition = PhoneAppComposition()
var body: some Scene {
WindowGroup { RemoteDemoView() }
WindowGroup { LivePhoneView(controller: composition.controller) }
}
}