ζ-2 native: SwiftData-Snapshot-Cache + DailyQuoteWidget

- SnapshotModels.swift: CachedQuote (slug-unique, themes/regions
  als CSV), SnapshotMeta (singleton mit lastSyncedAt + totalCount),
  SnapshotContainer.make() mit App-Group-Store-URL (Fallback auf
  App-Container für Dev ohne Apple-Dev-Portal-Setup)
- SnapshotSync (actor) mit injectable Loader für Tests: refresh /
  refreshIfStale / tryRefresh (fail-soft). Re-konsolidiert beim Pull
  (Update + Insert + Delete entzogene Slugs). 24h-Staleness-Default.
- DailyQuoteWidget: Hash-of-Day-Picker aus SwiftData, drei Sizes,
  Mitternacht-Refresh-Policy, Placeholder bei leerem Store. Widget-
  Target zieht SnapshotModels.swift mit (project.yml).
- ZitareNativeApp triggert SnapshotSync.tryRefresh() bei Launch +
  WidgetCenter.reloadAllTimelines() danach.
- AppConfig.snapshotURL = webBaseURL/index-min.json (Web-Endpoint
  noch nicht live, fail-soft).
- DeepLinkRouter Substring-Guard fix (`/t` statt `/t/` im
  Prefix-Array, sonst greift hasPrefix("/t//") nicht).
- 22 Tests grün (6 AppConfig + 11 DeepLinkRouter + 3 SnapshotSync +
  1 UI + 1 Widget-Compile-Smoke), swiftlint 0 violations in 22 Files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till 2026-05-14 13:16:05 +02:00
parent dd10f85cca
commit c89d48c6f6
10 changed files with 597 additions and 45 deletions

View file

@ -65,7 +65,7 @@ in [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks
| Phase | Ziel | Erfolg | Status | | Phase | Ziel | Erfolg | Status |
|---|---|---|---| |---|---|---|---|
| ζ-0 | Setup, leerer Build, Login | iOS-Build ✅, Tests ✅, Healthz Live ✅ | ✅ (Mac + Git-Push offen) | | ζ-0 | Setup, leerer Build, Login | iOS-Build ✅, Tests ✅, Healthz Live ✅ | ✅ (Mac + Git-Push offen) |
| ζ-1 | WebShellView + Universal-Links | zitare.mana.how rendert im WebView, UL-Routing implementiert | 🚧 (90%) | | ζ-1 | WebShellView + Universal-Links | WebView rendert, UL-Routing testbar, Web-Header ausgeblendet | ✅ |
| ζ-2 | Snapshot-Sync + DailyQuoteWidget | Widget auf realem Gerät zeigt Zitat | ⏳ | | ζ-2 | Snapshot-Sync + DailyQuoteWidget | Widget auf realem Gerät zeigt Zitat | ⏳ |
| ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ | | ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ |
| ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ | | ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ |

View file

@ -26,7 +26,7 @@ enum DeepLinkRouter {
/// `true` wenn der Pfad in den Erkunden-Tab gehört. Sonst Lesen-Tab. /// `true` wenn der Pfad in den Erkunden-Tab gehört. Sonst Lesen-Tab.
static func isExplorePath(_ path: String) -> Bool { static func isExplorePath(_ path: String) -> Bool {
let prefixes = ["/explore", "/region", "/thema", "/rolle", "/epoche", "/sprache", "/search", "/t/"] let prefixes = ["/explore", "/region", "/thema", "/rolle", "/epoche", "/sprache", "/search", "/t"]
return prefixes.contains { path == $0 || path.hasPrefix($0 + "/") } return prefixes.contains { path == $0 || path.hasPrefix($0 + "/") }
} }

View file

@ -1,14 +1,25 @@
import ManaCore import ManaCore
import SwiftData
import SwiftUI import SwiftUI
import WidgetKit
@main @main
struct ZitareNativeApp: App { struct ZitareNativeApp: App {
@State private var auth: AuthClient @State private var auth: AuthClient
private let snapshotContainer: ModelContainer?
init() { init() {
let auth = AuthClient(config: AppConfig.manaAppConfig) let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap() auth.bootstrap()
_auth = State(initialValue: auth) _auth = State(initialValue: auth)
do {
snapshotContainer = try SnapshotContainer.make()
} catch {
Log.snapshot.error(
"SnapshotContainer init fehlgeschlagen: \(String(describing: error), privacy: .public)"
)
snapshotContainer = nil
}
Log.app.info( Log.app.info(
"Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)" "Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)"
) )
@ -19,6 +30,18 @@ struct ZitareNativeApp: App {
RootView() RootView()
.environment(auth) .environment(auth)
.tint(ZitareTheme.primary) .tint(ZitareTheme.primary)
.task {
await refreshSnapshot()
} }
} }
}
private func refreshSnapshot() async {
guard let container = snapshotContainer else { return }
let sync = SnapshotSync(container: container)
await sync.tryRefresh()
// Widget-Timeline neu erstellen lassen, sodass der nächste
// Render-Pass den frischen Snapshot sieht.
WidgetCenter.shared.reloadAllTimelines()
}
} }

View file

@ -34,4 +34,11 @@ enum AppConfig {
/// App-Group für Daten-Sharing zwischen App Widget ShareExt. /// App-Group für Daten-Sharing zwischen App Widget ShareExt.
static let appGroup = "group.ev.mana.zitare" static let appGroup = "group.ev.mana.zitare"
/// Endpoint für den Korpus-Snapshot (Phase ζ-2). Heute noch nicht
/// als statische HTTP-Datei publiziert Aufgabe im Web-Repo:
/// `apps/zitare/static/index-min.json` aus dem Snapshot-Job
/// zusätzlich rauskopieren. Bis dahin schlägt der Pull mit 404
/// fehl und `SnapshotSync.tryRefresh()` macht fail-soft no-op.
static let snapshotURL = webBaseURL.appendingPathComponent("index-min.json")
} }

View file

@ -0,0 +1,112 @@
import Foundation
import SwiftData
/// SwiftData-Model für ein Quote aus dem `index-min.json`-Snapshot.
/// Lebt in einem App-Group-`ModelContainer`, damit Widget +
/// ShareExtension lesend zugreifen können.
@Model
final class CachedQuote {
/// Stabiler Slug, dient als Primary-Key (eindeutig via Unique-Index).
@Attribute(.unique) var slug: String
var text: String
var authorSlug: String?
var authorName: String?
var language: String?
/// Komma-getrennte Slug-Liste (SwiftData mag arrays of String mäßig).
var themesCSV: String
var regionsCSV: String
/// Wann zuletzt aus dem Snapshot importiert.
var importedAt: Date
init(
slug: String,
text: String,
authorSlug: String?,
authorName: String?,
language: String?,
themes: [String],
regions: [String],
importedAt: Date = Date()
) {
self.slug = slug
self.text = text
self.authorSlug = authorSlug
self.authorName = authorName
self.language = language
themesCSV = themes.joined(separator: ",")
regionsCSV = regions.joined(separator: ",")
self.importedAt = importedAt
}
var themes: [String] {
themesCSV.isEmpty ? [] : themesCSV.split(separator: ",").map(String.init)
}
var regions: [String] {
regionsCSV.isEmpty ? [] : regionsCSV.split(separator: ",").map(String.init)
}
}
/// SwiftData-Marker für wann zuletzt erfolgreich gesynct" + Total-
/// Count. Einzeiliger Singleton ein einziges Objekt im Container.
@Model
final class SnapshotMeta {
@Attribute(.unique) var id: String
var generatedAt: Date?
var lastSyncedAt: Date?
var totalCount: Int
init(
id: String = "default",
generatedAt: Date? = nil,
lastSyncedAt: Date? = nil,
totalCount: Int = 0
) {
self.id = id
self.generatedAt = generatedAt
self.lastSyncedAt = lastSyncedAt
self.totalCount = totalCount
}
}
/// Schema-Helper für ModelContainer-Setup. App + Widget + ShareExt
/// rufen `SnapshotContainer.make()` auf und teilen so denselben
/// SwiftData-Store unter der App-Group.
///
/// Der App-Group-Identifier ist hier hartkodiert, damit das File ohne
/// AppConfig-Dependency auch von der Widget-Extension konsumierbar
/// ist (Widget-Target kompiliert nur Source-File-Whitelist aus
/// `project.yml`).
enum SnapshotContainer {
static let appGroup = "group.ev.mana.zitare"
/// Default-URL für den Store: in der App-Group, damit alle drei
/// Extensions ihn sehen. Fällt zurück auf den App-Container, wenn
/// die App-Group (noch) nicht aktiviert ist siehe Apple-Dev-
/// Portal-Blocker in `PLAN.md`.
static func defaultStoreURL(appGroup: String = appGroup) -> URL {
let fm = FileManager.default
if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
return groupURL.appendingPathComponent("snapshot.store")
}
// Fallback: App-eigener Documents-Container (Widget sieht das
// dann nicht wird in Release mit funktionierender App-Group
// automatisch übersprungen).
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
return docs.appendingPathComponent("snapshot.store")
}
/// Baut einen `ModelContainer` für die `CachedQuote` + `SnapshotMeta`-
/// Models. `inMemory: true` für Unit-Tests.
static func make(inMemory: Bool = false) throws -> ModelContainer {
let schema = Schema([CachedQuote.self, SnapshotMeta.self])
let storeURL = defaultStoreURL()
let config = if inMemory {
ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
} else {
ModelConfiguration("snapshot", schema: schema, url: storeURL)
}
return try ModelContainer(for: schema, configurations: [config])
}
}

View file

@ -0,0 +1,204 @@
import Foundation
import SwiftData
/// Liest `index-min.json` aus dem Web-Surface und persistiert die Quotes
/// in den App-Group-`ModelContainer`, damit Widget + Spotlight + native
/// Surfaces ohne Live-API-Call rendern können.
///
/// **Vertrag mit dem Web** (siehe
/// `zitare/apps/api/src/jobs/snapshot.ts`):
///
/// ```json
/// {
/// "generatedAt": "ISO8601",
/// "count": N,
/// "quotes": [
/// { "slug": "...", "authorSlug": "...", "authorName": "...",
/// "language": "de", "themeSlugs": [...], "regionSlugs": [...] }
/// ]
/// }
/// ```
///
/// **Endpoint** ist heute noch nicht live (`/index-min.json` wird nicht
/// als HTTP-Route exposed). Die Sync-Klasse nimmt eine URL als Argument,
/// damit Tests gegen einen lokalen Bundle-Resource laufen und Release
/// auf den finalen URL umgestellt werden kann sobald
/// `zitare/apps/zitare/src/routes/(read)/index-min.json/+server.ts`
/// (oder die Static-File-Copy aus snapshot.ts) gebaut ist.
/// Loader-Abstraktion: ProductionLoader nutzt URLSession, Tests können
/// einen Inline-Loader injecten und Fixtures liefern, ohne dass
/// Foundation tatsächlich übers Netz geht.
typealias SnapshotLoader = @Sendable () async throws -> Data
actor SnapshotSync {
private let loader: SnapshotLoader
private let container: ModelContainer
private let url: URL
/// Default-Staleness, ab der ein Refresh sinnvoll wird (24h).
private let staleAfter: TimeInterval
init(
container: ModelContainer,
url: URL = AppConfig.snapshotURL,
loader: SnapshotLoader? = nil,
staleAfter: TimeInterval = 24 * 60 * 60
) {
self.container = container
self.url = url
if let loader {
self.loader = loader
} else {
self.loader = Self.urlSessionLoader(url: url)
}
self.staleAfter = staleAfter
}
private static func urlSessionLoader(url: URL) -> SnapshotLoader {
{
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw SnapshotSyncError.badResponse((response as? HTTPURLResponse)?.statusCode ?? -1)
}
return data
}
}
/// Convenience-Init für Tests, die Bytes direkt liefern.
static func forTesting(
container: ModelContainer,
url: URL = URL(string: "https://test.local/snap")!,
staleAfter: TimeInterval = 0,
data: @escaping @Sendable () -> Data
) -> SnapshotSync {
SnapshotSync(
container: container,
url: url,
loader: { data() },
staleAfter: staleAfter
)
}
/// Holt + persistiert nur, wenn der lokale Stand älter als
/// `staleAfter` ist (oder gar nicht existiert). No-op sonst.
func refreshIfStale() async throws {
if try await !isStale() {
Log.snapshot.info("Snapshot ist frisch, kein Refresh")
return
}
try await refresh()
}
/// Erzwingt einen Pull, ignoriert Staleness.
func refresh() async throws {
let snapshotURL = url
Log.snapshot.info("Snapshot-Pull: \(snapshotURL.absoluteString, privacy: .public)")
let data = try await loader()
let payload = try JSONDecoder.snapshot.decode(SnapshotPayload.self, from: data)
try await persist(payload)
Log.snapshot.info("Snapshot persistiert: \(payload.count) Quotes")
}
/// Wird vom Widget direkt aufgerufen, wenn der Timeline-Provider
/// einen Refresh braucht. Kein Throw Fail-soft, das Widget zeigt
/// in dem Fall den letzten lokal vorhandenen Stand.
func tryRefresh() async {
do { try await refreshIfStale() } catch {
Log.snapshot.warning("Snapshot-Refresh fehlgeschlagen: \(String(describing: error), privacy: .public)")
}
}
private func isStale() async throws -> Bool {
let context = ModelContext(container)
let metas = try context.fetch(FetchDescriptor<SnapshotMeta>())
guard let last = metas.first?.lastSyncedAt else { return true }
return Date().timeIntervalSince(last) > staleAfter
}
private func persist(_ payload: SnapshotPayload) async throws {
let context = ModelContext(container)
// Existing-Slugs als Set für O(1) lookup.
let existing = try context.fetch(FetchDescriptor<CachedQuote>())
var byslug: [String: CachedQuote] = [:]
for quote in existing {
byslug[quote.slug] = quote
}
var keepSlugs = Set<String>()
let importedAt = Date()
for quote in payload.quotes {
keepSlugs.insert(quote.slug)
if let model = byslug[quote.slug] {
model.text = quote.text
model.authorSlug = quote.authorSlug
model.authorName = quote.authorName
model.language = quote.language
model.themesCSV = (quote.themeSlugs ?? []).joined(separator: ",")
model.regionsCSV = (quote.regionSlugs ?? []).joined(separator: ",")
model.importedAt = importedAt
} else {
let model = CachedQuote(
slug: quote.slug,
text: quote.text,
authorSlug: quote.authorSlug,
authorName: quote.authorName,
language: quote.language,
themes: quote.themeSlugs ?? [],
regions: quote.regionSlugs ?? [],
importedAt: importedAt
)
context.insert(model)
}
}
// Quotes, die der Server zurückgezogen hat, lokal löschen.
for (slug, model) in byslug where !keepSlugs.contains(slug) {
context.delete(model)
}
// Meta upserten.
let metas = try context.fetch(FetchDescriptor<SnapshotMeta>())
let meta = metas.first ?? SnapshotMeta()
if metas.isEmpty { context.insert(meta) }
meta.generatedAt = SnapshotDate.parse(payload.generatedAt)
meta.lastSyncedAt = importedAt
meta.totalCount = payload.quotes.count
try context.save()
}
}
// MARK: - DTOs
struct SnapshotPayload: Decodable {
let generatedAt: String
let count: Int
let quotes: [SnapshotQuote]
}
struct SnapshotQuote: Decodable {
let slug: String
let text: String
let authorSlug: String?
let authorName: String?
let language: String?
let themeSlugs: [String]?
let regionSlugs: [String]?
}
enum SnapshotSyncError: Error {
case badResponse(Int)
}
// MARK: - Decoders
extension JSONDecoder {
static let snapshot: JSONDecoder = .init()
}
/// Wrapper, der `ISO8601DateFormatter` thread-sicher kapselt. Apple's
/// Formatter ist nicht `Sendable`; wir bauen pro Call einen frischen.
enum SnapshotDate {
static func parse(_ raw: String) -> Date? {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = f.date(from: raw) { return date }
f.formatOptions = [.withInternetDateTime]
return f.date(from: raw)
}
}

View file

@ -20,8 +20,13 @@ final class AppConfigTests: XCTestCase {
XCTAssertEqual(AppConfig.apiBaseURL.absoluteString, "https://zitare-api.mana.how") XCTAssertEqual(AppConfig.apiBaseURL.absoluteString, "https://zitare-api.mana.how")
} }
func test_webBaseURL_isPublicSurface() { func test_webBaseURL_currentDefault() {
XCTAssertEqual(AppConfig.webBaseURL.absoluteString, "https://zitare.com") // Übergang: zitare.com hat noch keinen DNS-Record (Cloudflare-Zone-
// Onboarding offen), deshalb fällt webBaseURL aktuell auf
// appBaseURL zurück. Nach Cloudflare-Cut wird das wieder
// publicWebURL Test dann anpassen.
XCTAssertEqual(AppConfig.webBaseURL.absoluteString, "https://zitare.mana.how")
XCTAssertEqual(AppConfig.publicWebURL.absoluteString, "https://zitare.com")
} }
func test_appBaseURL_isManaHowSurface() { func test_appBaseURL_isManaHowSurface() {

View file

@ -0,0 +1,104 @@
import SwiftData
import XCTest
@testable import ZitareNative
@MainActor
final class SnapshotSyncTests: XCTestCase {
/// Erster Pull: 2 Quotes, beide werden persistiert.
func test_persistsInitialPayload() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync = SnapshotSync.forTesting(container: container) { Self.firstRun }
try await sync.refresh()
let context = ModelContext(container)
let quotes = try context.fetch(FetchDescriptor<CachedQuote>())
XCTAssertEqual(quotes.count, 2)
let bySlug = Dictionary(uniqueKeysWithValues: quotes.map { ($0.slug, $0) })
XCTAssertEqual(bySlug["spitteler-x"]?.authorName, "Carl Spitteler")
XCTAssertEqual(bySlug["keller-x"]?.regions, ["zuerich", "schweiz"])
let metas = try context.fetch(FetchDescriptor<SnapshotMeta>())
XCTAssertEqual(metas.count, 1)
XCTAssertEqual(metas.first?.totalCount, 2)
}
/// Zweiter Pull (gleicher Container): keller-x zurückgezogen,
/// spitteler-x text geändert, neuere-x dazu Update + Delete +
/// Insert wie erwartet.
func test_reconcilesOnSecondPull() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync1 = SnapshotSync.forTesting(container: container) { Self.firstRun }
try await sync1.refresh()
let sync2 = SnapshotSync.forTesting(container: container) { Self.secondRun }
try await sync2.refresh()
let context = ModelContext(container)
let after = try context.fetch(FetchDescriptor<CachedQuote>())
let afterBySlug = Dictionary(uniqueKeysWithValues: after.map { ($0.slug, $0) })
XCTAssertEqual(after.count, 2)
XCTAssertEqual(afterBySlug["spitteler-x"]?.text, "A-updated")
XCTAssertNotNil(afterBySlug["neuere-x"])
XCTAssertNil(afterBySlug["keller-x"])
}
/// `refreshIfStale` macht kein Refresh, wenn lastSyncedAt frisch.
func test_freshSnapshotSkipsRefresh() async throws {
let container = try SnapshotContainer.make(inMemory: true)
let sync = SnapshotSync.forTesting(
container: container,
staleAfter: 3600
) { Self.smallPayload }
try await sync.refresh()
// Loader, der explosiv knallt refreshIfStale darf ihn nicht
// aufrufen, weil noch frisch.
let brokenSync = SnapshotSync(
container: container,
loader: { throw SnapshotSyncError.badResponse(-999) },
staleAfter: 3600
)
try await brokenSync.refreshIfStale() // soll *nicht* werfen
}
// MARK: - Fixtures
nonisolated(unsafe) static let firstRun: Data = .init(#"""
{
"generatedAt": "2026-05-08T20:48:48.795Z",
"count": 2,
"quotes": [
{ "slug": "spitteler-x", "text": "A",
"authorSlug": "spitteler", "authorName": "Carl Spitteler",
"language": "de", "themeSlugs": ["lebenskunst"],
"regionSlugs": ["schweiz"] },
{ "slug": "keller-x", "text": "B",
"authorSlug": "keller", "authorName": "Gottfried Keller",
"language": "de", "themeSlugs": [],
"regionSlugs": ["zuerich","schweiz"] }
]
}
"""#.utf8)
nonisolated(unsafe) static let secondRun: Data = .init(#"""
{
"generatedAt": "2026-05-09T00:00:00.000Z",
"count": 2,
"quotes": [
{ "slug": "spitteler-x", "text": "A-updated",
"authorSlug": "spitteler", "authorName": "Carl Spitteler",
"language": "de", "themeSlugs": ["lebenskunst"],
"regionSlugs": ["schweiz"] },
{ "slug": "neuere-x", "text": "C",
"authorSlug": "neuere", "authorName": "Anon",
"language": "de", "themeSlugs": ["philosophie"],
"regionSlugs": [] }
]
}
"""#.utf8)
nonisolated(unsafe) static let smallPayload: Data = .init(#"""
{"generatedAt":"2026-05-01T00:00:00.000Z","count":1,
"quotes":[{"slug":"a","text":"A","authorSlug":null,
"authorName":null,"language":"de","themeSlugs":[],"regionSlugs":[]}]}
"""#.utf8)
}

View file

@ -1,33 +1,29 @@
import SwiftData
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// Phase ζ-2 Placeholder Widget-Bundle für die WidgetKit-Extension. /// Widget-Bundle für die ZitareWidget-Extension.
/// ///
/// Aufgaben in ζ-2: /// **Phase ζ-2:** liest aus dem App-Group-`ModelContainer`, der von
/// /// der App via `SnapshotSync` befüllt wird. Falls die App-Group im
/// - `DailyQuoteWidget`: deterministisches Zitat des Tages /// Apple-Developer-Portal noch nicht aktiviert ist oder die App noch
/// (`hash(date + userSeed) index in snapshot.quotes`). /// nie gelaufen ist, fällt das Widget auf einen Placeholder-Quote
/// - `RandomQuoteWidget`: bei jedem Timeline-Refresh ein neues /// zurück.
/// Zitat.
/// - Datenquelle: SwiftData unter App-Group `group.ev.mana.zitare`,
/// gefüllt vom `SnapshotSync` in der App.
/// - TimelineProvider mit 24h-Window für Daily, 30min für Random.
/// - Drei Sizes (Small/Medium/Large) plus Lock-Screen-Varianten
/// (Circular, Inline).
@main @main
struct ZitareWidgetBundle: WidgetBundle { struct ZitareWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
DailyQuotePlaceholderWidget() DailyQuoteWidget()
} }
} }
/// Phase ζ-2 Placeholder. Wird ersetzt durch echte Implementation. // MARK: - Daily-Quote-Widget
struct DailyQuotePlaceholderWidget: Widget {
let kind = "DailyQuotePlaceholder" struct DailyQuoteWidget: Widget {
let kind = "DailyQuoteWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: PlaceholderProvider()) { entry in StaticConfiguration(kind: kind, provider: DailyQuoteProvider()) { entry in
PlaceholderEntryView(entry: entry) DailyQuoteEntryView(entry: entry)
} }
.configurationDisplayName("Zitat des Tages") .configurationDisplayName("Zitat des Tages")
.description("Ein kuratiertes Zitat von Zitare — täglich neu.") .description("Ein kuratiertes Zitat von Zitare — täglich neu.")
@ -35,51 +31,149 @@ struct DailyQuotePlaceholderWidget: Widget {
} }
} }
struct PlaceholderEntry: TimelineEntry { // MARK: - Entry + Provider
struct DailyQuoteEntry: TimelineEntry {
let date: Date let date: Date
let quote: String let text: String
let author: String let author: String
let isPlaceholder: Bool
} }
struct PlaceholderProvider: TimelineProvider { struct DailyQuoteProvider: TimelineProvider {
func placeholder(in _: Context) -> PlaceholderEntry { static let placeholder = DailyQuoteEntry(
PlaceholderEntry(date: Date(), quote: "Schweizer bleiben.", author: "Carl Spitteler") date: Date(),
text: "Wir wollen Schweizer bleiben.",
author: "Carl Spitteler",
isPlaceholder: true
)
func placeholder(in _: Context) -> DailyQuoteEntry {
Self.placeholder
} }
func getSnapshot( func getSnapshot(
in context: Context, in _: Context,
completion: @escaping (PlaceholderEntry) -> Void completion: @escaping (DailyQuoteEntry) -> Void
) { ) {
completion(placeholder(in: context)) completion(currentEntry(for: Date()))
} }
func getTimeline( func getTimeline(
in context: Context, in _: Context,
completion: @escaping (Timeline<PlaceholderEntry>) -> Void completion: @escaping (Timeline<DailyQuoteEntry>) -> Void
) { ) {
let entry = placeholder(in: context) let now = Date()
let nextRefresh = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() let entry = currentEntry(for: now)
completion(Timeline(entries: [entry], policy: .after(nextRefresh))) // Nächster Refresh genau um Mitternacht dort dreht der
// hash(date)-Picker, also wechselt das Zitat.
let calendar = Calendar.current
let nextMidnight = calendar.nextDate(
after: now,
matching: DateComponents(hour: 0, minute: 0),
matchingPolicy: .nextTime
) ?? now.addingTimeInterval(24 * 60 * 60)
completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
}
private func currentEntry(for date: Date) -> DailyQuoteEntry {
guard let pick = pickQuote(for: date) else {
return DailyQuoteProvider.placeholder
}
return DailyQuoteEntry(
date: date,
text: pick.text,
author: pick.author,
isPlaceholder: false
)
}
private func pickQuote(for date: Date) -> (text: String, author: String)? {
do {
let container = try SnapshotContainer.make()
let context = ModelContext(container)
let quotes = try context.fetch(FetchDescriptor<CachedQuote>())
guard !quotes.isEmpty else { return nil }
let sorted = quotes.sorted { $0.slug < $1.slug }
let dayKey = Calendar.current.dateComponents([.year, .month, .day], from: date)
let seed = (dayKey.year ?? 0) * 10000 + (dayKey.month ?? 0) * 100 + (dayKey.day ?? 0)
let index = abs(seed) % sorted.count
let pick = sorted[index]
return (pick.text, pick.authorName ?? pick.authorSlug ?? "Unbekannt")
} catch {
return nil
}
} }
} }
struct PlaceholderEntryView: View { // MARK: - View
let entry: PlaceholderEntry
struct DailyQuoteEntryView: View {
let entry: DailyQuoteEntry
@Environment(\.widgetFamily) private var family
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 10) {
Text(verbatim: "\u{201E}\(entry.quote)\u{201C}") Text(verbatim: "\u{201E}\(entry.text)\u{201C}")
.font(.callout) .font(font(for: family))
.fontWeight(.medium) .fontWeight(.medium)
.lineLimit(4) .foregroundStyle(Color(red: 0.22, green: 0.16, blue: 0.12))
Spacer(minLength: 4) .lineLimit(lineLimit(for: family))
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Text(verbatim: "\(entry.author)") Text(verbatim: "\(entry.author)")
.font(.caption) .font(.caption)
.foregroundStyle(Color(red: 0.49, green: 0.35, blue: 0.24))
if entry.isPlaceholder {
Text("Öffne Zitare einmal, um dein Tageszitat zu laden.")
.font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding() }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(family == .systemSmall ? 10 : 14)
.containerBackground(for: .widget) { .containerBackground(for: .widget) {
Color(red: 0.95, green: 0.93, blue: 0.88) // Paper-Variant-Background. Statisch Tokens aus ManaTokens
// wären schöner, aber Widget-Targets können das Package heute
// nicht so einfach mitziehen (eigene Compile-Pipeline).
LinearGradient(
colors: [
Color(red: 0.95, green: 0.93, blue: 0.88),
Color(red: 0.93, green: 0.91, blue: 0.85)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
private func font(for family: WidgetFamily) -> Font {
switch family {
case .systemSmall: .footnote
case .systemMedium: .callout
case .systemLarge: .title3
default: .callout
}
}
private func lineLimit(for family: WidgetFamily) -> Int {
switch family {
case .systemSmall: 4
case .systemMedium: 4
case .systemLarge: 8
default: 4
} }
} }
} }
#Preview(as: .systemMedium) {
DailyQuoteWidget()
} timeline: {
DailyQuoteProvider.placeholder
DailyQuoteEntry(
date: Date(),
text: "Wer fertig ist, dem ist nichts recht zu machen; ein Werdender wird immer dankbar sein.",
author: "Johann Wolfgang von Goethe",
isPlaceholder: false
)
}

View file

@ -144,6 +144,9 @@ targets:
excludes: excludes:
- "Resources/Info.plist" - "Resources/Info.plist"
- "Resources/ZitareWidgetExtension.entitlements" - "Resources/ZitareWidgetExtension.entitlements"
# Geteilter Snapshot-Code (SwiftData-Models + Container).
# Widget liest aus dem App-Group-Store, den die App befüllt.
- path: Sources/Core/Snapshot/SnapshotModels.swift
info: info:
path: Widgets/ZitareWidget/Resources/Info.plist path: Widgets/ZitareWidget/Resources/Info.plist
properties: properties: