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:
Till JS 2026-05-13 00:24:43 +02:00
parent 3b861af3fb
commit cf1160b270
9 changed files with 930 additions and 19 deletions

View 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)
}
}