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
+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) }
}
}