ζ-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:
parent
dd10f85cca
commit
c89d48c6f6
10 changed files with 597 additions and 45 deletions
2
PLAN.md
2
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 | ⏳ |
|
||||
|
|
|
|||
|
|
@ -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 + "/") }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
112
Sources/Core/Snapshot/SnapshotModels.swift
Normal file
112
Sources/Core/Snapshot/SnapshotModels.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
204
Sources/Core/Snapshot/SnapshotSync.swift
Normal file
204
Sources/Core/Snapshot/SnapshotSync.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
104
Tests/UnitTests/SnapshotSyncTests.swift
Normal file
104
Tests/UnitTests/SnapshotSyncTests.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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<PlaceholderEntry>) -> Void
|
||||
in _: Context,
|
||||
completion: @escaping (Timeline<DailyQuoteEntry>) -> 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<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 {
|
||||
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(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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue