Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -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)) }
|
||||
|
||||
Reference in New Issue
Block a user