ζ-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 |
|
| 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 | ⏳ |
|
||||||
|
|
|
||||||
|
|
@ -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 + "/") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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")
|
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() {
|
||||||
|
|
|
||||||
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 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue