Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift
T
enricobuehlerandClaude Opus 5 f071467cbb feat(client/apple): sort and group the host grid
The grid had no ordering story — hosts sat in the order they were added and that was
the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected)
and Group by (None, Profile, Status), both per device: this is a window on a list,
not something about how a host streams. The control stays out of the toolbar until
there are two hosts to arrange.

Grouping by profile is the one the profiles made possible: bands in catalog order,
each header wearing its profile's colour, then the unbound hosts. Pinned cards stay
with their host — a pin is presentation of that host, not a host of its own — and a
dangling binding lands in "No Profile" rather than in a band named after a profile
that no longer exists. Empty bands aren't drawn.

The ordering is pure and tested, which earned its keep immediately: the test caught
that undated hosts were sorting first, and made me work out whether that was a bug.
It isn't — no date means saved before `addedAt` existed, which means older, and in a
real store those are a prefix, so the default sort leaves an upgraded grid exactly as
it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so
every comparison tie-breaks on the stored index or equal rows swap on redraw; and a
never-connected host sorts LAST under Last Connected, not first as `.distantPast`
would have it.

`StoredHost` gains `addedAt` for the date the sort actually needs — optional and
appended last, so the widget contract holds and hosts saved before it keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

427 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The home screen: a grid of saved hosts + an "On this network" section of mDNS-discovered
// hosts, with the add/settings toolbar and the pairing / speed-test / add / settings
// navigation. The connect logic lives in ContentView (it reads the @AppStorage stream mode) and
// is passed in as closures.
import PunktfunkKit
import SwiftUI
// The tvOS slide transition ships with the Xcode PROJECT only — its manifest breaks SwiftPM's
// whole-graph validation, so `swift build` never sees it. `canImport` rather than `os(tvOS)` so
// the tvOS sources still TYPECHECK from the command line (the hand recipe in the Apple client's
// README), where the module is genuinely absent; in the app it is present and the transition
// applies exactly as before.
#if os(tvOS) && canImport(SwiftUINavigationTransitions)
import SwiftUINavigationTransitions
#endif
struct HomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
/// The profile catalog — the source of the card chips, the "Connect with ▸" menu, and the
/// pinned host+profile cards the grid renders alongside their host (design §5.2a).
@ObservedObject private var profiles = ProfileStore.shared
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
/// Start a session with this host, using the given profile selection — `.inherit` for a plain
/// card tap (the host's binding), an explicit pick from "Connect with ▸" or a pinned card.
let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library — start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
/// Game-library browser (default ON; the Settings toggle opts out) — the host-card
/// "Browse Library…" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
@State private var editTarget: StoredHost?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all — an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@AppStorage(DefaultsKey.hostGrouping) private var groupingRaw = HostGrouping.none.rawValue
var body: some View {
NavigationStack {
Group {
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
emptyState
} else {
ScrollView {
if !store.hosts.isEmpty {
LazyVStack(alignment: .leading, spacing: gridSpacing) {
ForEach(hostGroups) { group in
if let title = group.title {
groupHeader(title, accent: group.accent)
}
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(entries(in: group)) { entry in
hostCard(entry.host, pinned: entry.profile)
}
}
}
}
.padding()
// Mirror of the action row's focusSection below: an UPWARD move from
// the centered buttons must land back in the grid even when no card
// sits in the buttons' columns (a lone top-left card, say). The grid
// spans the row, so the section catches every upward ray.
#if os(tvOS)
.focusSection()
#endif
}
if !discoveredUnsaved.isEmpty {
discoveredSection
}
#if os(tvOS)
// Actions live below the hosts, not between them.
HStack(spacing: 32) {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
}
.padding(.top, 24)
// One FULL-WIDTH focus target for any downward move out of the grid.
// focusSection alone is not enough: the engine tests the section's
// FRAME, and a content-hugging centered HStack only overlaps the middle
// columns — a swipe down from an outer card dead-ends and the actions
// are unreachable by remote. Stretching the section across the row means
// every column's downward ray hits it.
.frame(maxWidth: .infinity)
.focusSection()
#endif
}
}
}
.navigationTitle("Punktfunk")
// Browse the LAN for advertised hosts only while the grid is up — not during a
// session. The home appears/disappears as the stream swaps in and out.
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep while the grid is up: a saved host reached only over a routed
// network (Tailscale/VPN) never advertises on mDNS, so `advertises` can't see it. Probe
// every non-advertising saved host ~every 10 s and publish the reachable set for the
// pips (`isOnline` above OR's it in). The `.task` is cancelled on disappear, matching
// `discovery.stop()`.
.task {
while !Task.isCancelled {
await store.refreshReachability(discovery: discovery)
try? await Task.sleep(for: .seconds(10))
}
}
#if os(tvOS)
// Pushed routes — the Settings-app navigation feel (push animation, Menu
// pops) instead of modal overlays.
.navigationDestination(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.navigationDestination(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in onPaired(host, fingerprint) }
}
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
}
#endif
#if !os(tvOS)
.toolbar {
#if os(iOS)
// Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton }
if showsArrangeMenu {
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
}
ToolbarItem(placement: .topBarTrailing) { addHostButton }
#else
if showsArrangeMenu {
ToolbarItem(placement: .primaryAction) {
arrangeMenu
.help("Sort and group the host list")
}
}
ToolbarItem(placement: .primaryAction) {
addHostButton
.help("Add a host")
}
ToolbarItem {
SettingsLink {
Label("Settings", systemImage: "gearshape")
}
.help("Stream mode and settings")
}
#endif
}
#endif
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
#if os(tvOS) && canImport(SwiftUINavigationTransitions)
// The Settings-app slide for every push in this stack (top-level routes AND
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
// with just a hint of life, no visible overshoot ping-pong.
.customNavigationTransition(
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
#endif
#if !os(tvOS)
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.sheet(item: $editTarget) { host in
// Prefill the MAC from the live advert when the host hasn't stored one yet.
AddHostSheet(
existing: host,
suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [],
onSave: { store.update($0) })
}
#if os(iOS)
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
// two-column layout.
.sheet(isPresented: $showSettings) {
SettingsView()
.settingsSheetSizing()
}
#endif
#endif
}
// MARK: - Cards
/// One grid tile: a host's primary card, or one of its pinned host+profile cards. A pin is
/// presentation only — same record, same live status, so it is an extra ENTRY here rather than
/// a duplicated host (which would fork pairing, WoL and renames — design §5.2a).
private struct HostGridEntry: Identifiable {
let host: StoredHost
let profile: StreamProfile?
var id: String { "\(host.id.uuidString)#\(profile?.id ?? "")" }
}
/// The grid's bands, ordered and divided per this device's preference (`HostArrangement`).
private var hostGroups: [HostGroup] {
HostArrangement.groups(
hosts: store.hosts, catalog: profiles.catalog,
online: Set(store.hosts.filter(isOnline).map(\.id)),
sort: HostSort(rawValue: sortRaw) ?? .added,
grouping: HostGrouping(rawValue: groupingRaw) ?? .none)
}
/// A band's hosts, each immediately followed by its pinned cards — so a pin reads as belonging
/// to its host rather than as a stray tile somewhere in the grid.
private func entries(in group: HostGroup) -> [HostGridEntry] {
group.hosts.flatMap { host in
[HostGridEntry(host: host, profile: nil)]
+ profiles.pinned(for: host).map { HostGridEntry(host: host, profile: $0) }
}
}
/// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never
/// advertises). One definition, used by the cards and by the Status grouping alike.
private func isOnline(_ host: StoredHost) -> Bool {
discovery.advertises(host) || store.probedOnline.contains(host.id)
}
private func groupHeader(_ title: String, accent: String?) -> some View {
HStack(spacing: 7) {
Circle()
.fill(Color(hex: accent ?? "") ?? Color.secondary.opacity(0.5))
.frame(width: 7, height: 7)
.accessibilityHidden(true) // the title says it
Text(title)
.font(.geist(13, .semibold, relativeTo: .subheadline))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.tracking(0.6)
}
.padding(.top, 4)
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
return HostCardView(
host: host,
isOnline: isOnline(host),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
// The accent ring marks the most recent HOST; pinned cards stay quiet so the grid
// doesn't grow several "most recent" bars for one machine.
isMostRecent: pinned == nil && host.id == mostRecentHostID,
isBusy: model.isBusy,
onConnect: { connect(host, selection) },
onPair: { if !model.isBusy { pairingTarget = host } },
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
onForget: { store.forgetIdentity(host) },
onRemove: { store.remove(host) },
onBrowseLibrary: onBrowseLibrary,
onWake: { wake(host) },
onEdit: { editTarget = host },
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
/// The profile affordances every host card carries (§5.2/§5.2a).
private func profileMenu(for host: StoredHost) -> HostProfileMenu {
HostProfileMenu(
profiles: profiles.profiles,
boundID: host.profileID,
pinnedIDs: host.pinnedProfileIDs ?? [],
connectWith: { selection in connect(host, selection) },
setDefault: { store.setProfile(host.id, profileID: $0) },
togglePin: { id in
let pinned = (host.pinnedProfileIDs ?? []).contains(id)
store.setPinned(host.id, profileID: id, pinned: !pinned)
},
copyLink: { profileID in
LinkClipboard.copy(DeepLink.forHost(host, profile: profileID).urlString)
})
}
private var discoveredSection: some View {
VStack(alignment: .leading, spacing: 10) {
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
.font(.geist(15, .semibold, relativeTo: .headline))
.foregroundStyle(.secondary)
.padding(.horizontal)
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(discoveredUnsaved) { discovered in
DiscoveredCardView(
discovered: discovered, isBusy: model.isBusy,
onConnect: { connectDiscovered(discovered) })
}
}
}
.padding([.horizontal, .bottom])
.padding(.top, store.hosts.isEmpty ? 0 : 8)
// Same reachability contract as the saved grid above — see its focusSection comment.
#if os(tvOS)
.focusSection()
#endif
}
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
/// launcher so both screens classify hosts identically).
private var discoveredUnsaved: [DiscoveredHost] {
discovery.unsaved(among: store.hosts)
}
/// The host of the most recent session — its card carries the accent ring.
private var mostRecentHostID: UUID? {
store.hosts
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
.max { $0.1 < $1.1 }?.0
}
// MARK: - Chrome
private var emptyState: some View {
ContentUnavailableView {
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
} description: {
Text("Add your punktfunk host with the + button.")
} actions: {
Button("Add Host") { showAddHost = true }
.glassProminentButtonStyle()
#if os(iOS)
.controlSize(.large)
#endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
}
}
private var addHostButton: some View {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
}
#if !os(tvOS)
/// One host has no order and nothing to divide, so the control stays out of the way until
/// there is a list to arrange.
private var showsArrangeMenu: Bool { store.hosts.count > 1 }
/// Sort and group, as two inline pickers in one menu — both are about the same list, and
/// splitting them across two toolbar items would say otherwise.
private var arrangeMenu: some View {
Menu {
Picker("Sort By", selection: $sortRaw) {
ForEach(HostSort.allCases) { option in
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
}
}
.pickerStyle(.inline)
Divider()
Picker("Group By", selection: $groupingRaw) {
ForEach(HostGrouping.allCases) { option in
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
}
}
.pickerStyle(.inline)
} label: {
Label("Sort and Group", systemImage: "line.3.horizontal.decrease.circle")
}
}
#endif
#if !os(macOS)
private var settingsButton: some View {
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
}
#endif
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the columns FILL
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
/// column on iPhone portrait, 34 generous cards on iPad.
private var gridColumns: [GridItem] {
// Wider than before: the monogram card is a horizontal module (tile + address line), so
// it needs room for a monospaced "IP:port" without truncating.
#if os(macOS)
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#elseif os(tvOS)
// Tracks CardMetrics' 10-foot sizes — at the 30pt name a 320pt column truncates
// every hostname longer than ~10 characters.
[GridItem(.adaptive(minimum: 460), spacing: 48)]
#else
[GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif
}
private var gridSpacing: CGFloat {
#if os(tvOS)
48 // the focused card scales up — give it room instead of overlapping siblings
#else
16
#endif
}
}