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>
202 lines
7.1 KiB
Swift
202 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|