import SwiftUI import RemoteCore @MainActor protocol PhoneExperienceService { func requestLocalNetworkAccess() async -> Result func pair(address: String, controllerName: String) async -> Result func send(_ command: RemoteCommand) async -> Result func forget() async -> Result } 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 { scenario == .permissionDenied ? .failure(.localNetworkPermissionDenied) : .success(()) } func pair(address: String, controllerName: String) async -> Result { 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 { scenario == .networkUnavailable ? .failure(.unavailable) : .success(.recordedInDemo) } func forget() async -> Result { .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)) }