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>
123 lines
3.2 KiB
Swift
123 lines
3.2 KiB
Swift
import Foundation
|
|
|
|
/// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`.
|
|
/// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`.
|
|
enum Rating: String, Codable, CaseIterable {
|
|
case again
|
|
case hard
|
|
case good
|
|
case easy
|
|
|
|
/// Anzeige-Label auf dem Rating-Button.
|
|
var label: String {
|
|
switch self {
|
|
case .again: "Nochmal"
|
|
case .hard: "Schwer"
|
|
case .good: "Gut"
|
|
case .easy: "Leicht"
|
|
}
|
|
}
|
|
|
|
/// Kurz-Symbol für minimalistische UI.
|
|
var shortcut: String {
|
|
switch self {
|
|
case .again: "1"
|
|
case .hard: "2"
|
|
case .good: "3"
|
|
case .easy: "4"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// FSRS-Review-State. Aus `ReviewStateSchema`.
|
|
enum ReviewState: String, Codable {
|
|
case new
|
|
case learning
|
|
case review
|
|
case relearning
|
|
}
|
|
|
|
/// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`.
|
|
struct Review: Codable, Hashable {
|
|
let cardId: String
|
|
let subIndex: Int
|
|
let userId: String
|
|
let due: Date
|
|
let stability: Double
|
|
let difficulty: Double
|
|
let elapsedDays: Double
|
|
let scheduledDays: Double
|
|
let learningSteps: Int
|
|
let reps: Int
|
|
let lapses: Int
|
|
let state: ReviewState
|
|
let lastReview: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case cardId = "card_id"
|
|
case subIndex = "sub_index"
|
|
case userId = "user_id"
|
|
case due
|
|
case stability
|
|
case difficulty
|
|
case elapsedDays = "elapsed_days"
|
|
case scheduledDays = "scheduled_days"
|
|
case learningSteps = "learning_steps"
|
|
case reps
|
|
case lapses
|
|
case state
|
|
case lastReview = "last_review"
|
|
}
|
|
}
|
|
|
|
/// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card.
|
|
struct DueReview: Codable, Hashable, Identifiable {
|
|
let review: Review
|
|
let card: ReviewCard
|
|
|
|
var id: String {
|
|
"\(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
|
|
review = try Review(from: decoder)
|
|
card = try container.decode(ReviewCard.self, forKey: .card)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
try review.encode(to: encoder)
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(card, forKey: .card)
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case card
|
|
}
|
|
}
|
|
|
|
/// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`.
|
|
struct DueReviewsListResponse: Decodable {
|
|
let reviews: [DueReview]
|
|
let total: Int
|
|
}
|
|
|
|
/// Body für `POST /reviews/:cardId/:subIndex/grade`.
|
|
struct GradeReviewBody: Encodable {
|
|
let rating: Rating
|
|
let reviewedAt: Date
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case rating
|
|
case reviewedAt = "reviewed_at"
|
|
}
|
|
}
|