From 3b861af3fb5eba2b2d8f716eed699df40404cc5e Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 00:16:11 +0200 Subject: [PATCH] =?UTF-8?q?v0.3.0=20=E2=80=94=20Phase=20=CE=B2-2=20Study-L?= =?UTF-8?q?oop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voller Lern-Flow mit Web-Parität: fällige Karten via /reviews/due laden, flip + rate (4 Buttons + Haptic), Grades via Offline-Queue ans Server-FSRS schicken. - Card/Review/DueReview DTOs mit snake_case + camelCase-deckId- Sonderfall im embedded card-Subobjekt - CardType-Enum (alle 7 Typen), Rating-Enum mit deutschen Labels - Cloze-Helper 1:1-Port aus cards-domain (extractClusterIds, subIndexCount, clusterId, renderPrompt/Answer, hint) - CardsAPI.dueReviews(deckId:) + gradeReview(cardId,subIndex,rating,reviewedAt) - PendingGrade SwiftData-Model + GradeQueue (FIFO-Drain, originaler Timestamp bleibt, bei Netzfehler in Queue, Retry beim nächsten Drain) - StudySession @Observable State-Machine - CardRenderer für basic, basic-reverse, cloze; Placeholder für image-occlusion/audio-front/typing/multiple-choice (β-3/β-4) - RatingBar mit UIImpactFeedbackGenerator (medium/heavy) - StudySessionView per NavigationLink aus DeckListView - 9 neue Tests (Cloze: 8, Review-Decoding: 3), insgesamt 17 grün Server-authoritative FSRS bleibt — kein ts-fsrs-Port. Endurance-Test auf realem Gerät steht aus (siehe PLAN.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 64 +++++-- Sources/App/CardsNativeApp.swift | 2 +- Sources/Core/API/CardsAPI.swift | 40 +++++ Sources/Core/Domain/Card.swift | 51 ++++++ Sources/Core/Domain/Cloze.swift | 78 +++++++++ Sources/Core/Domain/Review.swift | 113 ++++++++++++ Sources/Core/Storage/PendingGrade.swift | 31 ++++ Sources/Core/Sync/GradeQueue.swift | 91 ++++++++++ Sources/Features/Decks/DeckListView.swift | 16 +- Sources/Features/Study/CardRenderer.swift | 88 ++++++++++ Sources/Features/Study/RatingBar.swift | 63 +++++++ Sources/Features/Study/StudySession.swift | 92 ++++++++++ Sources/Features/Study/StudySessionView.swift | 165 ++++++++++++++++++ Tests/UnitTests/ClozeTests.swift | 59 +++++++ Tests/UnitTests/ReviewDecodingTests.swift | 83 +++++++++ 15 files changed, 1013 insertions(+), 23 deletions(-) create mode 100644 Sources/Core/Domain/Card.swift create mode 100644 Sources/Core/Domain/Cloze.swift create mode 100644 Sources/Core/Domain/Review.swift create mode 100644 Sources/Core/Storage/PendingGrade.swift create mode 100644 Sources/Core/Sync/GradeQueue.swift create mode 100644 Sources/Features/Study/CardRenderer.swift create mode 100644 Sources/Features/Study/RatingBar.swift create mode 100644 Sources/Features/Study/StudySession.swift create mode 100644 Sources/Features/Study/StudySessionView.swift create mode 100644 Tests/UnitTests/ClozeTests.swift create mode 100644 Tests/UnitTests/ReviewDecodingTests.swift diff --git a/PLAN.md b/PLAN.md index 96f3863..180b24e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,13 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 + β-1 abgeschlossen.** Repo lebt -auf Forgejo, Login funktioniert, Deck-Liste mit Card-/Due-Counts + -Offline-SwiftData-Cache + Pull-to-Refresh + Inbox-Banner für -Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün. +**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 abgeschlossen.** +Repo auf Forgejo, Login funktioniert, Deck-Liste mit Cache + +Pull-to-Refresh, voller Study-Loop mit Flip/Rating/Haptic + +Offline-Queue für Grades (PendingGrade SwiftData). Cloze client- +rendered (1:1-Port aus cards-domain). 17 Unit-Tests + 1 UI-Test grün. + +Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten +mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc @@ -24,6 +28,26 @@ Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün. - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **β-2 — Study-Loop (2026-05-13, Tag `v0.3.0`)** +- `Card`, `Review`, `DueReview` Codable-DTOs, `CardType`-Enum (alle 7 Typen) +- `Rating`-Enum: `again | hard | good | easy` mit deutschen Labels +- `Cloze`-Helpers (extractClusterIds, subIndexCount, clusterId, + renderPrompt, renderAnswer, hint) — 1:1-Port aus + `cards/packages/cards-domain/src/cloze.ts` +- `CardsAPI.dueReviews(deckId:)`, `CardsAPI.gradeReview(...)` mit + ISO8601-Encoder +- `PendingGrade` SwiftData-Model + `GradeQueue` für Offline-Submit + (FIFO-Drain, originaler reviewedAt-Timestamp bleibt erhalten) +- `StudySession` als @Observable State-Machine + (loading/studying/finished/failed) +- `CardRenderer`: basic, basic-reverse (sub-index-abhängig), cloze + client-rendered. image-occlusion/audio-front/typing/multiple-choice + zeigen Placeholder (β-3/β-4) +- `RatingBar` mit Haptic-Feedback (medium für again/hard/good, + heavy für easy, soft beim Flip) +- `StudySessionView` vollbild aus DeckListView per NavigationLink +- 9 zusätzliche Tests (Cloze 8x, Review/DueReview-Decoding 3x) + ✅ **β-1 — Decks lesen (2026-05-13, Tag `v0.2.0`)** - `Deck`-Codable-DTO mit snake_case-CodingKeys, plus `DeckCategory`, `DeckVisibility`, `FsrsSettings` @@ -43,29 +67,33 @@ Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün. |---|---|---| | β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe | | β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh | -| β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test | +| β-2 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) | | β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) | | β-4 | — | Media, image-occlusion (PencilKit), audio-front | | β-5 | — | Marketplace, Universal-Links | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-7 | — | App-Store-Submission | -## Nächste Schritte für β-2 +## Nächste Schritte für β-3 (Editor) -Aus Greenfield-Plan-Sektion "Phase β-2 — Study-Loop": +Aus Greenfield-Plan-Sektion "Phase β-3 — Card-/Deck-Editor": -1. `Card`-DTO + `Review`-DTO aus `cards/apps/api/src/lib/dto.ts` -2. `CardsAPI.dueCards(deckId:)` → fetcht `/reviews/due` + zugehörige - `/cards/:id`-Details für die Karten-Inhalte -3. `StudySessionView` mit `CardRenderer`-switch (basic + basic-reverse - + cloze; cloze-Rendering kommt vom Server via `renderClozePrompt`) -4. Flip-Animation, Rating-Bar (`again | hard | good | easy`) -5. `POST /api/v1/reviews/:cardId/:subIndex/grade` mit Haptic-Feedback -6. `PendingGrade` SwiftData-Model als Offline-Queue, Drain bei Reconnect -7. Endurance-Test auf realem Gerät (200+ Karten, Flugmodus zwischendurch) +1. `DeckCreateView`: Form für Name, Description, Color (Picker), + Category-Picker, Visibility, FSRS-Settings (Sheet) +2. `CardEditorView` per Type (basic, cloze, typing, multiple-choice): + Two-Text-Fields oder Cloze-Syntax-Highlighting +3. POST/PATCH/DELETE `/api/v1/cards` und `/api/v1/decks` +4. Anki-Import als Datei-Picker → `/api/v1/decks/import` -**Erfolgskriterium:** 50 Karten am Stück im Simulator durchgraden, -Web zeigt nach Refresh die gleichen Reviews-States. +**Erfolgskriterium:** Karte in Native erstellt, in Web sichtbar; +Karte in Web erstellt, in Native sichtbar (Pull-to-Refresh). + +## Pflicht-Tests für β-2 (vor β-3-Start) + +- [ ] Endurance-Test auf realem Gerät: 200+ Karten lernen, Flugmodus + zwischendurch — alle Grades landen am Server nach Reconnect. +- [ ] Cross-Check mit Web: Karte gegrade in Native → Web zeigt + identischen Review-State nach Reload. ## Cross-Refs diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index fd2a88d..daaaf93 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -9,7 +9,7 @@ struct CardsNativeApp: App { init() { do { - container = try ModelContainer(for: CachedDeck.self) + container = try ModelContainer(for: CachedDeck.self, PendingGrade.self) } catch { fatalError("Failed to init ModelContainer: \(error)") } diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 77f7924..ee78f34 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -54,6 +54,46 @@ actor CardsAPI { return try decoder.decode(DueReviewsResponse.self, from: data).total } + // MARK: - Study + + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews + /// inklusive zugehöriger Card-Daten. Hot-Path für die Study-View. + func dueReviews(deckId: String, limit: Int = 500) async throws -> [DueReview] { + let (data, http) = try await transport.request( + path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=\(limit)" + ) + try ensureOK(http, data: data) + return try decoder.decode(DueReviewsListResponse.self, from: data).reviews + } + + /// `POST /api/v1/reviews/:cardId/:subIndex/grade` — gibt eine + /// Bewertung ab. Server rechnet FSRS, antwortet mit aktualisiertem + /// Review. + @discardableResult + func gradeReview( + cardId: String, + subIndex: Int, + rating: Rating, + reviewedAt: Date = .now + ) async throws -> Review { + let body = try makeJSON(GradeReviewBody(rating: rating, reviewedAt: reviewedAt)) + let (data, http) = try await transport.request( + path: "/api/v1/reviews/\(cardId)/\(subIndex)/grade", + method: "POST", + body: body + ) + try ensureOK(http, data: data) + return try decoder.decode(Review.self, from: data) + } + + // MARK: - JSON-Encoding + + private func makeJSON(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(value) + } + // MARK: - Helpers private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { diff --git a/Sources/Core/Domain/Card.swift b/Sources/Core/Domain/Card.swift new file mode 100644 index 0000000..24bb6e4 --- /dev/null +++ b/Sources/Core/Domain/Card.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` +/// und `cards/packages/cards-domain/src/schemas/card.ts`. +struct Card: Codable, Identifiable, Hashable, Sendable { + 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 + + enum CodingKeys: String, CodingKey { + case id + case deckId = "deck_id" + 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. +enum CardType: String, Codable, Sendable, CaseIterable { + case basic + case basicReverse = "basic-reverse" + case cloze + case imageOcclusion = "image-occlusion" + case audioFront = "audio-front" + case typing + case multipleChoice = "multiple-choice" +} + +/// Vereinfachtes Card-Sub-Objekt aus `/reviews/due?deck_id=X`-Response. +/// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle- +/// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht +/// snake_case wie sonst. +struct ReviewCard: Codable, Hashable, Sendable { + let id: String + let deckId: String + let type: CardType + let fields: [String: String] +} diff --git a/Sources/Core/Domain/Cloze.swift b/Sources/Core/Domain/Cloze.swift new file mode 100644 index 0000000..214403c --- /dev/null +++ b/Sources/Core/Domain/Cloze.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Cloze-Helpers. 1:1-Port der Funktionen aus +/// `cards/packages/cards-domain/src/cloze.ts`. +/// +/// **Web-Parität-Hinweis:** Web rendert Cloze client-side. Native macht +/// dasselbe, weil Server keinen Render-Endpoint dafür hat. Pure-String- +/// Manipulation, kein FSRS — Mocking gegen die TS-Implementierung via +/// Fixture-Tests. +/// +/// Markup: `{{cN::answer}}` oder `{{cN::answer::hint}}`. N ist +/// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere +/// Sub-Index-Reviews. +enum Cloze { + /// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert, + /// weil `Regex` unter Strict-Concurrency nicht Sendable ist. + private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> { + #/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/# + } + + /// Distinct Cluster-IDs, sortiert. + static func extractClusterIds(_ text: String) -> [Int] { + var ids = Set() + for match in text.matches(of: clusterPattern) { + if let n = Int(match.output.1), n >= 1 { + ids.insert(n) + } + } + return ids.sorted() + } + + /// Hint für einen Cluster (erstes Vorkommen gewinnt). + static func hint(for text: String, clusterId: Int) -> String? { + for match in text.matches(of: clusterPattern) { + if let n = Int(match.output.1), n == clusterId, let hint = match.output.3 { + return String(hint) + } + } + return nil + } + + /// Anzahl distinct Cluster — entspricht Sub-Index-Count. + static func subIndexCount(_ text: String) -> Int { + extractClusterIds(text).count + } + + /// Mapping Sub-Index → Cluster-ID. + static func clusterId(for text: String, subIndex: Int) -> Int? { + let ids = extractClusterIds(text) + guard ids.indices.contains(subIndex) else { return nil } + return ids[subIndex] + } + + /// Prompt-Render: aktiver Cluster wird zu `[…]` (oder `[hint]`), + /// alle anderen werden auf ihre Antwort expandiert. + static func renderPrompt(_ text: String, activeClusterId: Int) -> String { + text.replacing(clusterPattern) { match in + guard let n = Int(match.output.1) else { return String(match.output.0) } + if n == activeClusterId { + if let hint = match.output.3 { + return "[\(hint)]" + } + return "[…]" + } + return String(match.output.2) + } + } + + /// Antwort-Render: alle Cluster expandiert. Aktiver Cluster wird + /// als Markdown-Bold markiert. + static func renderAnswer(_ text: String, activeClusterId: Int) -> String { + text.replacing(clusterPattern) { match in + guard let n = Int(match.output.1) else { return String(match.output.0) } + let answer = String(match.output.2) + return n == activeClusterId ? "**\(answer)**" : answer + } + } +} diff --git a/Sources/Core/Domain/Review.swift b/Sources/Core/Domain/Review.swift new file mode 100644 index 0000000..5fc9ae4 --- /dev/null +++ b/Sources/Core/Domain/Review.swift @@ -0,0 +1,113 @@ +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, Sendable, 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, Sendable { + 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, Sendable { + 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, Sendable, Identifiable { + let review: Review + let card: ReviewCard + + var id: String { "\(review.cardId)-\(review.subIndex)" } + + 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, Sendable { + let reviews: [DueReview] + let total: Int +} + +/// Body für `POST /reviews/:cardId/:subIndex/grade`. +struct GradeReviewBody: Encodable, Sendable { + let rating: Rating + let reviewedAt: Date + + enum CodingKeys: String, CodingKey { + case rating + case reviewedAt = "reviewed_at" + } +} diff --git a/Sources/Core/Storage/PendingGrade.swift b/Sources/Core/Storage/PendingGrade.swift new file mode 100644 index 0000000..cd5f773 --- /dev/null +++ b/Sources/Core/Storage/PendingGrade.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftData + +/// Offline-Grade in der lokalen Queue. Wird beim Reconnect der Reihe nach +/// an `POST /reviews/:cardId/:subIndex/grade` gesendet — mit dem +/// **originalen** `reviewedAt`-Timestamp, damit der Server-FSRS +/// korrekt rechnet. +@Model +final class PendingGrade { + @Attribute(.unique) var id: String + var cardId: String + var subIndex: Int + var ratingRaw: String + var reviewedAt: Date + var queuedAt: Date + var lastTryAt: Date? + var lastError: String? + + init(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date) { + id = "\(cardId)-\(subIndex)-\(reviewedAt.timeIntervalSince1970)" + self.cardId = cardId + self.subIndex = subIndex + ratingRaw = rating.rawValue + self.reviewedAt = reviewedAt + queuedAt = .now + } + + var rating: Rating? { + Rating(rawValue: ratingRaw) + } +} diff --git a/Sources/Core/Sync/GradeQueue.swift b/Sources/Core/Sync/GradeQueue.swift new file mode 100644 index 0000000..0b4ff8f --- /dev/null +++ b/Sources/Core/Sync/GradeQueue.swift @@ -0,0 +1,91 @@ +import Foundation +import ManaCore +import Observation +import SwiftData + +/// Persistente Offline-Queue für Grade-Aktionen. Drain-Loop kann +/// vom UI ausgelöst werden (bei Reconnect oder App-Foreground). +@MainActor +@Observable +final class GradeQueue { + private(set) var isDraining = false + private(set) var lastDrainError: String? + + private let api: CardsAPI + private let context: ModelContext + + init(api: CardsAPI, context: ModelContext) { + self.api = api + self.context = context + } + + /// Enqueue + sofort versuchen zu senden. Bei Fehler bleibt der + /// Eintrag in der Queue. + func submit(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date = .now) async { + let grade = PendingGrade( + cardId: cardId, + subIndex: subIndex, + rating: rating, + reviewedAt: reviewedAt + ) + context.insert(grade) + try? context.save() + Log.study.info( + "Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)" + ) + await drain() + } + + /// Schickt alle pending grades in FIFO-Reihenfolge ab. Bei Server- + /// Erfolg: aus Queue löschen. Bei Netzfehler: Loop abbrechen + /// (kommender Drain probiert es nochmal). + func drain() async { + guard !isDraining else { return } + isDraining = true + defer { isDraining = false } + + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.queuedAt, order: .forward)] + ) + let pending = (try? context.fetch(descriptor)) ?? [] + guard !pending.isEmpty else { + lastDrainError = nil + return + } + + for grade in pending { + guard let rating = grade.rating else { + context.delete(grade) + continue + } + do { + _ = try await api.gradeReview( + cardId: grade.cardId, + subIndex: grade.subIndex, + rating: rating, + reviewedAt: grade.reviewedAt + ) + context.delete(grade) + try? context.save() + } catch { + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + grade.lastTryAt = .now + grade.lastError = msg + try? context.save() + lastDrainError = msg + Log.study.notice( + "Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)" + ) + return + } + } + lastDrainError = nil + Log.study.info("Drain complete") + } + + /// Wie viele Grades hängen aktuell offline? + func pendingCount() -> Int { + let descriptor = FetchDescriptor() + return (try? context.fetchCount(descriptor)) ?? 0 + } +} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 9e94851..c389a8f 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -19,6 +19,11 @@ struct DeckListView: View { content } .navigationTitle("Decks") + .navigationDestination(for: String.self) { deckId in + if let deck = decks.first(where: { $0.id == deckId }) { + StudySessionView(deckId: deck.id, deckName: deck.name) + } + } .toolbar { toolbar } .refreshable { await store?.refresh() @@ -112,10 +117,13 @@ struct DeckListView: View { private var ownDecksSection: some View { Section { ForEach(decks) { deck in - DeckRow(deck: deck) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + NavigationLink(value: deck.id) { + DeckRow(deck: deck) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } } diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift new file mode 100644 index 0000000..9c258b0 --- /dev/null +++ b/Sources/Features/Study/CardRenderer.swift @@ -0,0 +1,88 @@ +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. +struct CardRenderer: View { + let card: ReviewCard + let subIndex: Int + let isFlipped: Bool + + var body: some View { + Group { + switch card.type { + case .basic: + basicView(front: "front", back: "back") + case .basicReverse: + // sub_index 0 = front→back, sub_index 1 = back→front + if subIndex == 0 { + basicView(front: "front", back: "back") + } else { + basicView(front: "back", back: "front") + } + case .cloze: + clozeView + case .imageOcclusion, .audioFront, .typing, .multipleChoice: + placeholderView + } + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func basicView(front frontKey: String, back backKey: String) -> some View { + VStack(spacing: 16) { + text(card.fields[frontKey] ?? "") + .font(.title2) + .foregroundStyle(CardsTheme.foreground) + if isFlipped { + Divider().background(CardsTheme.border) + text(card.fields[backKey] ?? "") + .font(.title3) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + } + + @ViewBuilder + private var clozeView: some View { + let raw = card.fields["text"] ?? "" + let clusterId = Cloze.clusterId(for: raw, subIndex: subIndex) ?? 1 + let rendered = isFlipped + ? Cloze.renderAnswer(raw, activeClusterId: clusterId) + : Cloze.renderPrompt(raw, activeClusterId: clusterId) + VStack(spacing: 12) { + text(rendered) + .font(.title3) + .foregroundStyle(CardsTheme.foreground) + } + } + + @ViewBuilder + private var placeholderView: some View { + VStack(spacing: 8) { + Image(systemName: "questionmark.square.dashed") + .font(.largeTitle) + .foregroundStyle(CardsTheme.mutedForeground) + Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + + /// Markdown-Bold (`**...**`) wird auf SwiftUI's AttributedString gemappt. + private func text(_ markdown: String) -> some View { + let attributed = (try? AttributedString( + markdown: markdown, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + )) ?? AttributedString(markdown) + return Text(attributed) + .multilineTextAlignment(.center) + } +} diff --git a/Sources/Features/Study/RatingBar.swift b/Sources/Features/Study/RatingBar.swift new file mode 100644 index 0000000..51d8181 --- /dev/null +++ b/Sources/Features/Study/RatingBar.swift @@ -0,0 +1,63 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating) +/// plus Haptic-Feedback. +struct RatingBar: View { + let onRate: (Rating) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(Rating.allCases, id: \.self) { rating in + Button { + triggerHaptic(for: rating) + onRate(rating) + } label: { + VStack(spacing: 2) { + Text(rating.label) + .font(.subheadline.weight(.semibold)) + Text(rating.shortcut) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(background(for: rating), in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(foreground(for: rating)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + + private func background(for rating: Rating) -> Color { + switch rating { + case .again: CardsTheme.error.opacity(0.12) + case .hard: CardsTheme.warning.opacity(0.12) + case .good: CardsTheme.primary.opacity(0.12) + case .easy: CardsTheme.success.opacity(0.12) + } + } + + private func foreground(for rating: Rating) -> Color { + switch rating { + case .again: CardsTheme.error + case .hard: CardsTheme.warning + case .good: CardsTheme.primary + case .easy: CardsTheme.success + } + } + + private func triggerHaptic(for rating: Rating) { + #if canImport(UIKit) + let generator = UIImpactFeedbackGenerator( + style: rating == .easy ? .heavy : .medium + ) + generator.impactOccurred() + #endif + } +} diff --git a/Sources/Features/Study/StudySession.swift b/Sources/Features/Study/StudySession.swift new file mode 100644 index 0000000..ae2e887 --- /dev/null +++ b/Sources/Features/Study/StudySession.swift @@ -0,0 +1,92 @@ +import Foundation +import ManaCore +import Observation +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. +@MainActor +@Observable +final class StudySession { + enum Phase: Sendable { + case loading + case studying + case finished + case failed(String) + } + + private(set) var phase: Phase = .loading + private(set) var queue: [DueReview] = [] + private(set) var currentIndex: Int = 0 + private(set) var isFlipped: Bool = false + private(set) var totalGraded: Int = 0 + + let deckId: String + let deckName: String + + private let api: CardsAPI + private let gradeQueue: GradeQueue + + init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) { + self.deckId = deckId + self.deckName = deckName + api = CardsAPI(auth: auth) + gradeQueue = GradeQueue(api: api, context: context) + } + + var current: DueReview? { + guard queue.indices.contains(currentIndex) else { return nil } + return queue[currentIndex] + } + + var remaining: Int { + max(0, queue.count - currentIndex) + } + + func start() async { + phase = .loading + do { + queue = try await api.dueReviews(deckId: deckId, limit: 500) + currentIndex = 0 + isFlipped = false + totalGraded = 0 + if queue.isEmpty { + phase = .finished + } else { + phase = .studying + } + Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)") + } catch { + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + phase = .failed(msg) + Log.study.error("Session start failed: \(msg, privacy: .public)") + } + } + + func flip() { + guard case .studying = phase else { return } + isFlipped.toggle() + } + + func grade(_ rating: Rating) async { + guard case .studying = phase, let card = current else { return } + let reviewedAt = Date.now + await gradeQueue.submit( + cardId: card.review.cardId, + subIndex: card.review.subIndex, + rating: rating, + reviewedAt: reviewedAt + ) + totalGraded += 1 + advance() + } + + private func advance() { + currentIndex += 1 + isFlipped = false + if currentIndex >= queue.count { + phase = .finished + Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)") + } + } +} diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift new file mode 100644 index 0000000..0c661c3 --- /dev/null +++ b/Sources/Features/Study/StudySessionView.swift @@ -0,0 +1,165 @@ +import ManaCore +import SwiftData +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. +struct StudySessionView: View { + let deckId: String + let deckName: String + + @Environment(AuthClient.self) private var auth + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + @State private var session: StudySession? + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + content + } + .navigationTitle(deckName) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if let session, case .studying = session.phase { + Text("\(session.remaining)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(CardsTheme.mutedForeground) + .accessibilityLabel("\(session.remaining) Karten übrig") + } + } + } + .task { + if session == nil { + let s = StudySession(deckId: deckId, deckName: deckName, auth: auth, context: context) + session = s + await s.start() + } + } + } + + @ViewBuilder + private var content: some View { + if let session { + switch session.phase { + case .loading: + ProgressView("Karten werden geladen …") + .tint(CardsTheme.primary) + case .studying: + studyingView(session: session) + case .finished: + finishedView(session: session) + case let .failed(message): + failedView(message: message, session: session) + } + } else { + ProgressView() + .tint(CardsTheme.primary) + } + } + + private func studyingView(session: StudySession) -> some View { + VStack(spacing: 16) { + if let due = session.current { + cardSurface(due: due, isFlipped: session.isFlipped) + .onTapGesture { + flipHaptic() + session.flip() + } + if session.isFlipped { + RatingBar { rating in + Task { await session.grade(rating) } + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + Button { + flipHaptic() + session.flip() + } label: { + Text("Antwort anzeigen") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + } + } + } + .padding(.bottom, 20) + .animation(.easeInOut(duration: 0.2), value: session.isFlipped) + .animation(.easeInOut(duration: 0.2), value: session.currentIndex) + } + + private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { + RoundedRectangle(cornerRadius: 16) + .fill(CardsTheme.surface) + .overlay( + CardRenderer( + card: due.card, + subIndex: due.review.subIndex, + isFlipped: isFlipped + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(CardsTheme.border, lineWidth: 1) + ) + .padding(.horizontal, 16) + .padding(.top, 12) + } + + private func finishedView(session: StudySession) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 64)) + .foregroundStyle(CardsTheme.success) + Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!") + .font(.title.bold()) + .foregroundStyle(CardsTheme.foreground) + if session.totalGraded > 0 { + Text("\(session.totalGraded) Karten gelernt") + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + } + Button("Zurück") { dismiss() } + .padding(.top, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func failedView(message: String, session: StudySession) -> some View { + VStack(spacing: 16) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 48)) + .foregroundStyle(CardsTheme.error) + Text("Karten konnten nicht geladen werden") + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + Text(message) + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(CardsTheme.mutedForeground) + .padding(.horizontal, 32) + Button("Erneut versuchen") { + Task { await session.start() } + } + .padding(.top, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func flipHaptic() { + #if canImport(UIKit) + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + #endif + } +} diff --git a/Tests/UnitTests/ClozeTests.swift b/Tests/UnitTests/ClozeTests.swift new file mode 100644 index 0000000..2cff5f3 --- /dev/null +++ b/Tests/UnitTests/ClozeTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Cloze") +struct ClozeTests { + @Test("Extrahiert distinct Cluster-IDs sortiert") + func extractsClusterIds() { + let text = "{{c2::a}} und {{c1::b}} sowie {{c2::c}}" + #expect(Cloze.extractClusterIds(text) == [1, 2]) + } + + @Test("Leerer Text → leere Cluster-Liste") + func noClustersWhenEmpty() { + #expect(Cloze.extractClusterIds("Kein Cluster hier") == []) + } + + @Test("subIndexCount entspricht distinct Clustern") + func subIndexCountMatchesClusters() { + #expect(Cloze.subIndexCount("Nur {{c1::eins}}") == 1) + #expect(Cloze.subIndexCount("{{c1::a}} {{c2::b}} {{c3::c}}") == 3) + #expect(Cloze.subIndexCount("Nichts") == 0) + } + + @Test("clusterId-für-subIndex mappt aufsteigend") + func clusterIdForSubIndex() { + let text = "{{c2::a}} {{c1::b}}" + #expect(Cloze.clusterId(for: text, subIndex: 0) == 1) + #expect(Cloze.clusterId(for: text, subIndex: 1) == 2) + #expect(Cloze.clusterId(for: text, subIndex: 2) == nil) + } + + @Test("Prompt ersetzt aktiven Cluster mit Ellipsis") + func renderPromptHidesActive() { + let text = "Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}." + let prompt = Cloze.renderPrompt(text, activeClusterId: 2) + #expect(prompt == "Hauptstadt von Frankreich ist […].") + } + + @Test("Prompt nutzt Hint wenn vorhanden") + func renderPromptUsesHint() { + let text = "Die {{c1::Sonne::Stern}} ist heiß." + #expect(Cloze.renderPrompt(text, activeClusterId: 1) == "Die [Stern] ist heiß.") + } + + @Test("Answer markiert aktiven Cluster mit Markdown-Bold") + func renderAnswerBoldsActive() { + let text = "{{c1::A}} und {{c2::B}}" + #expect(Cloze.renderAnswer(text, activeClusterId: 1) == "**A** und B") + #expect(Cloze.renderAnswer(text, activeClusterId: 2) == "A und **B**") + } + + @Test("Hint-Lookup gibt erstes Vorkommen") + func hintLookup() { + let text = "{{c1::a}} {{c1::b::tipp}}" + #expect(Cloze.hint(for: text, clusterId: 1) == "tipp") + #expect(Cloze.hint(for: text, clusterId: 2) == nil) + } +} diff --git a/Tests/UnitTests/ReviewDecodingTests.swift b/Tests/UnitTests/ReviewDecodingTests.swift new file mode 100644 index 0000000..1db436a --- /dev/null +++ b/Tests/UnitTests/ReviewDecodingTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Review-JSON-Decoding") +struct ReviewDecodingTests { + @Test("Review-Wire-Format decodet vollständig") + func decodesReview() throws { + let json = """ + { + "card_id": "card_1", + "sub_index": 0, + "user_id": "user_1", + "due": "2026-05-13T10:00:00.000Z", + "stability": 2.5, + "difficulty": 5.0, + "elapsed_days": 1.0, + "scheduled_days": 3.0, + "learning_steps": 0, + "reps": 5, + "lapses": 1, + "state": "review", + "last_review": "2026-05-10T10:00:00.000Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional + let review = try decoder.decode(Review.self, from: json) + + #expect(review.cardId == "card_1") + #expect(review.subIndex == 0) + #expect(review.state == .review) + #expect(review.reps == 5) + #expect(review.lastReview != nil) + } + + @Test("DueReview embedded card decodet (camelCase deckId!)") + func decodesDueReview() throws { + // Achtung: Server liefert hier `deckId` camelCase im embedded card, + // weil das aus Drizzle direkt rauskommt, nicht durch toCardDto. + let json = """ + { + "card_id": "c1", + "sub_index": 0, + "user_id": "u1", + "due": "2026-05-13T10:00:00.000Z", + "stability": 0, + "difficulty": 0, + "elapsed_days": 0, + "scheduled_days": 0, + "learning_steps": 0, + "reps": 0, + "lapses": 0, + "state": "new", + "last_review": null, + "card": { + "id": "c1", + "deckId": "d1", + "type": "basic", + "fields": {"front": "Was ist 1+1?", "back": "2"} + } + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional + let due = try decoder.decode(DueReview.self, from: json) + + #expect(due.review.cardId == "c1") + #expect(due.card.deckId == "d1") + #expect(due.card.type == .basic) + #expect(due.card.fields["front"] == "Was ist 1+1?") + } + + @Test("Rating-Enum-Werte sind exakt wie ts-fsrs erwartet") + func ratingValues() { + #expect(Rating.again.rawValue == "again") + #expect(Rating.hard.rawValue == "hard") + #expect(Rating.good.rawValue == "good") + #expect(Rating.easy.rawValue == "easy") + } +}