141 lines
8.0 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|