fix(apple/tvOS): television-idiomatic chrome — grid action tiles + full-screen covers
ci / rust (push) Has been cancelled

The iOS chrome half-worked on tvOS: toolbar items rendered tiny with clipped labels
and could not even be focused (which is why "+" never opened the add-host form), and
sheet presentations are not a tvOS idiom (the Settings form looked broken).

- The toolbar is gone on tvOS. Add Host and Settings live IN the hosts grid as
  full-size, focus-native tiles (.card style, same geometry as the host cards) — the
  natural way actions work on television.
- Every modal (Add Host, Settings, PIN pairing) presents as a fullScreenCover on tvOS;
  Settings gains a tvOS-only Done button (covers don't dismiss themselves).
- iOS/macOS keep their existing toolbar + sheets untouched.

Verified in the Apple TV simulator: title, host card and both action tiles render
full-size and focusable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:22:18 +02:00
parent 75396c20c2
commit 7dd479f9e4
2 changed files with 63 additions and 2 deletions
@@ -55,6 +55,17 @@ struct ContentView: View {
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if os(tvOS)
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
guard pairingTarget?.id == host.id else { return }
store.pin(host.id, fingerprint: fingerprint)
var pinned = host
pinned.pinnedSHA256 = fingerprint
connect(pinned)
}
}
#else
.sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
// Backstop against a stale ceremony surfacing after dismissal (PairSheet
@@ -66,6 +77,7 @@ struct ContentView: View {
connect(pinned)
}
}
#endif
}
private var sessionView: some View {
@@ -114,14 +126,23 @@ struct ContentView: View {
ForEach(store.hosts) { host in
hostCard(host)
}
#if os(tvOS)
actionTile("Add Host", systemImage: "plus") {
showAddHost = true
}
actionTile("Settings", systemImage: "gearshape") {
showSettings = true
}
#endif
}
.padding()
}
}
}
.navigationTitle("Punktfunkempfänger")
#if !os(tvOS)
.toolbar {
#if !os(macOS)
#if os(iOS)
// Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -138,14 +159,23 @@ struct ContentView: View {
}
#endif
}
#endif
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
#if os(tvOS)
.fullScreenCover(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.fullScreenCover(isPresented: $showSettings) {
SettingsView()
}
#else
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
#if !os(macOS)
#if os(iOS)
.sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView()
@@ -156,6 +186,7 @@ struct ContentView: View {
}
}
#endif
#endif
.alert(
"Connection failed",
isPresented: Binding(
@@ -306,6 +337,30 @@ struct ContentView: View {
#endif
}
#if os(tvOS)
/// Grid-resident replacement for the toolbar (whose items are neither sized nor
/// focusable on tvOS): a full-size, focus-native tile per action.
private func actionTile(
_ label: String, systemImage: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 56, weight: .light))
.foregroundStyle(.tint)
.frame(height: 76)
Text(label)
.font(.title3.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.padding(.horizontal, 12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.card)
}
#endif
/// The host of the most recent session its card carries the accent ring.
private var mostRecentHostID: UUID? {
store.hosts