377 lines
16 KiB
Swift
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)) }
|