Files
wristivo/Apps/iPhone/LivePhoneView.swift
T

141 lines
8.0 KiB
Swift

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"
}
}
}