Files
wristivo/Apps/iPhone/RemoteDemoView.swift
T

377 lines
16 KiB
Swift

import SwiftUI
import RemoteCore
@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 {
@StateObject private var model: PhoneExperienceViewModel
init(service: any PhoneExperienceService = DemoPhoneService()) {
_model = StateObject(wrappedValue: PhoneExperienceViewModel(service: service))
}
var body: some View {
NavigationStack {
ScrollView {
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)
}
}
.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 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)) }