Home-Screen-Widgets für Pageta — drei Größen, jede liest aus dem
App-Group-Snapshot den die App nach jedem Login/onAppear refreshed.
Widget-Process macht keine Netzwerk-Calls (kein JWT-Refresh in einer
Extension mit 30s-Limit). Pattern aus herbatrium-native + seepuls-
native: App fetched + persistiert, Widget rendert read-only.
- Sources/Core/WidgetSnapshot.swift: WidgetSnapshot + WidgetFeedItem +
SavedCounts. Mirror von server-side /api/v1/snapshot/widget.
WidgetSnapshotStore mit UserDefaults(suiteName: group.ev.mana.pageta).
- Sources/Core/Sync/SnapshotSync.swift: ruft fetchWidgetSnapshot,
persistiert, triggert WidgetCenter.reloadAllTimelines(). Soft-Fail
bei Network — letzter Snapshot bleibt sichtbar.
- PagetaAPI.fetchWidgetSnapshot() neu.
- PagetaNativeApp.onAppear: zusätzlich zu flushPendingShares jetzt
syncWidgetSnapshot.
- Widgets/PagetaWidget/:
- PagetaWidgetBundle: @main, FeedWidget mit kind "ev.mana.pageta.feed",
3 system-Families, paper-Cream Container-Background.
- FeedProvider: getTimeline alle 1h Fallback, App überschreibt via
WidgetCenter. Preview-Snapshot mit 3 realistischen Items.
- SmallFeedView: Top-1 Item mit Site-Caption + Serif-Title, widgetURL
pageta://article/{id}.
- MediumFeedView: Top-3 mit Header + ungelesen-Count + Color-Bar.
- LargeFeedView: Top-3 als Link (pageta://) + CountsRow (unread/lese
als Pills oben rechts).
- WidgetTheme.swift: paper-Brand-Tokens dupliziert (Widget-Bundle
kann App-Modul nicht importieren).
- EmptyState: Fallback wenn kein Snapshot da.
- project.yml: PagetaWidgetExtension-Target (Bundle
ev.mana.pageta.widget, App-Group, embed in main App). Shared
WidgetSnapshot.swift in beiden Targets — DTO-Drift wird so
unmöglich.
2 neue Tests (WidgetSnapshot-Wire-Format-Decode + Store-Round-Trip),
25/25 grün, iOS+macOS Build green.
Apple-Dev-Portal-Pre für Real-Run:
- Bundle ev.mana.pageta.widget registrieren
- App-Group group.ev.mana.pageta für alle 3 Targets aktivieren
Phase-2 jetzt 4/4 (π-5..π-7+π-6) — nur noch π-8 Spotlight + π-9
macOS-Polish + π-10 ASC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.2 KiB
Swift
107 lines
3.2 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
/// Widget-Bundle für Pageta. Ein Widget ("Pageta Feed"), drei
|
|
/// system-Familien (Small/Medium/Large). Accessory-Familien (Lock-
|
|
/// Screen) deferred — Reader-Use-Case bevorzugt größere Surfaces.
|
|
@main
|
|
struct PagetaWidgetBundle: WidgetBundle {
|
|
var body: some Widget {
|
|
FeedWidget()
|
|
}
|
|
}
|
|
|
|
struct FeedWidget: Widget {
|
|
let kind: String = "ev.mana.pageta.feed"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: FeedProvider()) { entry in
|
|
FeedEntryView(entry: entry)
|
|
.containerBackground(WidgetTheme.background, for: .widget)
|
|
}
|
|
.configurationDisplayName("Pageta Feed")
|
|
.description("Was es heute zu lesen gibt — Top-3 aus dem Pool.")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
}
|
|
}
|
|
|
|
struct FeedEntry: TimelineEntry {
|
|
let date: Date
|
|
let snapshot: WidgetSnapshot?
|
|
}
|
|
|
|
/// Timeline-Provider liest aus dem App-Group-UserDefaults. Die App
|
|
/// synchronisiert (SnapshotSync) bei Login + onAppear und triggert
|
|
/// `WidgetCenter.shared.reloadAllTimelines()` — Provider macht keine
|
|
/// eigenen Netzwerk-Calls.
|
|
struct FeedProvider: TimelineProvider {
|
|
private let store = WidgetSnapshotStore()
|
|
|
|
func placeholder(in context: Context) -> FeedEntry {
|
|
FeedEntry(date: Date(), snapshot: Self.previewSnapshot())
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (FeedEntry) -> Void) {
|
|
let snap = store.load() ?? (context.isPreview ? Self.previewSnapshot() : nil)
|
|
completion(FeedEntry(date: Date(), snapshot: snap))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<FeedEntry>) -> Void) {
|
|
let snap = store.load()
|
|
let entry = FeedEntry(date: Date(), snapshot: snap)
|
|
// Stündlich neu rendern als Fallback; App-Refresh überschreibt
|
|
// das via WidgetCenter, sobald frische Daten reinkommen.
|
|
let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
|
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
|
}
|
|
|
|
private static func previewSnapshot() -> WidgetSnapshot {
|
|
WidgetSnapshot(
|
|
feed: [
|
|
WidgetFeedItem(
|
|
id: "preview-1",
|
|
title: "Bundesnetzagentur senkt Übertragungs-Entgelte 2026",
|
|
siteName: "Heise",
|
|
topic: "tech",
|
|
imageUrl: nil,
|
|
publishedAt: nil,
|
|
url: "https://example.com/a"
|
|
),
|
|
WidgetFeedItem(
|
|
id: "preview-2",
|
|
title: "Neue Studie zu Konzentration und Notizenmachen",
|
|
siteName: "Spektrum",
|
|
topic: "wissenschaft",
|
|
imageUrl: nil,
|
|
publishedAt: nil,
|
|
url: "https://example.com/b"
|
|
),
|
|
WidgetFeedItem(
|
|
id: "preview-3",
|
|
title: "Schweizer Verein gründen — Schritt für Schritt",
|
|
siteName: "NZZ",
|
|
topic: "weltgeschehen",
|
|
imageUrl: nil,
|
|
publishedAt: nil,
|
|
url: "https://example.com/c"
|
|
),
|
|
],
|
|
saved: WidgetSnapshot.SavedCounts(unread: 4, reading: 1, finished: 12, archived: 38),
|
|
lastSyncAt: ISO8601DateFormatter().string(from: Date())
|
|
)
|
|
}
|
|
}
|
|
|
|
struct FeedEntryView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: FeedEntry
|
|
|
|
var body: some View {
|
|
switch family {
|
|
case .systemSmall: SmallFeedView(entry: entry)
|
|
case .systemMedium: MediumFeedView(entry: entry)
|
|
case .systemLarge: LargeFeedView(entry: entry)
|
|
default: SmallFeedView(entry: entry)
|
|
}
|
|
}
|
|
}
|