From f250db96f4a7b2a580ec5a29597978bb5def2843 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 16:18:04 +0200 Subject: [PATCH] feat(apple/widgets): Xcode canvas previews for the Live Activity + Hosts widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every surface renders in the canvas without running the app or starting a real Activity: Lock Screen banner across all four session stages (streaming/backgrounded/reconnecting/ended), Dynamic Island expanded/compact/minimal, and the Hosts widget in all four families with sample hosts (plus the empty state one timeline click away). Sample state is fileprivate to the widget files — never in PunktfunkShared. Co-Authored-By: Claude Fable 5 --- .../apple/PunktfunkWidgets/HostsWidget.swift | 44 +++++++++++ .../SessionLiveActivity.swift | 76 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/clients/apple/PunktfunkWidgets/HostsWidget.swift b/clients/apple/PunktfunkWidgets/HostsWidget.swift index de02ea41..76752976 100644 --- a/clients/apple/PunktfunkWidgets/HostsWidget.swift +++ b/clients/apple/PunktfunkWidgets/HostsWidget.swift @@ -184,3 +184,47 @@ private struct EmptyHostView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +// MARK: - Previews (Xcode canvas) +// +// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩). The widget +// `#Preview(as:widget:timeline:)` form feeds sample entries directly — the App-Group store is +// never read, so the canvas works without a paired device or saved hosts. The small preview's +// second entry shows the empty state one timeline click away. + +private let previewHosts: [StoredHost] = [ + StoredHost( + name: "Studio", address: "192.168.1.20", + lastConnected: .now.addingTimeInterval(-40 * 60)), + StoredHost( + name: "Living Room", address: "192.168.1.30", + lastConnected: .now.addingTimeInterval(-26 * 3600)), + StoredHost( + name: "Workstation", address: "10.0.0.5", + lastConnected: .now.addingTimeInterval(-6 * 86400)), +] + +#Preview("Small", as: .systemSmall) { + HostsWidget() +} timeline: { + HostsEntry(date: .now, hosts: previewHosts) + HostsEntry(date: .now, hosts: []) +} + +#Preview("Medium", as: .systemMedium) { + HostsWidget() +} timeline: { + HostsEntry(date: .now, hosts: previewHosts) +} + +#Preview("Lock Screen circular", as: .accessoryCircular) { + HostsWidget() +} timeline: { + HostsEntry(date: .now, hosts: previewHosts) +} + +#Preview("Lock Screen rectangular", as: .accessoryRectangular) { + HostsWidget() +} timeline: { + HostsEntry(date: .now, hosts: previewHosts) +} diff --git a/clients/apple/PunktfunkWidgets/SessionLiveActivity.swift b/clients/apple/PunktfunkWidgets/SessionLiveActivity.swift index a51fd613..a6e1ed39 100644 --- a/clients/apple/PunktfunkWidgets/SessionLiveActivity.swift +++ b/clients/apple/PunktfunkWidgets/SessionLiveActivity.swift @@ -138,3 +138,79 @@ private struct EndButton: View { .buttonStyle(.bordered) } } + +// MARK: - Previews (Xcode canvas) +// +// Select the PunktfunkWidgetsExtension scheme and open the canvas (⌥⌘↩) — the activity +// `#Preview(as:using:)` form renders every surface WITHOUT running the app or starting a real +// Activity: `.content` is the Lock Screen banner, `.dynamicIsland(.expanded/.compact/.minimal)` +// the island states (canvas device must be a Dynamic Island phone for those). Each listed +// content state becomes a frame in the canvas timeline strip, so all four session stages are one +// click apart. Sample state lives here (fileprivate), never in PunktfunkShared. + +extension PunktfunkSessionAttributes { + fileprivate static var preview: PunktfunkSessionAttributes { + PunktfunkSessionAttributes(hostID: UUID(), hostName: "Studio", launchTitle: "Hades II") + } +} + +extension PunktfunkSessionAttributes.ContentState { + fileprivate static var streaming: Self { + .init( + stage: .streaming, startedAt: .now.addingTimeInterval(-754), + modeLine: "2752×2064 @120 · HEVC · HDR", latencyMs: 8, mbps: 84.2) + } + fileprivate static var backgrounded: Self { + .init( + stage: .background, startedAt: .now.addingTimeInterval(-1975), + modeLine: "2752×2064 @120 · HEVC · HDR", + backgroundDeadline: .now.addingTimeInterval(9 * 60)) + } + fileprivate static var reconnecting: Self { + .init( + stage: .reconnecting, startedAt: .now.addingTimeInterval(-754), + modeLine: "2752×2064 @120 · HEVC · HDR") + } + fileprivate static var ended: Self { + .init( + stage: .ending, startedAt: .now.addingTimeInterval(-3541), + modeLine: "2752×2064 @120 · HEVC · HDR") + } +} + +#Preview("Lock Screen", as: .content, using: PunktfunkSessionAttributes.preview) { + PunktfunkSessionLiveActivity() +} contentStates: { + PunktfunkSessionAttributes.ContentState.streaming + PunktfunkSessionAttributes.ContentState.backgrounded + PunktfunkSessionAttributes.ContentState.reconnecting + PunktfunkSessionAttributes.ContentState.ended +} + +#Preview( + "Island expanded", as: .dynamicIsland(.expanded), + using: PunktfunkSessionAttributes.preview +) { + PunktfunkSessionLiveActivity() +} contentStates: { + PunktfunkSessionAttributes.ContentState.streaming + PunktfunkSessionAttributes.ContentState.backgrounded +} + +#Preview( + "Island compact", as: .dynamicIsland(.compact), + using: PunktfunkSessionAttributes.preview +) { + PunktfunkSessionLiveActivity() +} contentStates: { + PunktfunkSessionAttributes.ContentState.streaming +} + +#Preview( + "Island minimal", as: .dynamicIsland(.minimal), + using: PunktfunkSessionAttributes.preview +) { + PunktfunkSessionLiveActivity() +} contentStates: { + PunktfunkSessionAttributes.ContentState.streaming +}