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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user