feat(offline): text-only Cleanup + ζ-1 Offline-Sync

Drei zusammenhängende Blöcke in einem Commit (Files überlappen sich
zwischen den Themen — sauberer Split nicht ohne Friktion möglich):

1. Wordeck-Text-Only-Cleanup
   Image-Occlusion + Audio-Front-Code raus. Server ist seit Migration
   0004_wordeck_text_only.sql text-only (in Prod waren 0 Karten der
   Typen, 0 Media-Files). Native-Code war Build-11-Altlast.
   - Gelöscht: MediaCache, MediaEnvironment, RemoteImage,
     AudioPlayerButton, MaskEditorView, CardEditorMediaFields,
     CardEditorPayload, Media.swift
   - CardType-Enum auf 5 Werte: basic / basic-reverse / cloze /
     typing / multiple-choice
   - media_refs aus Card, CardCreateBody, CardUpdateBody, call-sites
   - WordeckAPI.uploadMedia / .fetchMedia / .deleteMedia + Single-File-
     makeMultipartBody gestrichen
   - MarketplaceCardConverter ohne Media-Cases
   - CardRenderer ohne imageOcclusionView / audioFrontView

2. AI-Media-Mode raus
   /decks/from-image-Endpoint existiert serverseitig nicht (server
   registriert nur /decks/generate für Text-Prompts). Native-Aufrufe
   wären 404 — toter Code.
   - aiMedia-Case aus DeckEditorView.CreateMode, ModePicker auf
     3 Optionen (Leer / KI / CSV)
   - AIMediaFormSections, MediaFileRow, mediaPickers, thumbnail,
     ingestPhotoItems, handlePDFImport raus
   - generateDeckFromMedia + makeFromImageMultipartBody raus
   - GenerationMediaFile-Struct + PhotosUI-Import + PlatformImage-
     typealias raus
   - NSPhotoLibraryUsageDescription aus project.yml entfernt (es gibt
     keinen Photo-Library-Zugriff mehr)
   - maxMediaFiles/maxImageBytes/maxPDFBytes + inferImageMimeType +
     imageExtension aus DeckEditorHelpers raus

3. ζ-1 Offline-Sync
   Konzept in docs/OFFLINE_SYNC.md. Server-authoritative-FSRS bleibt —
   kein lokales FSRS, nur Snapshot-Modell.
   - Neue SwiftData-Models: CachedCard + CachedDueReview, beide mit
     userId/deckId-Indizes
   - ModelContainer um die zwei Models erweitert (additive Migration,
     sollte automatisch laufen — vor TestFlight verifizieren)
   - DueReview bekommt programmatischen init(review:card:) für die
     Cache-Rekonstruktion
   - DeckListStore.refresh() zieht Cards + Due-Reviews pro Deck
     parallel in einer TaskGroup; applyToCache in drei Helpers
     gesplittet (applyDecks / applyCards / applyDueReviews)
   - Karten: Upsert mit Orphan-Cleanup
   - Due-Reviews: voll ersetzt pro Refresh (Server-`due`-Zeiten
     ändern sich, Merge wäre falsch)
   - StudySession.start() fällt bei Netz-Fehler auf
     CachedDueReview-Snapshot zurück, setzt isOfflineSession-Flag
   - StudySessionView zeigt offline-Banner und am Ende der Session
     einen Hinweis „Weitere Karten erst nach Verbindung verfügbar"
   - AccountView.wipeLocalCache(): DSGVO-Wipe vor signOut() und nach
     deleteAccount → CachedDeck + CachedCard + CachedDueReview +
     PendingGrade werden gelöscht

Plus: Keychain-Test in WordeckNativeTests.swift fix — erwartete
"ev.mana.wordeck", muss seit Cross-App-SSO-Commit 19fee75
ManaSharedKeychainGroup nutzen. Auf Konstant-Reference umgestellt,
damit's nicht wieder driftet.

Verifikation:
- xcodebuild iOS-Simulator: BUILD SUCCEEDED
- swiftlint --strict: 0 violations in 68 files
- swiftformat: clean
- 37/37 Tests grün (inkl. fix-Keychain-Test)
- macOS-Build scheitert an pre-existing .topBarTrailing in
  StudySessionView (iOS-only API seit 2026-05-13, nicht durch
  diesen Commit verursacht)

Pflicht-Verifikation vor TestFlight (in PLAN.md verewigt):
- SwiftData-Migration auf Bestandsbuilder
- Offline-Endurance (50+ Karten Flugmodus)
- Logout-Wipe mit Account-Switch
- Cross-Check Web ↔ Native nach Offline-Grade

Diff: 35 files, +869 / -1622, netto ~−750 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-18 22:06:41 +02:00
parent 19fee75c47
commit 9527240bcc
36 changed files with 728 additions and 1565 deletions

27
PLAN.md
View file

@ -234,6 +234,33 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe.
| β-5 | ✅ 2026-05-13 | Marketplace (Explore/Browse/Subscribe) + TabBar + Universal-Link-Handler (AASA server-side pending) |
| β-6 | ✅ 2026-05-13 | Keyboard-Shortcuts + Daily-Reminders + WidgetKit (Siri/Share deferred auf β-7) |
| β-7 | ✅ 2026-05-13 | App-Icon-Platzhalter + Siri-Shortcut + Share-Extension + Release-Checklist (externe Apple-Schritte siehe docs/RELEASE_CHECKLIST.md) |
| **Wordeck-Cleanup** | ✅ 2026-05-18 | Image-Occlusion + Audio-Front-Code raus (Server seit Migration `0004_wordeck_text_only.sql` text-only). Gelöscht: MediaCache, MediaEnvironment, RemoteImage, AudioPlayerButton, MaskEditorView, CardEditorMediaFields, CardEditorPayload, Media.swift. CardType-Enum auf 5 Werte reduziert, `media_refs` aus Card+CardCreateBody+CardUpdateBody+CardCreate-Call-Sites raus, `WordeckAPI.uploadMedia/.fetchMedia/.deleteMedia` raus, `makeMultipartBody` (Single-File) raus. |
| **AI-Media-raus** | ✅ 2026-05-18 | `/decks/from-image`-Endpoint existiert serverseitig gar nicht — gesamten Native-Code rausgenommen: `aiMedia`-Case + Sub-Sections in `DeckEditorView`, `generateDeckFromMedia` + `makeFromImageMultipartBody`, `GenerationMediaFile`-Struct, `PhotosUI`-Import, `PlatformImage`-typealias, `NSPhotoLibraryUsageDescription` aus `project.yml`. ModePicker auf 3 Optionen (Leer/KI/CSV). Auch Test fix: `WordeckNativeTests` nutzt jetzt `ManaSharedKeychainGroup` statt String-Literal. 37/37 Tests grün. |
| **ζ-1 (Offline-Sync)** | ✅ 2026-05-18 | `CachedCard` + `CachedDueReview` SwiftData-Models, `DeckListStore.refresh()` zieht Cards+Due-Reviews pro Deck parallel (TaskGroup) und ersetzt den Snapshot atomar. `StudySession.start()` fällt bei Netz-Fehler auf den Cache zurück, setzt `isOfflineSession`-Flag für UX-Banner. `DueReview` bekommt programmatischen `init(review:card:)` für die Rekonstruktion. `ModelContainer` um die zwei Models erweitert (additive Migration, sollte automatisch durchlaufen). DSGVO-Logout-Wipe in `AccountView`: vor jedem `signOut()` und nach `deleteAccount` werden `CachedDeck`+`CachedCard`+`CachedDueReview`+`PendingGrade` aus dem Context gelöscht. iOS-Build grün, swiftlint --strict clean, 37/37 Tests passen. |
## Geplant: ζ-2..4
Konzept in [`docs/OFFLINE_SYNC.md`](docs/OFFLINE_SYNC.md).
| Phase | Inhalt | Aufwand |
|---|---|---|
| ζ-2 | Distractor-Pool für MC-Karten (pro MC-Karte 10 Distractors mit-cachen) | 0,5 Tag |
| ζ-3 | `SettingsView`-Cache-Footprint anzeigen + manueller Cache-Clear | 0,5 Tag |
| ζ-4 (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag |
Server-authoritative-FSRS bleibt — kein lokales FSRS, nur Snapshot.
## Pflicht-Verifikation für ζ-1 (Endurance auf realem Gerät)
- [ ] **SwiftData-Migration:** alte App von TestFlight installieren, dann
über Xcode mit ζ-1-Build überschreiben — Cache muss durchlaufen, kein
Crash. (Additive Schema-Change sollte automatisch gehen, aber unverifiziert.)
- [ ] **Offline-Study:** 50+ Karten lernen mit Flugmodus, App killen,
neu öffnen, weiter lernen — alle Grades landen am Server nach Reconnect.
- [ ] **Logout-Wipe:** Abmelden, anderer Account anmelden — keine Karten/Decks
des Vorgängers in der DeckListView sichtbar.
- [ ] **Cross-Check mit Web:** Karte offline gegradet → Web zeigt identischen
Review-State nach Reload.
## Nächste Schritte: TestFlight + App-Store

View file

@ -8,11 +8,15 @@ struct WordeckNativeApp: App {
let container: ModelContainer
@State private var auth: AuthClient
@State private var authGate: ManaAuthGate
private let mediaCache: MediaCache
init() {
do {
container = try ModelContainer(for: CachedDeck.self, PendingGrade.self)
container = try ModelContainer(
for: CachedDeck.self,
CachedCard.self,
CachedDueReview.self,
PendingGrade.self
)
} catch {
fatalError("Failed to init ModelContainer: \(error)")
}
@ -20,7 +24,6 @@ struct WordeckNativeApp: App {
auth.bootstrap()
_auth = State(initialValue: auth)
_authGate = State(initialValue: ManaAuthGate(auth: auth))
mediaCache = MediaCache(api: WordeckAPI(auth: auth))
Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)")
}
@ -29,7 +32,6 @@ struct WordeckNativeApp: App {
RootView()
.environment(auth)
.environment(authGate)
.environment(\.mediaCache, mediaCache)
.tint(WordeckTheme.primary)
}
.modelContainer(container)

View file

@ -1,7 +1,7 @@
import Foundation
import ManaCore
/// AI-Deck-Generierung + Multipart-Helpers ausgelagert aus `WordeckAPI`,
/// AI-Deck-Generierung aus Text-Prompt ausgelagert aus `WordeckAPI`,
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
extension WordeckAPI {
/// `POST /api/v1/decks/generate` KI generiert Deck aus Prompt.
@ -17,98 +17,4 @@ extension WordeckAPI {
try ensureOK(http, data: responseData)
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
}
/// `POST /api/v1/decks/from-image` Vision-LLM generiert Deck aus
/// Bildern und/oder PDFs (max 5 Files, 10 MiB pro Bild, 30 MiB pro PDF)
/// und optional einer URL für Zusatz-Kontext. Rate-Limit 10/min.
/// Multipart-Body mit `file`-Parts (wiederholt) + Text-Felder.
func generateDeckFromMedia(
files: [GenerationMediaFile],
language: GenerationLanguage,
count: Int,
url: String?
) async throws -> DeckGenerateResponse {
let boundary = "wordeck-native-\(UUID().uuidString)"
let body = makeFromImageMultipartBody(
files: files,
language: language,
count: count,
url: url,
boundary: boundary
)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks/from-image",
method: "POST",
body: body,
contentType: "multipart/form-data; boundary=\(boundary)"
)
try ensureOK(http, data: responseData)
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
}
// MARK: - Multipart
/// Single-File-Multipart-Body für `/media/upload`.
func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(Data(header.utf8))
body.append(file)
body.append(Data(lineBreak.utf8))
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
return body
}
/// Multi-File-Multipart-Body für `/decks/from-image` mehrere Files
/// unter dem Form-Feld `file` (Server liest sie via `getAll('file')`)
/// plus optional `language`, `count`, `url` als Text-Felder.
func makeFromImageMultipartBody(
files: [GenerationMediaFile],
language: GenerationLanguage,
count: Int,
url: String?,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
func appendField(name: String, value: String) {
let part = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="\(name)"\(lineBreak)\(lineBreak)\
\(value)\(lineBreak)
"""
body.append(Data(part.utf8))
}
appendField(name: "language", value: language.rawValue)
appendField(name: "count", value: String(count))
if let url, !url.trimmingCharacters(in: .whitespaces).isEmpty {
appendField(name: "url", value: url)
}
for file in files {
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(file.filename)"\(lineBreak)\
Content-Type: \(file.mimeType)\(lineBreak)\(lineBreak)
"""
body.append(Data(header.utf8))
body.append(file.data)
body.append(Data(lineBreak.utf8))
}
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
return body
}
}

View file

@ -1,10 +1,9 @@
import Foundation
import ManaCore
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// Wordeck-API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Wordeck-Endpoints kennt. Marketplace-Moderation
/// + Self-Endpoints + AI-Generation sind in `WordeckAPI+Marketplace.swift`
/// und `WordeckAPI+Generation.swift` ausgelagert.
@ -148,44 +147,6 @@ actor WordeckAPI {
try ensureOK(http, data: data)
}
// MARK: - Media
/// `POST /api/v1/media/upload` Multipart-Upload. Max 25 MiB.
/// Erlaubte MIMEs: image/*, audio/*, video/*.
func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse {
let boundary = "wordeck-native-\(UUID().uuidString)"
let body = makeMultipartBody(
file: data,
filename: filename,
mimeType: mimeType,
boundary: boundary
)
let (response, http) = try await transport.request(
path: "/api/v1/media/upload",
method: "POST",
body: body,
contentType: "multipart/form-data; boundary=\(boundary)"
)
try ensureOK(http, data: response)
return try decoder.decode(MediaUploadResponse.self, from: response)
}
/// `GET /api/v1/media/:id` streamt das Media-File. Antwortet mit
/// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache.
func fetchMedia(id: String) async throws -> Data {
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
guard (200 ..< 300).contains(http.statusCode) else {
throw AuthError.serverError(status: http.statusCode, code: nil, message: "media fetch failed")
}
return data
}
/// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht
/// implementiert serverseitig; Stub bleibt für späteren Use.)
func deleteMedia(id _: String) async throws {
throw AuthError.serverError(status: 501, code: nil, message: "media delete not implemented on server")
}
// MARK: - Deck-Mutations
/// `POST /api/v1/decks` Deck anlegen.

View file

@ -1,14 +1,16 @@
import Foundation
/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto`
/// und `cards/packages/cards-domain/src/schemas/card.ts`.
/// Card-DTO. Wire-Format aus `wordeck/apps/api/src/lib/dto.ts:toCardDto`
/// und `wordeck/packages/wordeck-domain/src/schemas/card.ts`.
///
/// Seit Wordeck-Rebrand (2026-05-17) text-only kein `media_refs`
/// mehr im Schema.
struct Card: Codable, Identifiable, Hashable {
let id: String
let deckId: String
let userId: String
let type: CardType
let fields: [String: String]
let mediaRefs: [String]
let contentHash: String?
let createdAt: Date
let updatedAt: Date
@ -19,22 +21,18 @@ struct Card: Codable, Identifiable, Hashable {
case userId = "user_id"
case type
case fields
case mediaRefs = "media_refs"
case contentHash = "content_hash"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern
/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types
/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar.
/// Card-Type-Enum. Wordeck ist text-only (Rebrand 2026-05-17)
/// `image-occlusion` und `audio-front` sind aus dem Schema raus.
enum CardType: String, Codable, CaseIterable {
case basic
case basicReverse = "basic-reverse"
case cloze
case imageOcclusion = "image-occlusion"
case audioFront = "audio-front"
case typing
case multipleChoice = "multiple-choice"
}

View file

@ -8,31 +8,25 @@ import Foundation
/// - cloze: `text` (mit `{{cN::...}}`-Clustern)
/// - typing: `front`, `answer`
/// - multiple-choice: `front`, `answer`
/// - image-occlusion: `image_ref`, `mask_regions` (β-4)
/// - audio-front: `audio_ref`, `back` (β-4)
struct CardCreateBody: Encodable {
let deckId: String
let type: CardType
let fields: [String: String]
let mediaRefs: [String]?
enum CodingKeys: String, CodingKey {
case deckId = "deck_id"
case type
case fields
case mediaRefs = "media_refs"
}
}
/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs`
/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` ist änderbar
/// Type und deck_id sind immutable (Server-Schema).
struct CardUpdateBody: Encodable {
var fields: [String: String]?
var mediaRefs: [String]?
enum CodingKeys: String, CodingKey {
case fields
case mediaRefs = "media_refs"
}
}

View file

@ -1,7 +1,7 @@
import Foundation
/// Body für `POST /api/v1/decks/generate` AI-Text-Generierung.
/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`.
/// Aus `wordeck/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`.
struct DeckGenerateBody: Encodable {
let prompt: String
let language: GenerationLanguage
@ -22,35 +22,7 @@ enum GenerationLanguage: String, Codable, CaseIterable {
}
}
/// Eine hochzuladende Datei für `POST /api/v1/decks/from-image`.
/// Wird als multipart-`file`-Part gesendet.
struct GenerationMediaFile: Identifiable {
let id: UUID
let data: Data
let filename: String
let mimeType: String
init(id: UUID = UUID(), data: Data, filename: String, mimeType: String) {
self.id = id
self.data = data
self.filename = filename
self.mimeType = mimeType
}
/// `application/pdf` PDF-Dokument, sonst Bild.
var isPDF: Bool {
mimeType == "application/pdf"
}
/// Größen-Label für die UI ("3.2 MB").
var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
}
}
/// Response von beiden AI-Generate-Endpoints (`/decks/generate` und
/// `/decks/from-image`). Beide rufen serverseitig `insertGeneratedDeck`
/// und liefern dieselbe Shape.
/// Response von `/decks/generate`.
struct DeckGenerateResponse: Decodable {
let deck: Deck
let cardsCreated: Int

View file

@ -43,7 +43,7 @@ struct MarketplaceDeckInitBody: Encodable {
/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type-
/// Namen als bei privaten Karten der Server nutzt `'type-in'` statt
/// `'typing'` und `'audio'` statt `'audio-front'`.
/// `'typing'`.
struct MarketplacePublishCard: Encodable {
let type: String
let fields: [String: String]
@ -89,28 +89,19 @@ enum MarketplaceLicense: String, CaseIterable {
}
/// Konvertiert eine private `Card` in eine `MarketplacePublishCard`
/// mit dem korrekten Marketplace-Type und Feld-Mapping. Liefert `nil`,
/// wenn der Type im Marketplace nicht unterstützt wird (z.B. Image-
/// Occlusion und Audio-Front brauchen Media-Re-Uploads, das gibt es
/// im Marketplace-Publish-Flow heute nicht).
/// mit dem korrekten Marketplace-Type und Feld-Mapping.
enum MarketplaceCardConverter {
static func convert(_ card: Card) -> MarketplacePublishCard? {
switch card.type {
case .basic, .basicReverse, .cloze, .multipleChoice:
return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields)
case .typing:
// typing 'type-in' mit umgeschlüsselten Feldern.
let front = card.fields["front"] ?? ""
let answer = card.fields["answer"] ?? ""
return MarketplacePublishCard(
type: "type-in",
fields: ["question": front, "expected": answer]
)
case .imageOcclusion, .audioFront:
// Media-Refs zeigen auf user-private Media-IDs Marketplace-
// User können die nicht laden. Skip bis Server-seitig ein
// Media-Publish-Flow existiert.
return nil
}
}
}

View file

@ -1,105 +0,0 @@
import Foundation
/// Response von `POST /api/v1/media/upload`.
struct MediaUploadResponse: Decodable {
let id: String
let url: String
let mimeType: String
let kind: MediaKind
let sizeBytes: Int
let originalFilename: String?
enum CodingKeys: String, CodingKey {
case id
case url
case mimeType = "mime_type"
case kind
case sizeBytes = "size_bytes"
case originalFilename = "original_filename"
}
}
enum MediaKind: String, Codable {
case image
case audio
case video
case other
}
/// Image-Occlusion-Mask-Region.
/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`,
/// nicht ein Object Server-Schema-Constraint (`fields: Record<string,string>`).
struct MaskRegion: Codable, Hashable, Identifiable {
let id: String
let x: Double // 0..1 relativ
let y: Double
let w: Double
let h: Double
let label: String?
init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) {
self.id = id
self.x = x
self.y = y
self.w = w
self.h = h
self.label = label
}
}
/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String.
enum MaskRegions {
/// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`.
/// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID
/// (lexikographisch, gleich wie Server-Sortierung).
static func parse(_ json: String) -> [MaskRegion] {
let data = Data(json.utf8)
guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] }
return regions.sorted { $0.id < $1.id }
}
/// Sub-Index Region (Sortier-Reihenfolge).
static func region(for json: String, subIndex: Int) -> MaskRegion? {
let all = parse(json)
return all.indices.contains(subIndex) ? all[subIndex] : nil
}
/// Anzahl Regionen Anzahl Sub-Index-Reviews.
static func count(_ json: String) -> Int {
parse(json).count
}
/// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld.
static func encode(_ regions: [MaskRegion]) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
guard let data = try? encoder.encode(regions),
let json = String(bytes: data, encoding: .utf8)
else { return "[]" }
return json
}
}
extension CardFieldsBuilder {
/// `image-occlusion`-Fields: `image_ref` (media_id) +
/// `mask_regions` (stringified JSON-Array) + optional `note`.
static func imageOcclusion(
imageRef: String,
regions: [MaskRegion],
note: String? = nil
) -> [String: String] {
var fields: [String: String] = [
"image_ref": imageRef,
"mask_regions": MaskRegions.encode(regions)
]
if let note, !note.isEmpty {
fields["note"] = note
}
return fields
}
/// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text).
static func audioFront(audioRef: String, back: String) -> [String: String] {
["audio_ref": audioRef, "back": back]
}
}

View file

@ -79,6 +79,14 @@ struct DueReview: Codable, Hashable, Identifiable {
"\(review.cardId)-\(review.subIndex)"
}
/// Programmatischer Memberwise-Init fürs Rekonstruieren aus
/// `CachedDueReview` (offline-Fallback). Wird von Swift nicht
/// auto-synthesiert, weil der custom `init(from decoder:)` da ist.
init(review: Review, card: ReviewCard) {
self.review = review
self.card = card
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Flat-Decoding: Review-Felder + card-Objekt im selben JSON-Objekt

View file

@ -0,0 +1,43 @@
import Foundation
import SwiftData
/// Lokales Cache-Model für eine einzelne Karte. Wird beim
/// `DeckListStore.refresh` mitgezogen, damit die Liste der Karten
/// auch offline verfügbar ist. Server bleibt Wahrheit alle Edits
/// laufen über die API, der Cache wird nur beim Re-Fetch aktualisiert.
@Model
final class CachedCard {
@Attribute(.unique) var id: String
var deckId: String
var userId: String
var typeRaw: String
var fields: [String: String]
var contentHash: String?
var createdAt: Date
var updatedAt: Date
var lastFetchedAt: Date
init(card: Card) {
id = card.id
deckId = card.deckId
userId = card.userId
typeRaw = card.type.rawValue
fields = card.fields
contentHash = card.contentHash
createdAt = card.createdAt
updatedAt = card.updatedAt
lastFetchedAt = .now
}
func update(from card: Card) {
typeRaw = card.type.rawValue
fields = card.fields
contentHash = card.contentHash
updatedAt = card.updatedAt
lastFetchedAt = .now
}
var type: CardType? {
CardType(rawValue: typeRaw)
}
}

View file

@ -0,0 +1,88 @@
import Foundation
import SwiftData
/// Snapshot eines `DueReview` (Review + Card-Subset) zum Zeitpunkt des
/// letzten Sync. Wird verwendet, wenn `StudySession` keine Verbindung
/// zum Server bekommt der User lernt dann die Karten, die zum Sync-
/// Zeitpunkt fällig waren.
///
/// Server-authoritative-FSRS bleibt: die `due`/`stability`/ Werte
/// kommen vom Server, lokal wird nie gerechnet. Beim nächsten Sync
/// liefert der Server eine neue Due-Liste.
@Model
final class CachedDueReview {
/// Eindeutiger Schlüssel: `"<cardId>-<subIndex>"`. SwiftData braucht
/// einen primären Identifier pro Model, das natürliche compound-
/// Schlüssel auf cardId+subIndex.
@Attribute(.unique) var compoundId: String
var cardId: String
var subIndex: Int
var deckId: String
var userId: String
// Review-State (server-authoritative, hier nur Snapshot)
var due: Date
var stability: Double
var difficulty: Double
var elapsedDays: Double
var scheduledDays: Double
var learningSteps: Int
var reps: Int
var lapses: Int
var stateRaw: String
var lastReview: Date?
// Card-Snapshot (für offline-Rendering was die Study-View braucht)
var cardType: String
var cardFields: [String: String]
var snapshottedAt: Date
init(dueReview: DueReview, deckId: String, userId: String) {
compoundId = "\(dueReview.review.cardId)-\(dueReview.review.subIndex)"
cardId = dueReview.review.cardId
subIndex = dueReview.review.subIndex
self.deckId = deckId
self.userId = userId
due = dueReview.review.due
stability = dueReview.review.stability
difficulty = dueReview.review.difficulty
elapsedDays = dueReview.review.elapsedDays
scheduledDays = dueReview.review.scheduledDays
learningSteps = dueReview.review.learningSteps
reps = dueReview.review.reps
lapses = dueReview.review.lapses
stateRaw = dueReview.review.state.rawValue
lastReview = dueReview.review.lastReview
cardType = dueReview.card.type.rawValue
cardFields = dueReview.card.fields
snapshottedAt = .now
}
/// Rekonstruiert einen `DueReview` für die `StudySession`-Queue.
/// Gibt `nil` zurück, wenn der Type/State im Enum nicht mehr existiert
/// (z.B. nach Schema-Migration).
func toDueReview() -> DueReview? {
guard let state = ReviewState(rawValue: stateRaw),
let type = CardType(rawValue: cardType)
else { return nil }
let review = Review(
cardId: cardId,
subIndex: subIndex,
userId: userId,
due: due,
stability: stability,
difficulty: difficulty,
elapsedDays: elapsedDays,
scheduledDays: scheduledDays,
learningSteps: learningSteps,
reps: reps,
lapses: lapses,
state: state,
lastReview: lastReview
)
let card = ReviewCard(id: cardId, deckId: deckId, type: type, fields: cardFields)
return DueReview(review: review, card: card)
}
}

View file

@ -6,6 +6,10 @@ import WidgetKit
/// Orchestriert API + SwiftData-Cache für die Deck-Liste.
/// View bindet sich an `state` und `errorMessage`.
///
/// Seit ζ-1 (2026-05-18) zieht der Store auch Karten + Due-Reviews
/// pro Deck mit (offline-Read für die Study-View). Siehe
/// `docs/OFFLINE_SYNC.md`.
@MainActor
@Observable
final class DeckListStore {
@ -29,10 +33,9 @@ final class DeckListStore {
self.auth = auth
}
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
/// der Cache (offline-readable). Im Guest-Mode wird kein Server-Call
/// versucht der Cache (leer oder über Marketplace-Klone gefüllt)
/// wird so wie er ist gerendert.
/// Holt Decks + Karten + Due-Reviews vom Server, aktualisiert Cache.
/// Bei Netzfehler bleibt der Cache (offline-readable). Im Guest-Mode
/// wird kein Server-Call versucht.
func refresh() async {
guard case .signedIn = auth.status else {
state = .idle
@ -45,7 +48,8 @@ final class DeckListStore {
do {
let decks = try await api.listDecks()
try await applyToCache(decks: decks)
let perDeck = try await fetchPerDeckPayloads(decks: decks)
try await applyToCache(decks: decks, perDeck: perDeck)
updateWidgetSnapshot()
state = .loaded
Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server")
@ -60,58 +64,103 @@ final class DeckListStore {
}
}
private func applyToCache(decks remoteDecks: [Deck]) async throws {
let remoteIDs = Set(remoteDecks.map(\.id))
/// Snapshot pro Deck, geholt in einer parallelen TaskGroup.
private struct PerDeckPayload {
let cards: [Card]
let dueReviews: [DueReview]
}
// 1. Bestehende Cache-Entries finden
let descriptor = FetchDescriptor<CachedDeck>()
let cached = (try? context.fetch(descriptor)) ?? []
let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
// 2. Gelöschte Decks aus Cache entfernen
for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) {
context.delete(cachedDeck)
}
// 3. Counts parallel holen
let counts = await withTaskGroup(of: (String, Int, Int).self) { group in
for deck in remoteDecks {
private func fetchPerDeckPayloads(decks: [Deck]) async throws -> [String: PerDeckPayload] {
try await withThrowingTaskGroup(of: (String, PerDeckPayload).self) { group in
for deck in decks {
group.addTask { [api] in
async let cards = api.cardCount(deckId: deck.id)
async let due = api.dueCount(deckId: deck.id)
let cardCount = await (try? cards) ?? 0
let dueCount = await (try? due) ?? 0
return (deck.id, cardCount, dueCount)
async let cards = api.listCards(deckId: deck.id)
async let due = api.dueReviews(deckId: deck.id, limit: 500)
return try await (deck.id, PerDeckPayload(cards: cards, dueReviews: due))
}
}
var result: [String: (cardCount: Int, dueCount: Int)] = [:]
for await (id, c, d) in group {
result[id] = (c, d)
var result: [String: PerDeckPayload] = [:]
for try await (id, payload) in group {
result[id] = payload
}
return result
}
}
// 4. Neue/aktualisierte Decks einarbeiten
for deck in remoteDecks {
let counts = counts[deck.id] ?? (0, 0)
if let existing = cachedByID[deck.id] {
existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount)
} else {
let cachedDeck = CachedDeck(
deck: deck,
cardCount: counts.cardCount,
dueCount: counts.dueCount
)
context.insert(cachedDeck)
}
}
private func applyToCache(
decks remoteDecks: [Deck],
perDeck: [String: PerDeckPayload]
) async throws {
applyDecks(remoteDecks, perDeck: perDeck)
applyCards(remoteDecks, perDeck: perDeck)
applyDueReviews(remoteDecks, perDeck: perDeck)
try context.save()
}
private func applyDecks(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
let remoteIDs = Set(remoteDecks.map(\.id))
let cachedDecks = (try? context.fetch(FetchDescriptor<CachedDeck>())) ?? []
let cachedDeckByID = Dictionary(uniqueKeysWithValues: cachedDecks.map { ($0.id, $0) })
for cachedDeck in cachedDecks where !remoteIDs.contains(cachedDeck.id) {
context.delete(cachedDeck)
}
for deck in remoteDecks {
let cardCount = perDeck[deck.id]?.cards.count ?? 0
let dueCount = perDeck[deck.id]?.dueReviews.count ?? 0
if let existing = cachedDeckByID[deck.id] {
existing.update(from: deck, cardCount: cardCount, dueCount: dueCount)
} else {
context.insert(CachedDeck(deck: deck, cardCount: cardCount, dueCount: dueCount))
}
}
}
/// Karten: Upsert pro remoteDeck, Orphans (Karten von gelöschten
/// Decks oder serverseits gelöschte Karten) löschen.
private func applyCards(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
let allCachedCards = (try? context.fetch(FetchDescriptor<CachedCard>())) ?? []
let cachedCardByID = Dictionary(uniqueKeysWithValues: allCachedCards.map { ($0.id, $0) })
var remoteCardIDs: Set<String> = []
for deck in remoteDecks {
guard let cards = perDeck[deck.id]?.cards else { continue }
for card in cards {
remoteCardIDs.insert(card.id)
if let existing = cachedCardByID[card.id] {
existing.update(from: card)
} else {
context.insert(CachedCard(card: card))
}
}
}
for cachedCard in allCachedCards where !remoteCardIDs.contains(cachedCard.id) {
context.delete(cachedCard)
}
}
/// Due-Reviews: Snapshot überschreibt komplett. Server-`due`-Zeiten
/// können sich ändern, also kein Merge voll ersetzen.
private func applyDueReviews(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) {
let allCachedDues = (try? context.fetch(FetchDescriptor<CachedDueReview>())) ?? []
for cached in allCachedDues {
context.delete(cached)
}
for deck in remoteDecks {
guard let dues = perDeck[deck.id]?.dueReviews else { continue }
for due in dues {
context.insert(CachedDueReview(
dueReview: due,
deckId: deck.id,
userId: due.review.userId
))
}
}
}
/// Schreibt einen WidgetSnapshot in den shared App-Group-Container
/// und fordert WidgetKit auf, alle Widgets neu zu rendern. Wird nach
/// jedem erfolgreichen Refresh aufgerufen.
/// und fordert WidgetKit auf, alle Widgets neu zu rendern.
private func updateWidgetSnapshot() {
let descriptor = FetchDescriptor<CachedDeck>(
sortBy: [SortDescriptor(\.dueCount, order: .reverse)]

View file

@ -1,81 +0,0 @@
import Foundation
import ManaCore
/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden
/// einmal vom Server geladen und danach lokal serviert der Server
/// setzt `Cache-Control: private, immutable`, das honorieren wir hier.
///
/// LRU-Verdrängung mit Soft-Limit (Default 200 MB).
actor MediaCache {
private let root: URL
private let api: WordeckAPI
private let maxBytes: Int
init(api: WordeckAPI, maxBytes: Int = 200 * 1024 * 1024) {
self.api = api
self.maxBytes = maxBytes
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
root = caches.appendingPathComponent("cards-media", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
/// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls
/// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert.
func localURL(for mediaId: String) async throws -> URL {
let target = root.appendingPathComponent(mediaId)
if FileManager.default.fileExists(atPath: target.path) {
try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path)
return target
}
let data = try await api.fetchMedia(id: mediaId)
try data.write(to: target, options: .atomic)
try? await pruneIfNeeded()
return target
}
/// Direktes Lesen für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer).
func data(for mediaId: String) async throws -> Data {
try await Data(contentsOf: localURL(for: mediaId))
}
/// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen.
private struct CacheEntry {
let url: URL
let size: Int
let date: Date
}
private func pruneIfNeeded() async throws {
let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
guard let items = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: Array(resourceKeys)
) else { return }
let withMeta = items.compactMap { url -> CacheEntry? in
let values = try? url.resourceValues(forKeys: resourceKeys)
guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil }
return CacheEntry(url: url, size: size, date: date)
}
let totalBytes = withMeta.reduce(0) { $0 + $1.size }
guard totalBytes > maxBytes else { return }
let sortedOldestFirst = withMeta.sorted { $0.date < $1.date }
var remaining = totalBytes
for item in sortedOldestFirst {
if remaining <= maxBytes { break }
try? FileManager.default.removeItem(at: item.url)
remaining -= item.size
let name = item.url.lastPathComponent
let size = item.size
Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)")
}
}
/// Wipe für Sign-out o.ä.
func clear() {
try? FileManager.default.removeItem(at: root)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
}

View file

@ -1,5 +0,0 @@
import SwiftUI
extension EnvironmentValues {
@Entry var mediaCache: MediaCache?
}

View file

@ -1,10 +1,12 @@
import ManaAuthUI
import ManaCore
import SwiftData
import SwiftUI
struct AccountView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@Environment(\.modelContext) private var context
@State private var showChangeEmail = false
@State private var showChangePassword = false
@State private var showDeleteAccount = false
@ -46,7 +48,10 @@ struct AccountView: View {
.sheet(isPresented: $showDeleteAccount) {
ManaDeleteAccountView(
auth: auth,
onDone: { showDeleteAccount = false }
onDone: {
Task { await wipeLocalCache() }
showDeleteAccount = false
}
)
.manaBrand(WordeckBrand.manaBrand)
}
@ -101,7 +106,15 @@ struct AccountView: View {
// anonymen Modus nutzbar (lokale Decks, Marketplace
// browsen). Wer alles vergessen" will, nutzt
// Account löschen".
Task { await auth.signOut(keepGuestMode: true) }
//
// DSGVO: Cache (Karten + Due-Reviews + Decks +
// pending Grades) wird vor dem signOut gewipet, damit
// ein anderer User auf demselben Gerät keine Daten
// des Vorgängers sieht.
Task {
await wipeLocalCache()
await auth.signOut(keepGuestMode: true)
}
} label: {
Text("Abmelden")
.frame(maxWidth: .infinity)
@ -178,6 +191,18 @@ struct AccountView: View {
.padding(.top, 48)
}
/// Löscht alle lokal gecachten User-Daten: Decks, Karten, fällige
/// Reviews und die offline Grade-Queue. Wird vor jedem signOut und
/// vor Account-Löschung aufgerufen.
private func wipeLocalCache() async {
try? context.delete(model: CachedDeck.self)
try? context.delete(model: CachedCard.self)
try? context.delete(model: CachedDueReview.self)
try? context.delete(model: PendingGrade.self)
try? context.save()
Log.app.info("Local cache wiped (signOut / delete-account)")
}
private func rowLabel(_ title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)

View file

@ -431,12 +431,6 @@ private struct CardPreviewRow: View {
card.fields["front"] ?? ""
case .cloze:
card.fields["text"] ?? ""
case .imageOcclusion:
card.fields["note"]?.isEmpty == false
? card.fields["note"]!
: "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)"
case .audioFront:
card.fields["back"] ?? "Audio-Karte"
}
}
@ -447,8 +441,6 @@ private struct CardPreviewRow: View {
case .cloze: "text.append"
case .typing: "keyboard"
case .multipleChoice: "list.bullet"
case .imageOcclusion: "photo.on.rectangle.angled"
case .audioFront: "waveform"
}
}
@ -459,8 +451,6 @@ private struct CardPreviewRow: View {
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
case .imageOcclusion: "Bild-Verdeckung"
case .audioFront: "Audio"
}
}
}

View file

@ -42,41 +42,41 @@ struct DeckListView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: DeckRoute.self) { route in
switch route {
case let .study(deckId, deckName):
StudySessionView(deckId: deckId, deckName: deckName)
case let .detail(deckId):
DeckDetailView(deckId: deckId)
}
}
.navigationDestination(for: PendingShareRoute.self) { route in
PendingShareConsumeView(share: route.share, onDone: {
PendingShareStore.remove(id: route.share.id)
pendingShares = PendingShareStore.readAll()
})
}
.toolbar { toolbar }
.refreshable {
await store?.refresh()
}
.sheet(isPresented: $showCreate) {
NavigationStack {
DeckEditorView(mode: .create) { _ in
Task { await store?.refresh() }
.navigationDestination(for: DeckRoute.self) { route in
switch route {
case let .study(deckId, deckName):
StudySessionView(deckId: deckId, deckName: deckName)
case let .detail(deckId):
DeckDetailView(deckId: deckId)
}
}
}
.task {
if store == nil {
store = DeckListStore(auth: auth, context: context)
.navigationDestination(for: PendingShareRoute.self) { route in
PendingShareConsumeView(share: route.share, onDone: {
PendingShareStore.remove(id: route.share.id)
pendingShares = PendingShareStore.readAll()
})
}
.toolbar { toolbar }
.refreshable {
await store?.refresh()
}
.sheet(isPresented: $showCreate) {
NavigationStack {
DeckEditorView(mode: .create) { _ in
Task { await store?.refresh() }
}
}
}
.task {
if store == nil {
store = DeckListStore(auth: auth, context: context)
}
await store?.refresh()
pendingShares = PendingShareStore.readAll()
}
.onAppear {
pendingShares = PendingShareStore.readAll()
}
await store?.refresh()
pendingShares = PendingShareStore.readAll()
}
.onAppear {
pendingShares = PendingShareStore.readAll()
}
}
}

View file

@ -96,8 +96,7 @@ struct PendingShareConsumeView: View {
let body = CardCreateBody(
deckId: deckId,
type: .basic,
fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText),
mediaRefs: nil
fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText)
)
do {
_ = try await api.createCard(body)

View file

@ -34,8 +34,6 @@ struct CSVImportFormSections: View {
preview
} header: {
Text("Vorschau (\(rows.count) Karten)")
} footer: {
Text("Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads.")
}
}
}
@ -75,8 +73,6 @@ struct CSVImportFormSections: View {
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
case .imageOcclusion: "Bild-Verdeckung (übersprungen)"
case .audioFront: "Audio (übersprungen)"
}
}
}

View file

@ -1,173 +0,0 @@
import ManaCore
import PhotosUI
import SwiftUI
/// Bild + Masken-Editor + Hinweis-Feld + Status für `image-occlusion`-
/// Cards. Owned-State: `imagePickerItem` (PhotosPicker-Bridge). Alles
/// andere lebt im Parent als `@State` und kommt hier als `@Binding` an.
///
/// Beim Mount im Edit-Modus wird das bestehende Bild via `MediaCache`
/// nachgeladen, damit der User die existierenden Masken sieht.
struct ImageOcclusionFields: View {
@Binding var image: PlatformImage?
@Binding var imageData: Data?
@Binding var mimeType: String
@Binding var regions: [MaskRegion]
@Binding var note: String
@Binding var existingImageRef: String?
let onLoadError: (String) -> Void
@Environment(\.mediaCache) private var mediaCache
@State private var pickerItem: PhotosPickerItem?
var body: some View {
Section("Bild") {
PhotosPicker(selection: $pickerItem, matching: .images) {
ImagePickerLabel(hasImage: image != nil)
}
.onChange(of: pickerItem) { _, newItem in
Task { await loadPickedImage(newItem) }
}
}
if let image {
Section("Masken") {
MaskEditorView(image: image, regions: $regions)
}
}
Section("Hinweis (optional)") {
TextField("z.B. Kurz-Erklärung", text: $note, axis: .vertical)
.lineLimit(1 ... 3)
}
Section {
statusLabel
}
.task(id: existingImageRef) {
await loadExistingImageIfNeeded()
}
}
@ViewBuilder
private var statusLabel: some View {
if image == nil {
Label("Erst Bild wählen", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
} else if regions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(WordeckTheme.warning)
} else {
Label(
"\(regions.count) Masken → \(regions.count) Reviews",
systemImage: "checkmark.circle.fill"
)
.font(.caption)
.foregroundStyle(WordeckTheme.success)
}
}
private func loadExistingImageIfNeeded() async {
guard
image == nil,
let ref = existingImageRef,
let cache = mediaCache
else { return }
do {
let data = try await cache.data(for: ref)
if let img = PlatformImage(data: data) {
image = img
}
} catch {
onLoadError("Bestehendes Bild konnte nicht geladen werden: \(error.localizedDescription)")
}
}
private func loadPickedImage(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
imageData = data
mimeType = inferImageMimeType(from: data)
if let img = PlatformImage(data: data) {
image = img
regions = [] // neue Bildauswahl resetet Masken
existingImageRef = nil // bestehender Ref wird ersetzt
}
} catch {
onLoadError("Bild konnte nicht geladen werden: \(error.localizedDescription)")
}
}
private func inferImageMimeType(from data: Data) -> String {
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" }
return "image/jpeg"
}
}
/// Datei-Picker + Antwort-Feld für `audio-front`-Cards. Owned-State:
/// `showAudioPicker`. URL und Antwort kommen als `@Binding` aus dem
/// Parent.
struct AudioFrontFields: View {
@Binding var audioFileURL: URL?
@Binding var back: String
let existingAudioRef: String?
@State private var showPicker = false
var body: some View {
Section("Audio-Datei") {
Button {
showPicker = true
} label: {
pickerLabel
}
.fileImporter(
isPresented: $showPicker,
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
allowsMultipleSelection: false
) { result in
if case let .success(urls) = result, let first = urls.first {
audioFileURL = first
}
}
}
Section("Antwort") {
TextField("Was zu hören ist", text: $back, axis: .vertical)
.lineLimit(2 ... 4)
}
}
@ViewBuilder
private var pickerLabel: some View {
if let audioFileURL {
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
} else if existingAudioRef != nil {
Label("Audio ersetzen", systemImage: "waveform.badge.plus")
} else {
Label("Audio auswählen", systemImage: "waveform.badge.plus")
}
}
}
/// PhotosPicker-Label als eigene View, damit Swift-6-Strict-Concurrency
/// nicht über den `@Sendable`-Closure meckert (View-Konstruktor-Calls
/// werden zur Build-Zeit MainActor-isoliert evaluiert).
struct ImagePickerLabel: View {
let hasImage: Bool
var body: some View {
if hasImage {
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
} else {
Label("Bild auswählen", systemImage: "photo")
}
}
}

View file

@ -1,149 +0,0 @@
import Foundation
import ManaCore
/// Resultat von `CardEditorPayload.build` was an `WordeckAPI.createCard`
/// oder `updateCard` durchgereicht wird.
struct CardEditorPayload {
let fields: [String: String]
let mediaRefs: [String]?
}
/// Snapshot der CardEditor-Felder zum Submit-Zeitpunkt. Ein Wert-Typ,
/// damit `buildPayload` außerhalb der View testbar ist und der View-
/// Struct kompakt bleibt.
struct CardEditorPayloadInputs {
let type: CardType
let front: String
let back: String
let clozeText: String
let typingAnswer: String
let multipleChoiceAnswer: String
let occlusionImageData: Data?
let occlusionMimeType: String
let occlusionRegions: [MaskRegion]
let occlusionNote: String
let existingImageRef: String?
let audioFileURL: URL?
let existingAudioRef: String?
let existingMediaRefs: [String]
}
enum CardEditorPayloadError: LocalizedError {
case missingImage
case missingAudio
var errorDescription: String? {
switch self {
case .missingImage: "Bitte ein Bild wählen."
case .missingAudio: "Bitte eine Audio-Datei wählen."
}
}
}
enum CardEditorPayloadBuilder {
/// Baut den Payload für `POST /cards` bzw. `PATCH /cards/:id`.
/// Lädt für Image-Occlusion / Audio-Front bei Bedarf neue Media
/// hoch; sonst wird der bestehende `*_ref` aus der Card weiterverwendet.
static func build(inputs: CardEditorPayloadInputs, api: WordeckAPI) async throws -> CardEditorPayload {
switch inputs.type {
case .basic, .basicReverse:
CardEditorPayload(
fields: CardFieldsBuilder.basic(front: inputs.front, back: inputs.back),
mediaRefs: nil
)
case .cloze:
CardEditorPayload(
fields: CardFieldsBuilder.cloze(text: inputs.clozeText),
mediaRefs: nil
)
case .typing:
CardEditorPayload(
fields: CardFieldsBuilder.typing(front: inputs.front, answer: inputs.typingAnswer),
mediaRefs: nil
)
case .multipleChoice:
CardEditorPayload(
fields: CardFieldsBuilder.multipleChoice(
front: inputs.front,
answer: inputs.multipleChoiceAnswer
),
mediaRefs: nil
)
case .imageOcclusion:
try await buildImageOcclusionPayload(inputs: inputs, api: api)
case .audioFront:
try await buildAudioFrontPayload(inputs: inputs, api: api)
}
}
private static func buildImageOcclusionPayload(
inputs: CardEditorPayloadInputs,
api: WordeckAPI
) async throws -> CardEditorPayload {
let imageRef: String
var refs = inputs.existingMediaRefs
if let newData = inputs.occlusionImageData {
let media = try await api.uploadMedia(
data: newData,
filename: "occlusion.\(inputs.occlusionMimeType.contains("png") ? "png" : "jpg")",
mimeType: inputs.occlusionMimeType
)
imageRef = media.id
refs = [media.id]
} else if let ref = inputs.existingImageRef {
imageRef = ref
} else {
throw CardEditorPayloadError.missingImage
}
return CardEditorPayload(
fields: CardFieldsBuilder.imageOcclusion(
imageRef: imageRef,
regions: inputs.occlusionRegions,
note: inputs.occlusionNote.isEmpty ? nil : inputs.occlusionNote
),
mediaRefs: refs
)
}
private static func buildAudioFrontPayload(
inputs: CardEditorPayloadInputs,
api: WordeckAPI
) async throws -> CardEditorPayload {
let audioRef: String
var refs = inputs.existingMediaRefs
if let url = inputs.audioFileURL {
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let media = try await api.uploadMedia(
data: data,
filename: url.lastPathComponent,
mimeType: audioMimeType(for: url)
)
audioRef = media.id
refs = [media.id]
} else if let ref = inputs.existingAudioRef {
audioRef = ref
} else {
throw CardEditorPayloadError.missingAudio
}
return CardEditorPayload(
fields: CardFieldsBuilder.audioFront(audioRef: audioRef, back: inputs.back),
mediaRefs: refs
)
}
private static func audioMimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "mp3": "audio/mpeg"
case "wav": "audio/wav"
case "m4a", "mp4": "audio/mp4"
case "ogg", "oga": "audio/ogg"
default: "audio/mpeg"
}
}
}

View file

@ -1,20 +1,11 @@
import ManaCore
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
// swiftlint:disable type_body_length
/// Card-Create und Card-Edit in einer View.
///
/// - `.create(deckId:)` zeigt Type-Picker + leere Felder.
/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable),
/// pre-fillt alle Felder, und PATCHt auf Submit.
///
/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende
/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt.
struct CardEditorView: View {
enum Mode {
case create(deckId: String)
@ -36,24 +27,8 @@ struct CardEditorView: View {
@State private var isSubmitting = false
@State private var errorMessage: String?
// Image-Occlusion-State
@State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion]
@State private var occlusionNote: String
/// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten,
/// solange der User kein neues Bild wählt.
@State private var existingImageRef: String?
/// Audio-Front-State
@State private var audioFileURL: URL?
/// Bestehender `audio_ref` aus der Card im Edit-Modus.
@State private var existingAudioRef: String?
private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront
.basic, .basicReverse, .cloze, .typing, .multipleChoice
]
init(mode: Mode, onSaved: @escaping (Card) -> Void) {
@ -66,10 +41,6 @@ struct CardEditorView: View {
var initialCloze = ""
var initialTyping = ""
var initialMC = ""
var initialRegions: [MaskRegion] = []
var initialNote = ""
var initialImageRef: String?
var initialAudioRef: String?
switch mode {
case .create:
@ -88,13 +59,6 @@ struct CardEditorView: View {
case .multipleChoice:
initialFront = card.fields["front"] ?? ""
initialMC = card.fields["answer"] ?? ""
case .imageOcclusion:
initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]")
initialNote = card.fields["note"] ?? ""
initialImageRef = card.fields["image_ref"]
case .audioFront:
initialBack = card.fields["back"] ?? ""
initialAudioRef = card.fields["audio_ref"]
}
}
@ -104,10 +68,6 @@ struct CardEditorView: View {
_clozeText = State(initialValue: initialCloze)
_typingAnswer = State(initialValue: initialTyping)
_multipleChoiceAnswer = State(initialValue: initialMC)
_occlusionRegions = State(initialValue: initialRegions)
_occlusionNote = State(initialValue: initialNote)
_existingImageRef = State(initialValue: initialImageRef)
_existingAudioRef = State(initialValue: initialAudioRef)
}
var body: some View {
@ -221,24 +181,6 @@ struct CardEditorView: View {
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
}
case .imageOcclusion:
ImageOcclusionFields(
image: $occlusionImage,
imageData: $occlusionImageData,
mimeType: $occlusionMimeType,
regions: $occlusionRegions,
note: $occlusionNote,
existingImageRef: $existingImageRef,
onLoadError: { errorMessage = $0 }
)
case .audioFront:
AudioFrontFields(
audioFileURL: $audioFileURL,
back: $back,
existingAudioRef: existingAudioRef
)
}
}
@ -247,18 +189,6 @@ struct CardEditorView: View {
return false
}
private var deckId: String {
switch mode {
case let .create(deckId): deckId
case let .edit(card): card.deckId
}
}
private var existingMediaRefs: [String] {
if case let .edit(card) = mode { return card.mediaRefs }
return []
}
private var canSubmit: Bool {
switch type {
case .basic, .basicReverse:
@ -269,36 +199,42 @@ struct CardEditorView: View {
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion:
(occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
case .audioFront:
(audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
}
}
// MARK: - Submit
private func buildFields() -> [String: String] {
switch type {
case .basic, .basicReverse:
CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
CardFieldsBuilder.multipleChoice(
front: front.trimmed,
answer: multipleChoiceAnswer.trimmed
)
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = WordeckAPI(auth: auth)
let fields = buildFields()
do {
let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
let card: Card = switch mode {
case let .create(deckId):
try await api.createCard(CardCreateBody(
deckId: deckId,
type: type,
fields: payload.fields,
mediaRefs: payload.mediaRefs
fields: fields
))
case let .edit(existing):
try await api.updateCard(id: existing.id, body: CardUpdateBody(
fields: payload.fields,
mediaRefs: payload.mediaRefs
))
try await api.updateCard(id: existing.id, body: CardUpdateBody(fields: fields))
}
onSaved(card)
dismiss()
@ -307,25 +243,6 @@ struct CardEditorView: View {
}
}
private var payloadInputs: CardEditorPayloadInputs {
CardEditorPayloadInputs(
type: type,
front: front.trimmed,
back: back.trimmed,
clozeText: clozeText.trimmed,
typingAnswer: typingAnswer.trimmed,
multipleChoiceAnswer: multipleChoiceAnswer.trimmed,
occlusionImageData: occlusionImageData,
occlusionMimeType: occlusionMimeType,
occlusionRegions: occlusionRegions,
occlusionNote: occlusionNote.trimmed,
existingImageRef: existingImageRef,
audioFileURL: audioFileURL,
existingAudioRef: existingAudioRef,
existingMediaRefs: existingMediaRefs
)
}
private func label(for type: CardType) -> String {
switch type {
case .basic: "Einfach (Vorder/Rück)"
@ -333,14 +250,10 @@ struct CardEditorView: View {
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
case .imageOcclusion: "Bild-Verdeckung"
case .audioFront: "Audio"
}
}
}
// swiftlint:enable type_body_length
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)

View file

@ -1,12 +1,11 @@
import Foundation
import ManaCore
/// Konstanten für `DeckEditorView` Farbpalette, File-Limits.
/// Werte gespiegelt aus `forest`-Theme und Server-Limits in
/// `cards/apps/api/src/routes/decks-from-image.ts`.
/// Konstanten für `DeckEditorView` Farbpalette.
/// Werte gespiegelt aus dem `forest`-Theme.
enum DeckEditorPresets {
/// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später
/// via Custom-Picker (β-3-extension).
/// via Custom-Picker.
static let colors: [String] = [
"#10803D", // forest primary light
"#1E3A2F", // forest dark
@ -17,10 +16,6 @@ enum DeckEditorPresets {
"#0D9488", // teal
"#737373" // neutral
]
static let maxMediaFiles = 5
static let maxImageBytes = 10 * 1024 * 1024
static let maxPDFBytes = 30 * 1024 * 1024
}
/// Reine Hilfsfunktionen für `DeckEditorView` kein State, keine Bindings.
@ -39,29 +34,7 @@ enum DeckEditorHelpers {
return scheme == "http" || scheme == "https"
}
/// Magic-Byte-Check für die häufigsten Image-Formate. Fallback JPEG.
static func inferImageMimeType(from data: Data) -> String {
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" }
return "image/jpeg"
}
/// Dateiendung für ein erkanntes Image-MIME.
static func imageExtension(forMime mime: String) -> String {
switch mime {
case "image/png": "png"
case "image/gif": "gif"
case "image/webp": "webp"
default: "jpg"
}
}
/// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen.
/// Greift für beide AI-Endpoints, fällt sonst auf `errorDescription`.
static func mapAIError(_ error: AuthError) -> String {
if case let .serverError(status, _, message) = error {
switch status {

View file

@ -1,28 +1,24 @@
import ManaCore
import PhotosUI
import SwiftUI
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier
/// Sub-Modi zur Wahl: manuell (Leer"), AI-Text (Mit KI"), AI-Vision
/// (Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular.
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen drei
/// Sub-Modi zur Wahl: manuell (Leer"), AI-Text (Mit KI") und CSV.
/// Edit-Modus zeigt nur das manuelle Formular.
///
/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`.
/// `type_body_length` ist bewusst übersprungen die 4 Sub-Modi teilen
/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing.
/// Web-Vorbild: `wordeck/apps/web/src/routes/decks/new/+page.svelte`.
struct DeckEditorView: View {
enum Mode {
case create
case edit(deckId: String)
}
/// Vier Sub-Modi im Create-Sheet.
/// Drei Sub-Modi im Create-Sheet.
enum CreateMode: Hashable {
case manual
case aiText
case aiMedia
case csv
}
@ -43,17 +39,12 @@ struct DeckEditorView: View {
/// Create-mode selector
@State private var createMode: CreateMode = .manual
// AI-shared (Text + Media)
// AI-Text
@State private var aiPrompt: String = ""
@State private var aiCount: Int = 15
@State private var aiLanguage: GenerationLanguage = .de
@State private var aiUrl: String = ""
// AI-Media
@State private var aiMediaFiles: [GenerationMediaFile] = []
@State private var aiPhotoItems: [PhotosPickerItem] = []
@State private var showPDFImporter: Bool = false
// CSV-Import
@State private var csvRows: [CSVRow] = []
@State private var csvDeckName: String = ""
@ -99,16 +90,6 @@ struct DeckEditorView: View {
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { toolbar }
.onChange(of: aiPhotoItems) { _, items in
guard !items.isEmpty else { return }
Task { await ingestPhotoItems(items) }
}
.fileImporter(
isPresented: $showPDFImporter,
allowedContentTypes: [.pdf],
allowsMultipleSelection: true,
onCompletion: handlePDFImport
)
.fileImporter(
isPresented: $showCSVImporter,
allowedContentTypes: [.commaSeparatedText, .plainText],
@ -124,7 +105,6 @@ struct DeckEditorView: View {
Picker("Modus", selection: $createMode) {
Text("Leer").tag(CreateMode.manual)
Text("KI").tag(CreateMode.aiText)
Text("Bild").tag(CreateMode.aiMedia)
Text("CSV").tag(CreateMode.csv)
}
.pickerStyle(.segmented)
@ -140,8 +120,6 @@ struct DeckEditorView: View {
Text("Leeres Deck — Karten anschließend selbst anlegen.")
case .aiText:
Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.")
case .aiMedia:
Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.")
case .csv:
Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.")
}
@ -162,13 +140,6 @@ struct DeckEditorView: View {
case .aiText:
AITextFormSections(prompt: $aiPrompt)
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
case .aiMedia:
AIMediaFormSections(
files: $aiMediaFiles,
photoItems: $aiPhotoItems,
showPDFImporter: $showPDFImporter
)
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
case .csv:
CSVImportFormSections(
rows: $csvRows,
@ -222,7 +193,6 @@ struct DeckEditorView: View {
switch activeMode {
case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten"
case .aiText: "Mit KI generieren"
case .aiMedia: "Aus Bild generieren"
case .csv: "Aus CSV importieren"
}
}
@ -230,7 +200,7 @@ struct DeckEditorView: View {
private var confirmLabel: String {
switch activeMode {
case .manual: isCreate ? "Erstellen" : "Speichern"
case .aiText, .aiMedia: "Generieren"
case .aiText: "Generieren"
case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren"
}
}
@ -241,8 +211,6 @@ struct DeckEditorView: View {
!name.trimmingCharacters(in: .whitespaces).isEmpty
case .aiText:
aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3
case .aiMedia:
!aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl)
case .csv:
!csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty
}
@ -259,31 +227,7 @@ struct DeckEditorView: View {
}
}
// MARK: - Photo / PDF ingest
private func ingestPhotoItems(_ items: [PhotosPickerItem]) async {
for item in items {
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
guard data.count <= DeckEditorPresets.maxImageBytes else {
errorMessage = "Bild ist größer als 10 MB und wurde übersprungen."
continue
}
let mime = DeckEditorHelpers.inferImageMimeType(from: data)
let ext = DeckEditorHelpers.imageExtension(forMime: mime)
let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)"
aiMediaFiles.append(GenerationMediaFile(
data: data,
filename: filename,
mimeType: mime
))
} catch {
errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)"
}
}
aiPhotoItems = []
}
// MARK: - CSV ingest
private func handleCSVImport(_ result: Result<[URL], Error>) {
switch result {
@ -306,33 +250,6 @@ struct DeckEditorView: View {
}
}
private func handlePDFImport(_ result: Result<[URL], Error>) {
switch result {
case let .success(urls):
for url in urls {
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
guard data.count <= DeckEditorPresets.maxPDFBytes else {
errorMessage = "\(url.lastPathComponent) ist größer als 30 MB."
continue
}
aiMediaFiles.append(GenerationMediaFile(
data: data,
filename: url.lastPathComponent,
mimeType: "application/pdf"
))
} catch {
errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)"
}
}
case let .failure(error):
errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)"
}
}
// MARK: - Submit
private func startSubmit() {
@ -362,16 +279,6 @@ struct DeckEditorView: View {
try Task.checkCancellation()
onSaved(response.deck)
dismiss()
case (.create, .aiMedia):
let response = try await api.generateDeckFromMedia(
files: aiMediaFiles,
language: aiLanguage,
count: aiCount,
url: DeckEditorHelpers.nonEmpty(aiUrl)
)
try Task.checkCancellation()
onSaved(response.deck)
dismiss()
case (.create, .csv):
let deck = try await submitCSVImport(api: api)
onSaved(deck)
@ -418,26 +325,20 @@ struct DeckEditorView: View {
csvImportProgress = 0
for (index, row) in csvRows.enumerated() {
try Task.checkCancellation()
let fields: [String: String]
switch row.type {
let fields: [String: String] = switch row.type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: row.front, back: row.back)
CardFieldsBuilder.basic(front: row.front, back: row.back)
case .cloze:
fields = CardFieldsBuilder.cloze(text: row.front)
CardFieldsBuilder.cloze(text: row.front)
case .typing:
fields = CardFieldsBuilder.typing(front: row.front, answer: row.back)
CardFieldsBuilder.typing(front: row.front, answer: row.back)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back)
case .imageOcclusion, .audioFront:
// Media-Types brauchen Uploads überspringe in CSV-Import.
csvImportProgress = index + 1
continue
CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back)
}
_ = try await api.createCard(CardCreateBody(
deckId: deck.id,
type: row.type,
fields: fields,
mediaRefs: nil
fields: fields
))
csvImportProgress = index + 1
}
@ -558,101 +459,6 @@ private struct AITextFormSections: View {
}
}
// MARK: - AI media form
private struct AIMediaFormSections: View {
@Binding var files: [GenerationMediaFile]
@Binding var photoItems: [PhotosPickerItem]
@Binding var showPDFImporter: Bool
var body: some View {
Section {
mediaPickers
ForEach(files) { file in
MediaFileRow(file: file) {
files.removeAll { $0.id == file.id }
}
}
} header: {
Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
}
}
@ViewBuilder
private var mediaPickers: some View {
let remaining = DeckEditorPresets.maxMediaFiles - files.count
PhotosPicker(
selection: $photoItems,
maxSelectionCount: max(remaining, 0),
matching: .images
) {
Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled")
}
.disabled(remaining <= 0)
Button {
showPDFImporter = true
} label: {
Label("PDFs hinzufügen", systemImage: "doc.text")
}
.disabled(remaining <= 0)
}
}
private struct MediaFileRow: View {
let file: GenerationMediaFile
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
thumbnail
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(file.filename)
.font(.subheadline)
.lineLimit(1)
Text(file.sizeLabel)
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
}
Spacer()
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(WordeckTheme.mutedForeground)
}
.buttonStyle(.plain)
.accessibilityLabel("Entfernen")
}
}
@ViewBuilder
private var thumbnail: some View {
if file.isPDF {
ZStack {
WordeckTheme.muted
Image(systemName: "doc.text.fill")
.foregroundStyle(WordeckTheme.primary)
}
} else if let img = PlatformImage(data: file.data) {
#if canImport(UIKit)
Image(uiImage: img)
.resizable()
.scaledToFill()
#else
Image(nsImage: img)
.resizable()
.scaledToFill()
#endif
} else {
WordeckTheme.muted
}
}
}
// MARK: - Shared AI controls
private struct AISharedSections: View {

View file

@ -1,146 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede
/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe.
///
/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`.
struct MaskEditorView: View {
let image: PlatformImage
@Binding var regions: [MaskRegion]
@State private var dragStart: CGPoint?
@State private var dragEnd: CGPoint?
@State private var nextIdCounter: Int = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.")
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
imageCanvas
.aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 8))
if regions.isEmpty {
Text("Noch keine Maske")
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
} else {
ForEach(regions) { region in
maskRow(region: region)
}
}
}
}
private var imageCanvas: some View {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
#if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: .fit)
#else
Image(nsImage: image).resizable().aspectRatio(contentMode: .fit)
#endif
ForEach(regions) { region in
overlayRect(for: region, in: geo.size)
}
if let dragStart, let dragEnd {
let rect = normalizedRect(from: dragStart, to: dragEnd)
Rectangle()
.stroke(WordeckTheme.warning, lineWidth: 2)
.background(Rectangle().fill(WordeckTheme.warning.opacity(0.2)))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 4)
.onChanged { value in
if dragStart == nil { dragStart = value.startLocation }
dragEnd = value.location
}
.onEnded { value in
commitDrag(start: value.startLocation, end: value.location, in: geo.size)
}
)
}
}
private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View {
Rectangle()
.fill(WordeckTheme.primary.opacity(0.6))
.frame(width: region.w * size.width, height: region.h * size.height)
.offset(x: region.x * size.width, y: region.y * size.height)
.overlay(
Text(region.label?.isEmpty == false ? region.label! : region.id)
.font(.caption2.weight(.bold))
.foregroundStyle(WordeckTheme.primaryForeground)
.padding(2)
.offset(x: region.x * size.width + 2, y: region.y * size.height + 2),
alignment: .topLeading
)
}
private func maskRow(region: MaskRegion) -> some View {
HStack(spacing: 8) {
Image(systemName: "square.dashed")
.foregroundStyle(WordeckTheme.primary)
TextField("Label (optional)", text: Binding(
get: { region.label ?? "" },
set: { newValue in updateLabel(for: region.id, to: newValue) }
))
.textFieldStyle(.roundedBorder)
Button(role: .destructive) {
regions.removeAll { $0.id == region.id }
} label: {
Image(systemName: "trash")
.foregroundStyle(WordeckTheme.error)
}
.buttonStyle(.plain)
}
}
private func updateLabel(for id: String, to value: String) {
guard let idx = regions.firstIndex(where: { $0.id == id }) else { return }
let old = regions[idx]
regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value)
}
private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect {
let x = min(start.x, end.x)
let y = min(start.y, end.y)
let w = abs(end.x - start.x)
let h = abs(end.y - start.y)
return CGRect(x: x, y: y, width: w, height: h)
}
private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) {
defer {
dragStart = nil
dragEnd = nil
}
let rect = normalizedRect(from: start, to: end)
// Mindestgröße 1% der Bildkante Tap-Klicks ignorieren
guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return }
nextIdCounter += 1
let id = String(format: "m%03d", nextIdCounter)
let normalized = MaskRegion(
id: id,
x: rect.minX / size.width,
y: rect.minY / size.height,
w: rect.width / size.width,
h: rect.height / size.height,
label: nil
)
regions.append(normalized)
}
}

View file

@ -23,31 +23,31 @@ struct ExploreView: View {
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: MarketplaceRoute.self) { route in
switch route {
case .browse:
BrowseView()
case let .publicDeck(slug):
PublicDeckView(slug: slug)
.navigationDestination(for: MarketplaceRoute.self) { route in
switch route {
case .browse:
BrowseView()
case let .publicDeck(slug):
PublicDeckView(slug: slug)
}
}
}
.navigationDestination(for: String.self) { deckId in
DeckDetailView(deckId: deckId)
}
.refreshable {
await store?.loadExplore()
}
.task {
if store == nil {
store = MarketplaceStore(auth: auth)
.navigationDestination(for: String.self) { deckId in
DeckDetailView(deckId: deckId)
}
.refreshable {
await store?.loadExplore()
}
.task {
if store == nil {
store = MarketplaceStore(auth: auth)
}
await store?.loadExplore()
}
.onChange(of: deepLinkSlug) { _, newSlug in
guard let slug = newSlug else { return }
path = [.publicDeck(slug: slug)]
deepLinkSlug = nil
}
await store?.loadExplore()
}
.onChange(of: deepLinkSlug) { _, newSlug in
guard let slug = newSlug else { return }
path = [.publicDeck(slug: slug)]
deepLinkSlug = nil
}
}
}

View file

@ -1,75 +0,0 @@
import AVFoundation
import SwiftUI
/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal
/// per MediaCache, spielt mit AVAudioPlayer ab.
struct AudioPlayerButton: View {
let mediaId: String
@Environment(\.mediaCache) private var mediaCache
@State private var player: AVAudioPlayer?
@State private var isPlaying = false
@State private var failed = false
var body: some View {
Button {
togglePlayback()
} label: {
HStack(spacing: 12) {
Image(systemName: failed
? "speaker.slash.fill"
: (isPlaying ? "pause.circle.fill" : "play.circle.fill"))
.font(.system(size: 48))
.foregroundStyle(failed ? WordeckTheme.error : WordeckTheme.primary)
Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören"))
.font(.headline)
.foregroundStyle(WordeckTheme.foreground)
}
.frame(maxWidth: .infinity)
.padding(20)
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(WordeckTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(failed)
.task(id: mediaId) {
await load()
}
.onDisappear {
player?.stop()
isPlaying = false
}
}
private func load() async {
guard let cache = mediaCache else { failed = true
return
}
do {
let data = try await cache.data(for: mediaId)
#if canImport(UIKit)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
#endif
player = try AVAudioPlayer(data: data)
player?.prepareToPlay()
} catch {
failed = true
}
}
private func togglePlayback() {
guard let player else { return }
if player.isPlaying {
player.pause()
isPlaying = false
} else {
player.currentTime = 0
player.play()
isPlaying = true
}
}
}

View file

@ -1,72 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Lädt ein authentifiziertes Image vom Wordeck-Media-Endpoint und
/// rendert es. Streamt erst beim ersten Mal, danach aus dem
/// MediaCache (LRU 200 MB).
struct RemoteImage: View {
let mediaId: String
let contentMode: ContentMode
@Environment(\.mediaCache) private var mediaCache
@State private var image: PlatformImage?
@State private var failed = false
init(mediaId: String, contentMode: ContentMode = .fit) {
self.mediaId = mediaId
self.contentMode = contentMode
}
var body: some View {
Group {
if let image {
imageView(image)
} else if failed {
ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark")
.foregroundStyle(WordeckTheme.mutedForeground)
} else {
ProgressView()
.tint(WordeckTheme.primary)
}
}
.task(id: mediaId) {
await load()
}
}
@ViewBuilder
private func imageView(_ image: PlatformImage) -> some View {
#if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
#elseif canImport(AppKit)
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
#endif
}
private func load() async {
guard let cache = mediaCache else { failed = true
return
}
do {
let data = try await cache.data(for: mediaId)
if let img = PlatformImage(data: data) {
image = img
} else {
failed = true
}
} catch {
failed = true
}
}
}
#if canImport(UIKit)
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
typealias PlatformImage = NSImage
#endif

View file

@ -1,10 +1,8 @@
import SwiftUI
/// Rendert die Karten-Inhalte je nach `CardType`. Front-/Back-Seite
/// werden über `isFlipped` gesteuert.
///
/// β-2 deckt `basic`, `basic-reverse`, `cloze` ab. Restliche Typen
/// zeigen einen Placeholder mit Hinweis auf die kommende Phase.
/// werden über `isFlipped` gesteuert. Wordeck ist text-only alle
/// Card-Types rendern ausschließlich Markdown-Text.
struct CardRenderer: View {
let card: ReviewCard
let subIndex: Int
@ -24,10 +22,6 @@ struct CardRenderer: View {
}
case .cloze:
clozeView
case .imageOcclusion:
imageOcclusionView
case .audioFront:
audioFrontView
case .multipleChoice:
MultipleChoiceCardView(card: card, isFlipped: isFlipped)
case .typing:
@ -66,82 +60,6 @@ struct CardRenderer: View {
}
}
@ViewBuilder
private var imageOcclusionView: some View {
let imageRef = card.fields["image_ref"] ?? ""
let maskJSON = card.fields["mask_regions"] ?? "[]"
let regions = MaskRegions.parse(maskJSON)
let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil
VStack(spacing: 12) {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
RemoteImage(mediaId: imageRef, contentMode: .fit)
.frame(width: geo.size.width, height: geo.size.height)
ForEach(regions) { region in
let isActive = region.id == activeRegion?.id
// Front: aktive Maske opak, andere transparent.
// Back: alle Masken transparent (Bild komplett sichtbar).
if !isFlipped, isActive {
Rectangle()
.fill(WordeckTheme.primary.opacity(0.92))
.frame(
width: region.w * geo.size.width,
height: region.h * geo.size.height
)
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height)
.overlay(
Text(region.label?.isEmpty == false ? region.label! : "?")
.font(.caption.weight(.bold))
.foregroundStyle(WordeckTheme.primaryForeground)
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height),
alignment: .topLeading
)
}
}
}
}
.aspectRatio(4 / 3, contentMode: .fit)
if isFlipped, let label = activeRegion?.label, !label.isEmpty {
Text(label)
.font(.title3.weight(.semibold))
.foregroundStyle(WordeckTheme.primary)
}
if let note = card.fields["note"], !note.isEmpty {
Text(note)
.font(.caption)
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
}
@ViewBuilder
private var audioFrontView: some View {
let audioRef = card.fields["audio_ref"] ?? ""
VStack(spacing: 16) {
AudioPlayerButton(mediaId: audioRef)
if isFlipped {
Divider().background(WordeckTheme.border)
text(card.fields["back"] ?? "")
.font(.title3)
.foregroundStyle(WordeckTheme.foreground)
}
}
}
private var placeholderView: some View {
VStack(spacing: 8) {
Image(systemName: "questionmark.square.dashed")
.font(.largeTitle)
.foregroundStyle(WordeckTheme.mutedForeground)
Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
/// Markdown-Bold (`**...**`) wird auf SwiftUI's AttributedString gemappt.
private func text(_ markdown: String) -> some View {
let attributed = (try? AttributedString(

View file

@ -5,6 +5,11 @@ import SwiftData
/// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start,
/// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab.
///
/// Seit ζ-1 (2026-05-18): wenn der Server-Call scheitert, fällt die
/// Session auf den `CachedDueReview`-Snapshot vom letzten Sync zurück.
/// Der User lernt dann offline. Grades laufen wie immer in die
/// `GradeQueue` und drainen beim Reconnect.
@MainActor
@Observable
final class StudySession {
@ -20,16 +25,21 @@ final class StudySession {
private(set) var currentIndex: Int = 0
private(set) var isFlipped: Bool = false
private(set) var totalGraded: Int = 0
/// `true` wenn die Session aus dem lokalen Snapshot statt vom Server
/// gestartet wurde. View kann ein Offline-Banner zeigen.
private(set) var isOfflineSession: Bool = false
let deckId: String
let deckName: String
private let api: WordeckAPI
private let context: ModelContext
private let gradeQueue: GradeQueue
init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) {
self.deckId = deckId
self.deckName = deckName
self.context = context
api = WordeckAPI(auth: auth)
gradeQueue = GradeQueue(api: api, context: context)
}
@ -50,6 +60,7 @@ final class StudySession {
currentIndex = 0
isFlipped = false
totalGraded = 0
isOfflineSession = false
if queue.isEmpty {
phase = .finished
} else {
@ -59,12 +70,37 @@ final class StudySession {
let id = deckId
Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)")
} catch {
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
phase = .failed(msg)
Log.study.error("Session start failed: \(msg, privacy: .public)")
// Server nicht erreichbar oder Auth-Fehler Cache-Fallback.
queue = loadFromCache()
currentIndex = 0
isFlipped = false
totalGraded = 0
if queue.isEmpty {
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
phase = .failed(msg)
Log.study.error("Session start failed (no cache): \(msg, privacy: .public)")
} else {
isOfflineSession = true
phase = .studying
let count = queue.count
let id = deckId
Log.study
.notice("Offline-Session — \(count, privacy: .public) cached due in deck \(id, privacy: .public)")
}
}
}
private func loadFromCache() -> [DueReview] {
let deckId = deckId
var descriptor = FetchDescriptor<CachedDueReview>(
predicate: #Predicate<CachedDueReview> { $0.deckId == deckId },
sortBy: [SortDescriptor(\.due, order: .forward)]
)
descriptor.fetchLimit = 500
let cached = (try? context.fetch(descriptor)) ?? []
return cached.compactMap { $0.toDueReview() }
}
func flip() {
guard case .studying = phase else { return }
isFlipped.toggle()

View file

@ -66,6 +66,9 @@ struct StudySessionView: View {
private func studyingView(session: StudySession) -> some View {
VStack(spacing: 16) {
if session.isOfflineSession {
offlineBanner
}
if let due = session.current {
cardSurface(due: due, isFlipped: session.isFlipped)
.onTapGesture {
@ -81,6 +84,24 @@ struct StudySessionView: View {
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
}
/// Banner für Offline-Sessions. Erklärt dem User ehrlich, dass er
/// gerade die Karten lernt, die zum letzten Sync fällig waren
/// neue Karten kommen erst nach Wiederverbindung.
private var offlineBanner: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
Text("Offline — Karten vom letzten Sync")
}
.font(.caption.weight(.medium))
.foregroundStyle(WordeckTheme.mutedForeground)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(WordeckTheme.muted, in: Capsule())
.padding(.horizontal, 16)
.padding(.top, 4)
.transition(.opacity)
}
/// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und
/// `RatingBar` die Card oben nicht stauchen kann sonst proportioniert
/// `.aspectRatio(.fit)` die Card neu und das Layout springt.
@ -137,6 +158,14 @@ struct StudySessionView: View {
.font(.subheadline)
.foregroundStyle(WordeckTheme.mutedForeground)
}
if session.isOfflineSession {
Text("Weitere Karten erst nach Verbindung verfügbar.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(WordeckTheme.mutedForeground)
.padding(.horizontal, 32)
.padding(.top, 4)
}
Button("Zurück") { dismiss() }
.padding(.top, 24)
}

View file

@ -47,8 +47,7 @@ struct MutationEncodingTests {
let body = CardCreateBody(
deckId: "deck_1",
type: .basic,
fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello"),
mediaRefs: nil
fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello")
)
let json = try encode(body)
#expect(json["deck_id"] as? String == "deck_1")
@ -64,8 +63,7 @@ struct MutationEncodingTests {
let body = CardCreateBody(
deckId: "d",
type: .basicReverse,
fields: CardFieldsBuilder.basic(front: "a", back: "b"),
mediaRefs: nil
fields: CardFieldsBuilder.basic(front: "a", back: "b")
)
let json = try encode(body)
#expect(json["type"] as? String == "basic-reverse")
@ -76,8 +74,7 @@ struct MutationEncodingTests {
let body = CardCreateBody(
deckId: "d",
type: .cloze,
fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint."),
mediaRefs: nil
fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint.")
)
let json = try encode(body)
#expect(json["type"] as? String == "cloze")
@ -90,8 +87,7 @@ struct MutationEncodingTests {
let body = CardCreateBody(
deckId: "d",
type: .multipleChoice,
fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A"),
mediaRefs: nil
fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A")
)
let json = try encode(body)
#expect(json["type"] as? String == "multiple-choice")
@ -99,7 +95,7 @@ struct MutationEncodingTests {
@Test("CardUpdateBody nur mit fields")
func cardUpdateBodyFieldsOnly() throws {
let body = CardUpdateBody(fields: ["front": "neu"], mediaRefs: nil)
let body = CardUpdateBody(fields: ["front": "neu"])
let json = try encode(body)
#expect((json["fields"] as? [String: String])?["front"] == "neu")
#expect(json["media_refs"] == nil)

View file

@ -1,9 +1,10 @@
import ManaCore
import Testing
@testable import WordeckNative
@Suite("AppConfig")
struct AppConfigTests {
@Test("Cards-API zeigt auf api.wordeck.com")
@Test("Wordeck-API zeigt auf api.wordeck.com")
func apiBaseURLPointsToWordeck() {
#expect(AppConfig.apiBaseURL.absoluteString == "https://api.wordeck.com")
}
@ -13,8 +14,13 @@ struct AppConfigTests {
#expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how")
}
@Test("Keychain-Service ist ev.mana.wordeck")
func keychainServiceIsAppSpecific() {
#expect(AppConfig.manaAppConfig.keychainService == "ev.mana.wordeck")
/// Cross-App-SSO: alle nativen mana-Apps teilen sich
/// `ManaSharedKeychainGroup` (= "ev.mana.session"), damit JWT +
/// Refresh-Token zwischen Apps geteilt werden können. Referenz
/// statt String-Literal, sonst driftet's bei jeder Plattform-
/// Aktualisierung.
@Test("Keychain-Service nutzt geteilte Mana-Group")
func keychainServiceUsesSharedGroup() {
#expect(AppConfig.manaAppConfig.keychainService == ManaSharedKeychainGroup)
}
}

244
docs/OFFLINE_SYNC.md Normal file
View file

@ -0,0 +1,244 @@
# Offline-Sync — wordeck-native
> **Status:** Konzept-Draft (2026-05-18). Implementierung als Phase
> ζ-1 / ζ-2 in `PLAN.md` geplant, noch nicht begonnen.
## Ziel
Alle Decks des Users — **eigene + abonnierte Marketplace-Forks**
sollen automatisch beim Login / App-Foreground gecacht werden, so
dass der komplette **„Heute fällige Karten lernen"**-Pfad ohne Netz
funktioniert. Grades laufen wie heute über die `GradeQueue` und
drainen beim Reconnect.
## Warum jetzt einfach
Mit dem Wordeck-Text-Only-Rebrand (2026-05-17) sind Bilder und
Audio aus dem Schema raus. Eine Karte ist jetzt nur noch
`{type, fields: [String:String], deck_id, …}` — pure Text. Damit
ist die komplette Offline-Payload **JSON-only**:
| Bestandteil | Größe pro Eintrag | Bei 10 000 Karten |
|---|---:|---:|
| Card-Record (text-only) | ≈ 300 B JSON | ≈ 3 MB |
| Review-Snapshot (FSRS-State) | ≈ 150 B | ≈ 1,5 MB |
| Distractor-Pool (nur MC, ≈ 10/Karte) | ≈ 500 B | ≈ 0,5 MB (pro MC-Karte) |
Selbst Power-User mit 50 Decks und 5 000 Karten landen unter
**5 MB** Total-Footprint. SwiftData verkraftet das mit Links.
## Server-Invariante bleibt
**FSRS rechnet weiterhin nur am Server.** Lokales FSRS bleibt
verboten (CLAUDE.md §1). Der Offline-Modus ist ein **Snapshot-
Modell**: der Client lernt das, was der Server beim letzten Sync
als „due" markiert hat, schickt Grades hinterher, holt nach Sync
einen frischen Snapshot. Mehr ist nicht erlaubt.
## Architektur
```
┌────────────────────────────────────────────────────────────┐
│ DeckListStore.refresh() │
│ │
│ GET /decks ──┐ │
│ ├── TaskGroup ── per Deck ──┬── listCards() │
│ │ ├── dueReviews()│
│ │ └── distractors │
│ │ │
│ ▼ │
│ SwiftData-Persistenz │
│ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ CachedDeck │ │ CachedCard │ │ CachedDue │ │
│ │ │ │ │ │ Review │ │
│ │ (heute) │ │ (neu) │ │ (neu) │ │
│ └─────────────┘ └────────────┘ └──────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ StudySession.start() │
│ │
│ try: api.dueReviews(deckId) │
│ fall: CachedDueReview (deckId) │
│ │
│ grade → GradeQueue (PendingGrade) │
│ │
└────────────────────────────────────────────┘
```
## Daten-Modelle (neu)
```swift
@Model
final class CachedCard {
@Attribute(.unique) var id: String // card_id
var deckId: String
var userId: String
var typeRaw: String // CardType.rawValue
var fields: [String: String] // pures JSON-Field-Bag
var contentHash: String?
var createdAt: Date
var updatedAt: Date
var lastFetchedAt: Date
// Multiple-Choice-Pool für Offline-Rendering.
// Leer für non-MC-Karten.
var distractorPool: [String] = []
}
@Model
final class CachedDueReview {
@Attribute(.unique) var compoundId: String // "\(cardId)-\(subIndex)"
var cardId: String
var subIndex: Int
var deckId: String // Index für StudySession-Lookup
var due: Date // Server-berechnet
var stability: Double
var difficulty: Double
var stateRaw: String // ReviewState
var lastReview: Date?
var snapshottedAt: Date // wann gepullt
}
```
## API-Endpoints (vorhanden, kein Server-Change nötig)
| Endpoint | Verwendung | Limit |
|---|---|---|
| `GET /api/v1/cards?deck_id=X` | komplette Card-Liste pro Deck | **kein Limit** |
| `GET /api/v1/reviews/due?deck_id=X&limit=500` | due-Snapshot | **500** ⚠ |
| `GET /api/v1/decks/:deckId/distractors?card_id=Y&field=back&count=10` | MC-Pool | 10 |
## Sync-Algorithmus (`DeckListStore.refresh()` erweitert)
```
1. GET /decks → remoteDecks
2. Diff Cache ↔ remoteDecks, gelöschte Decks aus Cache entfernen
3. Für jedes Deck parallel (TaskGroup):
a. listCards(deckId) → in CachedCard upserten
b. dueReviews(deckId, limit: 500) → CachedDueReview ersetzen
(nicht mergen — Snapshot überschreibt komplett, weil due-Zeiten
sich serverseitig ändern können)
c. Für jede MC-Karte: distractors(deckId, cardId, count: 10) →
CachedCard.distractorPool
4. WidgetSnapshot updaten (heute schon, bleibt)
```
## StudySession-Anpassung
```swift
func start() async {
phase = .loading
do {
queue = try await api.dueReviews(deckId: deckId, limit: 500)
// ... wie heute
} catch {
// Netz-Fehler → Cache befragen
queue = loadFromCache(deckId: deckId)
if queue.isEmpty {
phase = .failed("Kein Netz und keine gecachten Karten.")
} else {
Log.study.notice("Offline-Mode: \(queue.count) cached due reviews")
phase = .studying
isOfflineSession = true
}
}
}
```
Beim Grade-Submit ändert sich nichts: `GradeQueue.submit()`
persistiert eh erst lokal und drained später. Das funktioniert
heute schon offline.
## Trigger
| Wann | Was |
|---|---|
| App-Foreground / Login | `DeckListStore.refresh()` (heute) → erweitert auf Card+Review+Distractor-Sync |
| Pull-to-Refresh in `DeckListView` | dasselbe |
| Nach `subscribe(slug:)` im Marketplace | direkt `refresh()` aufrufen, damit das frisch abonnierte Deck sofort komplett gecacht ist |
| `BGAppRefreshTask` (alle ~12 h, optional, β-7-Polish) | Drain Grade-Queue + Refresh; nur wenn `wifi_only=true` erlaubt oder User hat Mobile-Sync aktiv |
## Settings (in `SettingsView`)
- **Auto-Sync** (Default: an) — schaltet Card/Review-Prefetch ein/aus
- **Background-Refresh** (Default: aus) — `BGAppRefreshTask`
- **Cache-Footprint anzeigen** — „17 Decks, 1 234 Karten, 4,2 MB"
- **Cache leeren** — Wipe aller `CachedCard` + `CachedDueReview`,
`CachedDeck` bleibt (sonst Deck-Liste leer)
## Phasen
| Phase | Inhalt | Aufwand |
|---|---|---|
| **ζ-1** | `CachedCard` + Sync in `DeckListStore`, `StudySession`-Cache-Fallback | 1 Tag |
| **ζ-2** | `CachedDueReview` + Distractor-Pool für MC-Karten | 0,5 Tag |
| **ζ-3** | `SettingsView`-Footprint + Cache-Clear | 0,5 Tag |
| **ζ-4** (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag |
Endurance-Pflicht (siehe `PLAN.md`): 200+ Karten offline lernen,
Flugmodus, alle Grades landen nach Reconnect am Server, Cross-
Check mit Web-Review-State.
## Offene Punkte
- 🛑 **`dueReviews(limit: 500)` ist hardcoded — Decks > 500 Karten
haben einen stillen Cap.** Wenn ein Marketplace-Deck mehr als
500 fällige Karten hat (passiert bei frischen Abos), bekommt
der Client offline nur die ersten 500. Optionen:
- (a) Pagination einbauen (`offset=…`) und mehrere Calls
chainen — billig.
- (b) Server-Endpoint `/api/v1/reviews/due-all?deck_id=X` der
paginiert in einer Response liefert — sauberer, braucht
Backend-PR.
- (c) Aktzeptieren, Banner „Sync unvollständig — weitere
Karten erst nach Online-Refresh".
Vorschlag: **(a)** zunächst, Schwelle im Snapshot loggen.
- 🛑 **Distractor-Pool drifted, wenn der User Karten löscht.**
Ein Pool von 10 Distractors zur Sync-Zeit kann nach Lösch-
Aktionen Treffer in der Liste haben, die offline nicht mehr
existieren. Akzeptabel, weil MC-Distractors ohnehin
„Fülltext" sind — Reveal-Korrekt-Highlight kommt vom
`answer`-Feld der Karte, nicht aus dem Pool.
- 🛑 **„Mehr Karten als der Snapshot enthält"** — wenn User
offline alle 100 fälligen Karten durchgelernt hat und weiter
klickt, gibt es keinen lokalen Weg, „nächste fällige Karte" zu
bestimmen. UX-Honest: am Ende der Session Banner zeigen
(„Weitere Karten erst nach Verbindung verfügbar"), Server-
authoritative-FSRS bleibt damit intakt.
- 🛑 **SwiftData-Migration.** Schema-Update von Build 11 → ζ-1
legt zwei neue `@Model`-Klassen an. Bei In-Place-Upgrade von
TestFlight-Buildern muss der `ModelContainer` mit
`MigrationPlan` versorgt werden — sonst Crash beim ersten Start
nach Update. Wir haben das vorher noch nicht gebraucht; für ζ-1
Pflicht-Aufgabe vor Submit.
- 🛑 **Cache-Invalidierung bei Cross-Device-Edits.** User editiert
Karte auf Web → Native zeigt offline noch alte Version, bis der
nächste Refresh läuft. Heute akzeptabel — `updatedAt`-Vergleich
beim Sync wirft die alte Version raus. Wenn das in der Praxis
weh tut, kann später ein Web-Push-Hook auf `card.updated`
einen Targeted-Refresh triggern (nicht ζ-Scope).
- 🛑 **Logout = Cache-Wipe.** Bei Sign-out alle `CachedCard` +
`CachedDueReview` löschen. Heute macht `auth.signOut()` das
nicht. Muss in ζ-1 mit rein.
## Was *nicht* in ζ kommt
- **Lokales FSRS-Berechnen** — verboten per CLAUDE.md §1.
- **Offline-Card-Create** — Editor bleibt online-only. Drafting
ohne Netz wäre nett, hat aber Konflikt-Auflösung als Folge-
Problem. Aufgeschoben bis nach v1.
- **Media-Prefetch** — gegenstandslos seit Wordeck-Rebrand
(text-only).
## Cross-Refs
- `CLAUDE.md` — Architektur-Invarianten (§1 FSRS, §2 Offline-Read)
- `PLAN.md` — Phasen-Stand
- `../mana/docs/playbooks/WORDECK_REBRAND.md` — Text-Only-Cut
- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — Greenfield-SOT
- `../wordeck/apps/api/src/routes/cards.ts``GET /cards?deck_id`
- `../wordeck/apps/api/src/routes/reviews.ts``GET /reviews/due`
- `../wordeck/apps/api/src/routes/decks.ts``/distractors`

View file

@ -79,7 +79,6 @@ targets:
- cards
NSUserActivityTypes:
- NSUserActivityTypeBrowsingWeb
NSPhotoLibraryUsageDescription: "Wordeck greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst."
ITSAppUsesNonExemptEncryption: false
entitlements:
path: Sources/Resources/WordeckNative.entitlements