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:
parent
19fee75c47
commit
9527240bcc
36 changed files with 728 additions and 1565 deletions
27
PLAN.md
27
PLAN.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
43
Sources/Core/Storage/CachedCard.swift
Normal file
43
Sources/Core/Storage/CachedCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
88
Sources/Core/Storage/CachedDueReview.swift
Normal file
88
Sources/Core/Storage/CachedDueReview.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var mediaCache: MediaCache?
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
244
docs/OFFLINE_SYNC.md
Normal 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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue