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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue