v0.4.0 — Phase β-3 Editor
Voller Editor-Flow für Decks und 5 Card-Types (basic, basic-reverse, cloze, typing, multiple-choice). image-occlusion + audio-front kommen mit β-4 (Media). Anki-Import bleibt vorerst aus (Web parsed client- side, gibt keinen Server-Import-Endpoint zu rufen). - DeckCreateBody/UpdateBody, CardCreateBody/UpdateBody Encodable mit snake_case-CodingKeys, nil-Felder werden weggelassen - CardFieldsBuilder mit Type-spezifischen Pflicht-Feld-Konstruktoren - CardsAPI: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard - DeckEditorView (Create + Edit in einer View): Color-Picker mit 8-Preset-Palette, Category-Picker (11 Kats, deutsche Labels), Visibility-Segmented-Control - CardEditorView mit Type-Picker und dynamischen Feldern je Typ. Cloze-Sektion zeigt Live-Cluster-Count und Hint-Syntax-Hinweis. image-occlusion/audio-front zeigen β-4-Placeholder - DeckDetailView mit Action-Buttons (Lernen, Karte hinzufügen, Bearbeiten, Löschen mit Confirmation) - DeckListView: "+"-Button im Toolbar (Leading) für Create-Sheet - 7 neue Encoding-Tests (24 Unit-Tests + 1 UI-Test grün) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3b861af3fb
commit
cf1160b270
9 changed files with 930 additions and 19 deletions
65
PLAN.md
65
PLAN.md
|
|
@ -1,10 +1,9 @@
|
||||||
# Plan — cards-native (SwiftUI Universal)
|
# Plan — cards-native (SwiftUI Universal)
|
||||||
|
|
||||||
**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 abgeschlossen.**
|
**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 + β-3 abgeschlossen.**
|
||||||
Repo auf Forgejo, Login funktioniert, Deck-Liste mit Cache +
|
Login, Deck-Liste mit Cache, Study-Loop mit Offline-Grade-Queue,
|
||||||
Pull-to-Refresh, voller Study-Loop mit Flip/Rating/Haptic +
|
voller Editor-Flow (Deck Create/Edit/Delete + Card Create für 5
|
||||||
Offline-Queue für Grades (PendingGrade SwiftData). Cloze client-
|
Types). 24 Unit-Tests + 1 UI-Test grün.
|
||||||
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
|
Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten
|
||||||
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
||||||
|
|
@ -28,6 +27,24 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
||||||
- `LoginView` (Email/PW gegen mana-auth)
|
- `LoginView` (Email/PW gegen mana-auth)
|
||||||
- 3 Unit-Tests (AppConfig)
|
- 3 Unit-Tests (AppConfig)
|
||||||
|
|
||||||
|
✅ **β-3 — Editor (2026-05-13, Tag `v0.4.0`)**
|
||||||
|
- `DeckCreateBody`, `DeckUpdateBody`, `CardCreateBody`, `CardUpdateBody`
|
||||||
|
Encodable-Structs (snake_case via `CodingKeys`, nil-Felder werden
|
||||||
|
weggelassen)
|
||||||
|
- `CardFieldsBuilder` mit Type-spezifischen Pflicht-Feld-Konstruktoren
|
||||||
|
- `CardsAPI`: createDeck/updateDeck/deleteDeck + createCard/updateCard/deleteCard
|
||||||
|
- `DeckEditorView` für Create + Edit in einer View (mode-switch),
|
||||||
|
Color-Picker mit 8-Preset-Palette aus forest-Theme, Category-Picker
|
||||||
|
(11 Kategorien mit deutschen Labels), Visibility-Segmented-Control
|
||||||
|
- `CardEditorView` mit Type-Picker (basic, basic-reverse, cloze,
|
||||||
|
typing, multiple-choice) und dynamischen Feldern je Typ. Cloze-View
|
||||||
|
zeigt Live-Cluster-Count und Hint-Syntax-Hinweis. image-occlusion
|
||||||
|
und audio-front zeigen β-4-Placeholder
|
||||||
|
- `DeckDetailView` mit 4 Action-Buttons (Lernen, Karte hinzufügen,
|
||||||
|
Bearbeiten, Löschen), Confirmation-Dialog für Delete
|
||||||
|
- DeckListView: "+"-Button im Toolbar (Leading), Sheet für Create
|
||||||
|
- 7 zusätzliche Encoding-Tests (24 Unit-Tests total)
|
||||||
|
|
||||||
✅ **β-2 — Study-Loop (2026-05-13, Tag `v0.3.0`)**
|
✅ **β-2 — Study-Loop (2026-05-13, Tag `v0.3.0`)**
|
||||||
- `Card`, `Review`, `DueReview` Codable-DTOs, `CardType`-Enum (alle 7 Typen)
|
- `Card`, `Review`, `DueReview` Codable-DTOs, `CardType`-Enum (alle 7 Typen)
|
||||||
- `Rating`-Enum: `again | hard | good | easy` mit deutschen Labels
|
- `Rating`-Enum: `again | hard | good | easy` mit deutschen Labels
|
||||||
|
|
@ -68,25 +85,41 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till.
|
||||||
| β-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 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) |
|
| β-2 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) |
|
||||||
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
|
| β-3 | ✅ 2026-05-13 | Editor: Deck-CRUD + Card-Create (5 Types); Anki-Import auf β-3-ext verschoben |
|
||||||
| β-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 β-3 (Editor)
|
## Nächste Schritte für β-4 (Media + Advanced Card-Types)
|
||||||
|
|
||||||
Aus Greenfield-Plan-Sektion "Phase β-3 — Card-/Deck-Editor":
|
Aus Greenfield-Plan-Sektion "Phase β-4":
|
||||||
|
|
||||||
1. `DeckCreateView`: Form für Name, Description, Color (Picker),
|
1. Media-Upload via `POST /api/v1/media` (Multipart, 25 MiB max,
|
||||||
Category-Picker, Visibility, FSRS-Settings (Sheet)
|
MinIO-Backend), PHPickerViewController für Foto-Auswahl
|
||||||
2. `CardEditorView` per Type (basic, cloze, typing, multiple-choice):
|
2. `audio-front`-Cards: AVAudioPlayer für Wiedergabe (Pattern aus
|
||||||
Two-Text-Fields oder Cloze-Syntax-Highlighting
|
memoro-native)
|
||||||
3. POST/PATCH/DELETE `/api/v1/cards` und `/api/v1/decks`
|
3. `image-occlusion`-Renderer: SVG-Mask-Overlay über AsyncImage,
|
||||||
4. Anki-Import als Datei-Picker → `/api/v1/decks/import`
|
Tap auf Mask → Reveal
|
||||||
|
4. iPad-PencilKit-Editor für Image-Occlusion-Masks
|
||||||
|
5. `MediaCache` im FileManager (Caches/cards-media/<id>, LRU 200 MB)
|
||||||
|
6. `CardEditorView` um image-occlusion + audio-front erweitern
|
||||||
|
|
||||||
**Erfolgskriterium:** Karte in Native erstellt, in Web sichtbar;
|
**Erfolgskriterium:** Karten mit Bildern und Audio aus Web-erstellten
|
||||||
Karte in Web erstellt, in Native sichtbar (Pull-to-Refresh).
|
Decks funktionieren in Native. Image-Occlusion in beide Richtungen
|
||||||
|
(Native↔Web) sichtbar.
|
||||||
|
|
||||||
|
## Verschoben auf β-3-Extension oder später
|
||||||
|
|
||||||
|
- **Anki-Import** (`.apkg`-Parser): Web parsed client-side und ruft
|
||||||
|
`POST /cards` pro Karte. Native bräuchte eigenen Swift-Parser für
|
||||||
|
Anki-Pakete (Plist/sqlite/.apkg) — eigener Brocken, nicht
|
||||||
|
blockierend für andere Phasen.
|
||||||
|
- **Card-Edit** (PATCH /cards/:id): Card-Create reicht für Web-Parität
|
||||||
|
in v1, Edit kann später nachgereicht werden.
|
||||||
|
- **Distractor-Vorschau** für Multiple-Choice-Editor: Server liefert
|
||||||
|
Distractors zur Lernzeit (`/decks/:deckId/distractors`), Editor
|
||||||
|
zeigt sie nicht — Web macht das auch nicht.
|
||||||
|
|
||||||
## Pflicht-Tests für β-2 (vor β-3-Start)
|
## Pflicht-Tests für β-2 (vor β-3-Start)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,84 @@ actor CardsAPI {
|
||||||
return try decoder.decode(DueReviewsResponse.self, from: data).total
|
return try decoder.decode(DueReviewsResponse.self, from: data).total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Deck-Mutations
|
||||||
|
|
||||||
|
/// `POST /api/v1/decks` — Deck anlegen.
|
||||||
|
@discardableResult
|
||||||
|
func createDeck(_ body: DeckCreateBody) async throws -> Deck {
|
||||||
|
let data = try makeJSON(body)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks",
|
||||||
|
method: "POST",
|
||||||
|
body: data
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(Deck.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/v1/decks/:id` — Deck-Felder ändern.
|
||||||
|
@discardableResult
|
||||||
|
func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck {
|
||||||
|
let data = try makeJSON(body)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks/\(id)",
|
||||||
|
method: "PATCH",
|
||||||
|
body: data
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(Deck.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/decks/:id` — Deck löschen (kaskadiert Cards + Reviews).
|
||||||
|
func deleteDeck(id: String) async throws {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks/\(id)",
|
||||||
|
method: "DELETE"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card-Mutations
|
||||||
|
|
||||||
|
/// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields`
|
||||||
|
/// gegen den Card-Type und erstellt automatisch Reviews
|
||||||
|
/// (1 für basic, 2 für basic-reverse, N für cloze).
|
||||||
|
@discardableResult
|
||||||
|
func createCard(_ body: CardCreateBody) async throws -> Card {
|
||||||
|
let data = try makeJSON(body)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/cards",
|
||||||
|
method: "POST",
|
||||||
|
body: data
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(Card.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/v1/cards/:id` — nur `fields` und `media_refs`
|
||||||
|
/// sind änderbar.
|
||||||
|
@discardableResult
|
||||||
|
func updateCard(id: String, body: CardUpdateBody) async throws -> Card {
|
||||||
|
let data = try makeJSON(body)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/cards/\(id)",
|
||||||
|
method: "PATCH",
|
||||||
|
body: data
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(Card.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/cards/:id` — Karte + zugehörige Reviews löschen
|
||||||
|
/// (Cascade auf DB-Ebene).
|
||||||
|
func deleteCard(id: String) async throws {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/cards/\(id)",
|
||||||
|
method: "DELETE"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Study
|
// MARK: - Study
|
||||||
|
|
||||||
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews
|
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews
|
||||||
|
|
|
||||||
56
Sources/Core/Domain/CardMutations.swift
Normal file
56
Sources/Core/Domain/CardMutations.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/cards`. Aus `CardCreateSchema`.
|
||||||
|
///
|
||||||
|
/// `fields` ist type-abhängig — Server validiert via
|
||||||
|
/// `validateFieldsForType()`. Pflicht-Keys pro Type:
|
||||||
|
/// - basic, basic-reverse: `front`, `back`
|
||||||
|
/// - 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, Sendable {
|
||||||
|
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` —
|
||||||
|
/// Type und deck_id sind immutable (Server-Schema).
|
||||||
|
struct CardUpdateBody: Encodable, Sendable {
|
||||||
|
var fields: [String: String]?
|
||||||
|
var mediaRefs: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fields
|
||||||
|
case mediaRefs = "media_refs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hilfs-Builder für Card-Type-spezifische `fields`-Dictionaries.
|
||||||
|
enum CardFieldsBuilder {
|
||||||
|
static func basic(front: String, back: String) -> [String: String] {
|
||||||
|
["front": front, "back": back]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cloze(text: String) -> [String: String] {
|
||||||
|
["text": text]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func typing(front: String, answer: String) -> [String: String] {
|
||||||
|
["front": front, "answer": answer]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func multipleChoice(front: String, answer: String) -> [String: String] {
|
||||||
|
["front": front, "answer": answer]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Sources/Core/Domain/DeckMutations.swift
Normal file
38
Sources/Core/Domain/DeckMutations.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
|
||||||
|
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
|
||||||
|
struct DeckCreateBody: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let category: DeckCategory?
|
||||||
|
let visibility: DeckVisibility?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case description
|
||||||
|
case color
|
||||||
|
case category
|
||||||
|
case visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
|
||||||
|
struct DeckUpdateBody: Encodable, Sendable {
|
||||||
|
var name: String?
|
||||||
|
var description: String?
|
||||||
|
var color: String?
|
||||||
|
var category: DeckCategory?
|
||||||
|
var visibility: DeckVisibility?
|
||||||
|
var archived: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case description
|
||||||
|
case color
|
||||||
|
case category
|
||||||
|
case visibility
|
||||||
|
case archived
|
||||||
|
}
|
||||||
|
}
|
||||||
202
Sources/Features/Decks/DeckDetailView.swift
Normal file
202
Sources/Features/Decks/DeckDetailView.swift
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen.
|
||||||
|
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet.
|
||||||
|
struct DeckDetailView: View {
|
||||||
|
let deckId: String
|
||||||
|
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Query private var decks: [CachedDeck]
|
||||||
|
|
||||||
|
@State private var showEditor = false
|
||||||
|
@State private var showCardEditor = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
@State private var navigateToStudy = false
|
||||||
|
@State private var deleteError: String?
|
||||||
|
|
||||||
|
init(deckId: String) {
|
||||||
|
self.deckId = deckId
|
||||||
|
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
CardsTheme.background.ignoresSafeArea()
|
||||||
|
if let deck = decks.first {
|
||||||
|
content(deck: deck)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(decks.first?.name ?? "")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.sheet(isPresented: $showEditor) {
|
||||||
|
NavigationStack {
|
||||||
|
DeckEditorView(
|
||||||
|
mode: .edit(deckId: deckId),
|
||||||
|
existing: decks.first
|
||||||
|
) { _ in
|
||||||
|
Task { await refreshAfterEdit() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCardEditor) {
|
||||||
|
NavigationStack {
|
||||||
|
CardEditorView(deckId: deckId) { _ in
|
||||||
|
Task { await refreshAfterEdit() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Deck löschen?",
|
||||||
|
isPresented: $showDeleteConfirm,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
Task { await delete() }
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $navigateToStudy) {
|
||||||
|
if let deck = decks.first {
|
||||||
|
StudySessionView(deckId: deck.id, deckName: deck.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func content(deck: CachedDeck) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header(deck: deck)
|
||||||
|
actions(deck: deck)
|
||||||
|
if let deleteError {
|
||||||
|
Text(deleteError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(deck: CachedDeck) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(deck.name)
|
||||||
|
.font(.title.bold())
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
if deck.isFromMarketplace {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let description = deck.deckDescription, !description.isEmpty {
|
||||||
|
Text(description)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack")
|
||||||
|
if deck.dueCount > 0 {
|
||||||
|
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
|
||||||
|
.foregroundStyle(CardsTheme.primary)
|
||||||
|
}
|
||||||
|
if let category = deck.category {
|
||||||
|
Text(category.label)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actions(deck: CachedDeck) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
navigateToStudy = true
|
||||||
|
} label: {
|
||||||
|
Label("Karten lernen", systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.primaryForeground)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(deck.dueCount == 0)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showCardEditor = true
|
||||||
|
} label: {
|
||||||
|
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
showEditor = true
|
||||||
|
} label: {
|
||||||
|
Label("Bearbeiten", systemImage: "pencil")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showDeleteConfirm = true
|
||||||
|
} label: {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAfterEdit() async {
|
||||||
|
let store = DeckListStore(auth: auth, context: context)
|
||||||
|
await store.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete() async {
|
||||||
|
deleteError = nil
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
try await api.deleteDeck(id: deckId)
|
||||||
|
// Cache nachziehen
|
||||||
|
if let deck = decks.first {
|
||||||
|
context.delete(deck)
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
deleteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ struct DeckListView: View {
|
||||||
|
|
||||||
@State private var store: DeckListStore?
|
@State private var store: DeckListStore?
|
||||||
@State private var showAccount = false
|
@State private var showAccount = false
|
||||||
|
@State private var showCreate = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -20,14 +21,19 @@ struct DeckListView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Decks")
|
.navigationTitle("Decks")
|
||||||
.navigationDestination(for: String.self) { deckId in
|
.navigationDestination(for: String.self) { deckId in
|
||||||
if let deck = decks.first(where: { $0.id == deckId }) {
|
DeckDetailView(deckId: deckId)
|
||||||
StudySessionView(deckId: deck.id, deckName: deck.name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await store?.refresh()
|
await store?.refresh()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showCreate) {
|
||||||
|
NavigationStack {
|
||||||
|
DeckEditorView(mode: .create) { _ in
|
||||||
|
Task { await store?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
store = DeckListStore(auth: auth, context: context)
|
store = DeckListStore(auth: auth, context: context)
|
||||||
|
|
@ -130,6 +136,15 @@ struct DeckListView: View {
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
private var toolbar: some ToolbarContent {
|
private var toolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
showCreate = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(CardsTheme.primary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Deck hinzufügen")
|
||||||
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showAccount = true
|
showAccount = true
|
||||||
|
|
|
||||||
202
Sources/Features/Editor/CardEditorView.swift
Normal file
202
Sources/Features/Editor/CardEditorView.swift
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
|
||||||
|
/// Deckt basic, basic-reverse, cloze, typing, multiple-choice ab —
|
||||||
|
/// image-occlusion und audio-front kommen in β-4 (brauchen Media).
|
||||||
|
struct CardEditorView: View {
|
||||||
|
let deckId: String
|
||||||
|
let onCreated: (Card) -> Void
|
||||||
|
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var type: CardType = .basic
|
||||||
|
@State private var front: String = ""
|
||||||
|
@State private var back: String = ""
|
||||||
|
@State private var clozeText: String = ""
|
||||||
|
@State private var typingAnswer: String = ""
|
||||||
|
@State private var multipleChoiceAnswer: String = ""
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
/// β-3-Card-Types (β-4 ergänzt image-occlusion + audio-front).
|
||||||
|
private static let supportedTypes: [CardType] = [
|
||||||
|
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Card-Type") {
|
||||||
|
Picker("Typ", selection: $type) {
|
||||||
|
ForEach(Self.supportedTypes, id: \.self) { t in
|
||||||
|
Text(label(for: t)).tag(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
typeFields
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Neue Karte")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Erstellen") { Task { await submit() } }
|
||||||
|
.disabled(!canSubmit || isSubmitting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var typeFields: some View {
|
||||||
|
switch type {
|
||||||
|
case .basic, .basicReverse:
|
||||||
|
Section("Vorderseite") {
|
||||||
|
TextField("Front", text: $front, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 6)
|
||||||
|
}
|
||||||
|
Section("Rückseite") {
|
||||||
|
TextField("Back", text: $back, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 6)
|
||||||
|
}
|
||||||
|
if type == .basicReverse {
|
||||||
|
Section {
|
||||||
|
Text("Beide Richtungen werden gelernt — front→back und back→front.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .cloze:
|
||||||
|
Section("Cloze-Text") {
|
||||||
|
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||||
|
text: $clozeText, axis: .vertical)
|
||||||
|
.lineLimit(3 ... 8)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.monospaced()
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
let count = Cloze.subIndexCount(clozeText)
|
||||||
|
if count > 0 {
|
||||||
|
Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.success)
|
||||||
|
} else {
|
||||||
|
Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.warning)
|
||||||
|
}
|
||||||
|
Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .typing:
|
||||||
|
Section("Frage") {
|
||||||
|
TextField("Front", text: $front, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 4)
|
||||||
|
}
|
||||||
|
Section("Erwartete Antwort") {
|
||||||
|
TextField("Answer", text: $typingAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .multipleChoice:
|
||||||
|
Section("Frage") {
|
||||||
|
TextField("Front", text: $front, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 4)
|
||||||
|
}
|
||||||
|
Section("Richtige Antwort") {
|
||||||
|
TextField("Answer", text: $multipleChoiceAnswer)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .imageOcclusion, .audioFront:
|
||||||
|
Section {
|
||||||
|
Label("Dieser Typ kommt in Phase β-4 (Media)", systemImage: "clock")
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
switch type {
|
||||||
|
case .basic, .basicReverse:
|
||||||
|
!front.trimmed.isEmpty && !back.trimmed.isEmpty
|
||||||
|
case .cloze:
|
||||||
|
Cloze.subIndexCount(clozeText) > 0
|
||||||
|
case .typing:
|
||||||
|
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
|
||||||
|
case .multipleChoice:
|
||||||
|
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
|
||||||
|
case .imageOcclusion, .audioFront:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSubmitting = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
|
||||||
|
let fields: [String: String]
|
||||||
|
switch type {
|
||||||
|
case .basic, .basicReverse:
|
||||||
|
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
|
||||||
|
case .cloze:
|
||||||
|
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
|
||||||
|
case .typing:
|
||||||
|
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
|
||||||
|
case .multipleChoice:
|
||||||
|
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
|
||||||
|
case .imageOcclusion, .audioFront:
|
||||||
|
return // disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: nil)
|
||||||
|
do {
|
||||||
|
let card = try await api.createCard(body)
|
||||||
|
onCreated(card)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(for type: CardType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .basic: "Einfach (Vorder/Rück)"
|
||||||
|
case .basicReverse: "Beidseitig"
|
||||||
|
case .cloze: "Lückentext"
|
||||||
|
case .typing: "Eintippen"
|
||||||
|
case .multipleChoice: "Multiple Choice"
|
||||||
|
case .imageOcclusion: "Bild-Verdeckung"
|
||||||
|
case .audioFront: "Audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var trimmed: String {
|
||||||
|
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
Sources/Features/Editor/DeckEditorView.swift
Normal file
187
Sources/Features/Editor/DeckEditorView.swift
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create-
|
||||||
|
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
|
||||||
|
struct DeckEditorView: View {
|
||||||
|
enum Mode: Sendable {
|
||||||
|
case create
|
||||||
|
case edit(deckId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
let onSaved: (Deck) -> Void
|
||||||
|
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var description: String
|
||||||
|
@State private var color: String
|
||||||
|
@State private var category: DeckCategory?
|
||||||
|
@State private var visibility: DeckVisibility
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
|
||||||
|
/// freie Hex-Werte später via Picker setzen (β-3-extension).
|
||||||
|
private static let presetColors: [String] = [
|
||||||
|
"#10803D", // forest primary light
|
||||||
|
"#1E3A2F", // forest dark
|
||||||
|
"#D97706", // amber
|
||||||
|
"#DC2626", // red
|
||||||
|
"#2563EB", // blue
|
||||||
|
"#7C3AED", // violet
|
||||||
|
"#0D9488", // teal
|
||||||
|
"#737373", // neutral
|
||||||
|
]
|
||||||
|
|
||||||
|
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
|
||||||
|
self.mode = mode
|
||||||
|
self.onSaved = onSaved
|
||||||
|
_name = State(initialValue: existing?.name ?? "")
|
||||||
|
_description = State(initialValue: existing?.deckDescription ?? "")
|
||||||
|
_color = State(initialValue: existing?.color ?? Self.presetColors[0])
|
||||||
|
_category = State(initialValue: existing?.category)
|
||||||
|
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Deck-Name", text: $name)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Beschreibung") {
|
||||||
|
TextField("optional", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Farbe") {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(Self.presetColors, id: \.self) { hex in
|
||||||
|
colorSwatch(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Kategorie") {
|
||||||
|
Picker("Kategorie", selection: $category) {
|
||||||
|
Text("Keine").tag(DeckCategory?.none)
|
||||||
|
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
||||||
|
Text(cat.label).tag(DeckCategory?.some(cat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Sichtbarkeit") {
|
||||||
|
Picker("Sichtbarkeit", selection: $visibility) {
|
||||||
|
Text("Privat").tag(DeckVisibility.private)
|
||||||
|
Text("Space").tag(DeckVisibility.space)
|
||||||
|
Text("Öffentlich").tag(DeckVisibility.public)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||||
|
Task { await submit() }
|
||||||
|
}
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCreate: Bool {
|
||||||
|
if case .create = mode { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func colorSwatch(_ hex: String) -> some View {
|
||||||
|
let isSelected = color == hex
|
||||||
|
Circle()
|
||||||
|
.fill(Color.swatchFromHex(hex))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
|
||||||
|
)
|
||||||
|
.onTapGesture { color = hex }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSubmitting = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch mode {
|
||||||
|
case .create:
|
||||||
|
let body = DeckCreateBody(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: nonEmpty(description),
|
||||||
|
color: color,
|
||||||
|
category: category,
|
||||||
|
visibility: visibility
|
||||||
|
)
|
||||||
|
let deck = try await api.createDeck(body)
|
||||||
|
onSaved(deck)
|
||||||
|
dismiss()
|
||||||
|
case let .edit(deckId):
|
||||||
|
let body = DeckUpdateBody(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: nonEmpty(description),
|
||||||
|
color: color,
|
||||||
|
category: category,
|
||||||
|
visibility: visibility
|
||||||
|
)
|
||||||
|
let deck = try await api.updateDeck(id: deckId, body: body)
|
||||||
|
onSaved(deck)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nonEmpty(_ s: String) -> String? {
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
static func swatchFromHex(_ hex: String) -> Color {
|
||||||
|
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
|
||||||
|
guard let rgb = UInt32(trimmed, radix: 16) else {
|
||||||
|
return CardsTheme.primary
|
||||||
|
}
|
||||||
|
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(rgb & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Tests/UnitTests/MutationEncodingTests.swift
Normal file
100
Tests/UnitTests/MutationEncodingTests.swift
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CardsNative
|
||||||
|
|
||||||
|
@Suite("Mutation Body Encoding")
|
||||||
|
struct MutationEncodingTests {
|
||||||
|
private func encode<T: Encodable>(_ value: T) throws -> [String: Any] {
|
||||||
|
let data = try JSONEncoder().encode(value)
|
||||||
|
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DeckCreateBody nutzt snake_case und lässt nil weg")
|
||||||
|
func deckCreateBody() throws {
|
||||||
|
let body = DeckCreateBody(
|
||||||
|
name: "Spanisch",
|
||||||
|
description: nil,
|
||||||
|
color: "#10803D",
|
||||||
|
category: .language,
|
||||||
|
visibility: .private
|
||||||
|
)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["name"] as? String == "Spanisch")
|
||||||
|
#expect(json["color"] as? String == "#10803D")
|
||||||
|
#expect(json["category"] as? String == "language")
|
||||||
|
#expect(json["visibility"] as? String == "private")
|
||||||
|
// description war nil — sollte nicht im JSON sein
|
||||||
|
#expect(json["description"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DeckUpdateBody kann archived: true setzen")
|
||||||
|
func deckUpdateBodyArchived() throws {
|
||||||
|
let body = DeckUpdateBody(archived: true)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["archived"] as? Bool == true)
|
||||||
|
#expect(json["name"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CardCreateBody für basic-Type")
|
||||||
|
func cardCreateBodyBasic() throws {
|
||||||
|
let body = CardCreateBody(
|
||||||
|
deckId: "deck_1",
|
||||||
|
type: .basic,
|
||||||
|
fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello"),
|
||||||
|
mediaRefs: nil
|
||||||
|
)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["deck_id"] as? String == "deck_1")
|
||||||
|
#expect(json["type"] as? String == "basic")
|
||||||
|
let fields = json["fields"] as? [String: String]
|
||||||
|
#expect(fields?["front"] == "Hallo")
|
||||||
|
#expect(fields?["back"] == "Hello")
|
||||||
|
#expect(json["media_refs"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CardCreateBody für basic-reverse Type-Name")
|
||||||
|
func cardCreateBodyBasicReverse() throws {
|
||||||
|
let body = CardCreateBody(
|
||||||
|
deckId: "d",
|
||||||
|
type: .basicReverse,
|
||||||
|
fields: CardFieldsBuilder.basic(front: "a", back: "b"),
|
||||||
|
mediaRefs: nil
|
||||||
|
)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["type"] as? String == "basic-reverse")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CardCreateBody für cloze")
|
||||||
|
func cardCreateBodyCloze() throws {
|
||||||
|
let body = CardCreateBody(
|
||||||
|
deckId: "d",
|
||||||
|
type: .cloze,
|
||||||
|
fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint."),
|
||||||
|
mediaRefs: nil
|
||||||
|
)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["type"] as? String == "cloze")
|
||||||
|
let fields = json["fields"] as? [String: String]
|
||||||
|
#expect(fields?["text"] == "Die {{c1::Sonne}} scheint.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CardCreateBody multiple-choice Type-Name")
|
||||||
|
func cardCreateBodyMultipleChoice() throws {
|
||||||
|
let body = CardCreateBody(
|
||||||
|
deckId: "d",
|
||||||
|
type: .multipleChoice,
|
||||||
|
fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A"),
|
||||||
|
mediaRefs: nil
|
||||||
|
)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect(json["type"] as? String == "multiple-choice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("CardUpdateBody nur mit fields")
|
||||||
|
func cardUpdateBodyFieldsOnly() throws {
|
||||||
|
let body = CardUpdateBody(fields: ["front": "neu"], mediaRefs: nil)
|
||||||
|
let json = try encode(body)
|
||||||
|
#expect((json["fields"] as? [String: String])?["front"] == "neu")
|
||||||
|
#expect(json["media_refs"] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue