Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -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