From cf1160b270e04ea46f7e179c0dcef69368e8e7f6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 00:24:43 +0200 Subject: [PATCH] =?UTF-8?q?v0.4.0=20=E2=80=94=20Phase=20=CE=B2-3=20Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PLAN.md | 65 ++++-- Sources/Core/API/CardsAPI.swift | 78 +++++++ Sources/Core/Domain/CardMutations.swift | 56 +++++ Sources/Core/Domain/DeckMutations.swift | 38 ++++ Sources/Features/Decks/DeckDetailView.swift | 202 +++++++++++++++++++ Sources/Features/Decks/DeckListView.swift | 21 +- Sources/Features/Editor/CardEditorView.swift | 202 +++++++++++++++++++ Sources/Features/Editor/DeckEditorView.swift | 187 +++++++++++++++++ Tests/UnitTests/MutationEncodingTests.swift | 100 +++++++++ 9 files changed, 930 insertions(+), 19 deletions(-) create mode 100644 Sources/Core/Domain/CardMutations.swift create mode 100644 Sources/Core/Domain/DeckMutations.swift create mode 100644 Sources/Features/Decks/DeckDetailView.swift create mode 100644 Sources/Features/Editor/CardEditorView.swift create mode 100644 Sources/Features/Editor/DeckEditorView.swift create mode 100644 Tests/UnitTests/MutationEncodingTests.swift diff --git a/PLAN.md b/PLAN.md index 180b24e..09f4c09 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,10 +1,9 @@ # Plan — cards-native (SwiftUI Universal) -**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. +**Stand: 2026-05-13 — Phasen β-0 + β-1 + β-2 + β-3 abgeschlossen.** +Login, Deck-Liste mit Cache, Study-Loop mit Offline-Grade-Queue, +voller Editor-Flow (Deck Create/Edit/Delete + Card Create für 5 +Types). 24 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. @@ -28,6 +27,24 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `LoginView` (Email/PW gegen mana-auth) - 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`)** - `Card`, `Review`, `DueReview` Codable-DTOs, `CardType`-Enum (alle 7 Typen) - `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 | | β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh | | β-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 | | β-5 | — | Marketplace, Universal-Links | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-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), - 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` +1. Media-Upload via `POST /api/v1/media` (Multipart, 25 MiB max, + MinIO-Backend), PHPickerViewController für Foto-Auswahl +2. `audio-front`-Cards: AVAudioPlayer für Wiedergabe (Pattern aus + memoro-native) +3. `image-occlusion`-Renderer: SVG-Mask-Overlay über AsyncImage, + Tap auf Mask → Reveal +4. iPad-PencilKit-Editor für Image-Occlusion-Masks +5. `MediaCache` im FileManager (Caches/cards-media/, LRU 200 MB) +6. `CardEditorView` um image-occlusion + audio-front erweitern -**Erfolgskriterium:** Karte in Native erstellt, in Web sichtbar; -Karte in Web erstellt, in Native sichtbar (Pull-to-Refresh). +**Erfolgskriterium:** Karten mit Bildern und Audio aus Web-erstellten +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) diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index ee78f34..943d5d8 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -54,6 +54,84 @@ actor CardsAPI { 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 /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews diff --git a/Sources/Core/Domain/CardMutations.swift b/Sources/Core/Domain/CardMutations.swift new file mode 100644 index 0000000..bec4a71 --- /dev/null +++ b/Sources/Core/Domain/CardMutations.swift @@ -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] + } +} diff --git a/Sources/Core/Domain/DeckMutations.swift b/Sources/Core/Domain/DeckMutations.swift new file mode 100644 index 0000000..c40d62f --- /dev/null +++ b/Sources/Core/Domain/DeckMutations.swift @@ -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 + } +} diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift new file mode 100644 index 0000000..045f78c --- /dev/null +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -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 { $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) + } + } +} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index c389a8f..a1830a2 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -11,6 +11,7 @@ struct DeckListView: View { @State private var store: DeckListStore? @State private var showAccount = false + @State private var showCreate = false var body: some View { NavigationStack { @@ -20,14 +21,19 @@ struct DeckListView: View { } .navigationTitle("Decks") .navigationDestination(for: String.self) { deckId in - if let deck = decks.first(where: { $0.id == deckId }) { - StudySessionView(deckId: deck.id, deckName: deck.name) - } + DeckDetailView(deckId: deckId) } .toolbar { toolbar } .refreshable { await store?.refresh() } + .sheet(isPresented: $showCreate) { + NavigationStack { + DeckEditorView(mode: .create) { _ in + Task { await store?.refresh() } + } + } + } .task { if store == nil { store = DeckListStore(auth: auth, context: context) @@ -130,6 +136,15 @@ struct DeckListView: View { @ToolbarContentBuilder 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) { Button { showAccount = true diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift new file mode 100644 index 0000000..ad4379f --- /dev/null +++ b/Sources/Features/Editor/CardEditorView.swift @@ -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) + } +} diff --git a/Sources/Features/Editor/DeckEditorView.swift b/Sources/Features/Editor/DeckEditorView.swift new file mode 100644 index 0000000..6be1022 --- /dev/null +++ b/Sources/Features/Editor/DeckEditorView.swift @@ -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) + } +} diff --git a/Tests/UnitTests/MutationEncodingTests.swift b/Tests/UnitTests/MutationEncodingTests.swift new file mode 100644 index 0000000..1336ffe --- /dev/null +++ b/Tests/UnitTests/MutationEncodingTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Mutation Body Encoding") +struct MutationEncodingTests { + private func encode(_ 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) + } +}