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)
|
||||
|
||||
**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/<id>, 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
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