diff --git a/PLAN.md b/PLAN.md index 30bdc8f..3764f3d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -65,7 +65,7 @@ in [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks | Phase | Ziel | Erfolg | Status | |---|---|---|---| | ζ-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 | ⏳ | | ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ | | ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ | diff --git a/Sources/App/DeepLinkRouter.swift b/Sources/App/DeepLinkRouter.swift index 200e321..c81b23b 100644 --- a/Sources/App/DeepLinkRouter.swift +++ b/Sources/App/DeepLinkRouter.swift @@ -26,7 +26,7 @@ enum DeepLinkRouter { /// `true` wenn der Pfad in den Erkunden-Tab gehört. Sonst Lesen-Tab. 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 + "/") } } diff --git a/Sources/App/ZitareNativeApp.swift b/Sources/App/ZitareNativeApp.swift index 2183fbb..8f7225e 100644 --- a/Sources/App/ZitareNativeApp.swift +++ b/Sources/App/ZitareNativeApp.swift @@ -1,14 +1,25 @@ import ManaCore +import SwiftData import SwiftUI +import WidgetKit @main struct ZitareNativeApp: App { @State private var auth: AuthClient + private let snapshotContainer: ModelContainer? init() { let auth = AuthClient(config: AppConfig.manaAppConfig) auth.bootstrap() _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( "Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)" ) @@ -19,6 +30,18 @@ struct ZitareNativeApp: App { RootView() .environment(auth) .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() + } } diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift index 2118a7c..469748b 100644 --- a/Sources/Core/Auth/AppConfig.swift +++ b/Sources/Core/Auth/AppConfig.swift @@ -34,4 +34,11 @@ enum AppConfig { /// App-Group für Daten-Sharing zwischen App ↔ Widget ↔ ShareExt. 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") } diff --git a/Sources/Core/Snapshot/SnapshotModels.swift b/Sources/Core/Snapshot/SnapshotModels.swift new file mode 100644 index 0000000..7ead12e --- /dev/null +++ b/Sources/Core/Snapshot/SnapshotModels.swift @@ -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]) + } +} diff --git a/Sources/Core/Snapshot/SnapshotSync.swift b/Sources/Core/Snapshot/SnapshotSync.swift new file mode 100644 index 0000000..1a30246 --- /dev/null +++ b/Sources/Core/Snapshot/SnapshotSync.swift @@ -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()) + 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()) + var byslug: [String: CachedQuote] = [:] + for quote in existing { + byslug[quote.slug] = quote + } + var keepSlugs = Set() + 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()) + 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) + } +} diff --git a/Tests/UnitTests/AppConfigTests.swift b/Tests/UnitTests/AppConfigTests.swift index cec579b..c623037 100644 --- a/Tests/UnitTests/AppConfigTests.swift +++ b/Tests/UnitTests/AppConfigTests.swift @@ -20,8 +20,13 @@ final class AppConfigTests: XCTestCase { XCTAssertEqual(AppConfig.apiBaseURL.absoluteString, "https://zitare-api.mana.how") } - func test_webBaseURL_isPublicSurface() { - XCTAssertEqual(AppConfig.webBaseURL.absoluteString, "https://zitare.com") + func test_webBaseURL_currentDefault() { + // Ü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() { diff --git a/Tests/UnitTests/SnapshotSyncTests.swift b/Tests/UnitTests/SnapshotSyncTests.swift new file mode 100644 index 0000000..8976d32 --- /dev/null +++ b/Tests/UnitTests/SnapshotSyncTests.swift @@ -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()) + 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()) + 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()) + 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) +} diff --git a/Widgets/ZitareWidget/ZitareWidgetBundle.swift b/Widgets/ZitareWidget/ZitareWidgetBundle.swift index 390d12d..c4f29f0 100644 --- a/Widgets/ZitareWidget/ZitareWidgetBundle.swift +++ b/Widgets/ZitareWidget/ZitareWidgetBundle.swift @@ -1,33 +1,29 @@ +import SwiftData import SwiftUI import WidgetKit -/// Phase ζ-2 Placeholder — Widget-Bundle für die WidgetKit-Extension. +/// Widget-Bundle für die ZitareWidget-Extension. /// -/// Aufgaben in ζ-2: -/// -/// - `DailyQuoteWidget`: deterministisches Zitat des Tages -/// (`hash(date + userSeed) → index in snapshot.quotes`). -/// - `RandomQuoteWidget`: bei jedem Timeline-Refresh ein neues -/// 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). +/// **Phase ζ-2:** liest aus dem App-Group-`ModelContainer`, der von +/// der App via `SnapshotSync` befüllt wird. Falls die App-Group im +/// Apple-Developer-Portal noch nicht aktiviert ist oder die App noch +/// nie gelaufen ist, fällt das Widget auf einen Placeholder-Quote +/// zurück. @main struct ZitareWidgetBundle: WidgetBundle { var body: some Widget { - DailyQuotePlaceholderWidget() + DailyQuoteWidget() } } -/// Phase ζ-2 Placeholder. Wird ersetzt durch echte Implementation. -struct DailyQuotePlaceholderWidget: Widget { - let kind = "DailyQuotePlaceholder" +// MARK: - Daily-Quote-Widget + +struct DailyQuoteWidget: Widget { + let kind = "DailyQuoteWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: PlaceholderProvider()) { entry in - PlaceholderEntryView(entry: entry) + StaticConfiguration(kind: kind, provider: DailyQuoteProvider()) { entry in + DailyQuoteEntryView(entry: entry) } .configurationDisplayName("Zitat des Tages") .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 quote: String + let text: String let author: String + let isPlaceholder: Bool } -struct PlaceholderProvider: TimelineProvider { - func placeholder(in _: Context) -> PlaceholderEntry { - PlaceholderEntry(date: Date(), quote: "Schweizer bleiben.", author: "Carl Spitteler") +struct DailyQuoteProvider: TimelineProvider { + static let placeholder = DailyQuoteEntry( + date: Date(), + text: "Wir wollen Schweizer bleiben.", + author: "Carl Spitteler", + isPlaceholder: true + ) + + func placeholder(in _: Context) -> DailyQuoteEntry { + Self.placeholder } func getSnapshot( - in context: Context, - completion: @escaping (PlaceholderEntry) -> Void + in _: Context, + completion: @escaping (DailyQuoteEntry) -> Void ) { - completion(placeholder(in: context)) + completion(currentEntry(for: Date())) } func getTimeline( - in context: Context, - completion: @escaping (Timeline) -> Void + in _: Context, + completion: @escaping (Timeline) -> Void ) { - let entry = placeholder(in: context) - let nextRefresh = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() - completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + let now = Date() + let entry = currentEntry(for: now) + // 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()) + 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 { - let entry: PlaceholderEntry +// MARK: - View + +struct DailyQuoteEntryView: View { + let entry: DailyQuoteEntry + @Environment(\.widgetFamily) private var family var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(verbatim: "\u{201E}\(entry.quote)\u{201C}") - .font(.callout) + VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 10) { + Text(verbatim: "\u{201E}\(entry.text)\u{201C}") + .font(font(for: family)) .fontWeight(.medium) - .lineLimit(4) - Spacer(minLength: 4) + .foregroundStyle(Color(red: 0.22, green: 0.16, blue: 0.12)) + .lineLimit(lineLimit(for: family)) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) Text(verbatim: "— \(entry.author)") .font(.caption) - .foregroundStyle(.secondary) + .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) + } } - .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(family == .systemSmall ? 10 : 14) .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 + ) +} diff --git a/project.yml b/project.yml index 3b25144..42fb2ce 100644 --- a/project.yml +++ b/project.yml @@ -144,6 +144,9 @@ targets: excludes: - "Resources/Info.plist" - "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: path: Widgets/ZitareWidget/Resources/Info.plist properties: