v0.3.0 — Phase β-2 Study-Loop
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) <noreply@anthropic.com>
This commit is contained in:
parent
f664a00b64
commit
3b861af3fb
15 changed files with 1013 additions and 23 deletions
64
PLAN.md
64
PLAN.md
|
|
@ -1,9 +1,13 @@
|
||||||
# Plan — cards-native (SwiftUI Universal)
|
# Plan — cards-native (SwiftUI Universal)
|
||||||
|
|
||||||
**Stand: 2026-05-13 — Phasen β-0 + β-1 abgeschlossen.** Repo lebt
|
**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 abgeschlossen.**
|
||||||
auf Forgejo, Login funktioniert, Deck-Liste mit Card-/Due-Counts +
|
Repo auf Forgejo, Login funktioniert, Deck-Liste mit Cache +
|
||||||
Offline-SwiftData-Cache + Pull-to-Refresh + Inbox-Banner für
|
Pull-to-Refresh, voller Study-Loop mit Flip/Rating/Haptic +
|
||||||
Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün.
|
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`.
|
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
|
||||||
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
|
> 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)
|
- `LoginView` (Email/PW gegen mana-auth)
|
||||||
- 3 Unit-Tests (AppConfig)
|
- 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`)**
|
✅ **β-1 — Decks lesen (2026-05-13, Tag `v0.2.0`)**
|
||||||
- `Deck`-Codable-DTO mit snake_case-CodingKeys, plus
|
- `Deck`-Codable-DTO mit snake_case-CodingKeys, plus
|
||||||
`DeckCategory`, `DeckVisibility`, `FsrsSettings`
|
`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 |
|
| β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe |
|
||||||
| β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh |
|
| β-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) |
|
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
|
||||||
| β-4 | — | Media, image-occlusion (PencilKit), audio-front |
|
| β-4 | — | Media, image-occlusion (PencilKit), audio-front |
|
||||||
| β-5 | — | Marketplace, Universal-Links |
|
| β-5 | — | Marketplace, Universal-Links |
|
||||||
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
|
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
|
||||||
| β-7 | — | App-Store-Submission |
|
| β-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`
|
1. `DeckCreateView`: Form für Name, Description, Color (Picker),
|
||||||
2. `CardsAPI.dueCards(deckId:)` → fetcht `/reviews/due` + zugehörige
|
Category-Picker, Visibility, FSRS-Settings (Sheet)
|
||||||
`/cards/:id`-Details für die Karten-Inhalte
|
2. `CardEditorView` per Type (basic, cloze, typing, multiple-choice):
|
||||||
3. `StudySessionView` mit `CardRenderer`-switch (basic + basic-reverse
|
Two-Text-Fields oder Cloze-Syntax-Highlighting
|
||||||
+ cloze; cloze-Rendering kommt vom Server via `renderClozePrompt`)
|
3. POST/PATCH/DELETE `/api/v1/cards` und `/api/v1/decks`
|
||||||
4. Flip-Animation, Rating-Bar (`again | hard | good | easy`)
|
4. Anki-Import als Datei-Picker → `/api/v1/decks/import`
|
||||||
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)
|
|
||||||
|
|
||||||
**Erfolgskriterium:** 50 Karten am Stück im Simulator durchgraden,
|
**Erfolgskriterium:** Karte in Native erstellt, in Web sichtbar;
|
||||||
Web zeigt nach Refresh die gleichen Reviews-States.
|
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
|
## Cross-Refs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct CardsNativeApp: App {
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
do {
|
do {
|
||||||
container = try ModelContainer(for: CachedDeck.self)
|
container = try ModelContainer(for: CachedDeck.self, PendingGrade.self)
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to init ModelContainer: \(error)")
|
fatalError("Failed to init ModelContainer: \(error)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,46 @@ actor CardsAPI {
|
||||||
return try decoder.decode(DueReviewsResponse.self, from: data).total
|
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<T: Encodable>(_ value: T) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
return try encoder.encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
||||||
|
|
|
||||||
51
Sources/Core/Domain/Card.swift
Normal file
51
Sources/Core/Domain/Card.swift
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
78
Sources/Core/Domain/Cloze.swift
Normal file
78
Sources/Core/Domain/Cloze.swift
Normal file
|
|
@ -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<Int>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Sources/Core/Domain/Review.swift
Normal file
113
Sources/Core/Domain/Review.swift
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Sources/Core/Storage/PendingGrade.swift
Normal file
31
Sources/Core/Storage/PendingGrade.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
Sources/Core/Sync/GradeQueue.swift
Normal file
91
Sources/Core/Sync/GradeQueue.swift
Normal file
|
|
@ -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<PendingGrade>(
|
||||||
|
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<PendingGrade>()
|
||||||
|
return (try? context.fetchCount(descriptor)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,11 @@ struct DeckListView: View {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.navigationTitle("Decks")
|
.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 }
|
.toolbar { toolbar }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await store?.refresh()
|
await store?.refresh()
|
||||||
|
|
@ -112,10 +117,13 @@ struct DeckListView: View {
|
||||||
private var ownDecksSection: some View {
|
private var ownDecksSection: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(decks) { deck in
|
ForEach(decks) { deck in
|
||||||
DeckRow(deck: deck)
|
NavigationLink(value: deck.id) {
|
||||||
.listRowBackground(Color.clear)
|
DeckRow(deck: deck)
|
||||||
.listRowSeparator(.hidden)
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
.buttonStyle(.plain)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
Sources/Features/Study/CardRenderer.swift
Normal file
88
Sources/Features/Study/CardRenderer.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Sources/Features/Study/RatingBar.swift
Normal file
63
Sources/Features/Study/RatingBar.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
Sources/Features/Study/StudySession.swift
Normal file
92
Sources/Features/Study/StudySession.swift
Normal file
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
Sources/Features/Study/StudySessionView.swift
Normal file
165
Sources/Features/Study/StudySessionView.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Tests/UnitTests/ClozeTests.swift
Normal file
59
Tests/UnitTests/ClozeTests.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Tests/UnitTests/ReviewDecodingTests.swift
Normal file
83
Tests/UnitTests/ReviewDecodingTests.swift
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue