import ManaCore import SwiftUI /// Card-Create und Card-Edit in einer View. /// /// - `.create(deckId:)` zeigt Type-Picker + leere Felder. /// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable), /// pre-fillt alle Felder, und PATCHt auf Submit. struct CardEditorView: View { enum Mode { case create(deckId: String) case edit(card: Card) } let mode: Mode let onSaved: (Card) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss @State private var type: CardType @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? private static let supportedTypes: [CardType] = [ .basic, .basicReverse, .cloze, .typing, .multipleChoice ] init(mode: Mode, onSaved: @escaping (Card) -> Void) { self.mode = mode self.onSaved = onSaved let initialType: CardType var initialFront = "" var initialBack = "" var initialCloze = "" var initialTyping = "" var initialMC = "" switch mode { case .create: initialType = .basic case let .edit(card): initialType = card.type switch card.type { case .basic, .basicReverse: initialFront = card.fields["front"] ?? "" initialBack = card.fields["back"] ?? "" case .cloze: initialCloze = card.fields["text"] ?? "" case .typing: initialFront = card.fields["front"] ?? "" initialTyping = card.fields["answer"] ?? "" case .multipleChoice: initialFront = card.fields["front"] ?? "" initialMC = card.fields["answer"] ?? "" } } _type = State(initialValue: initialType) _front = State(initialValue: initialFront) _back = State(initialValue: initialBack) _clozeText = State(initialValue: initialCloze) _typingAnswer = State(initialValue: initialTyping) _multipleChoiceAnswer = State(initialValue: initialMC) } var body: some View { Form { if isCreate { Section("Card-Type") { Picker("Typ", selection: $type) { ForEach(Self.supportedTypes, id: \.self) { cardType in Text(label(for: cardType)).tag(cardType) } } .pickerStyle(.menu) } } typeFields if let errorMessage { Section { Text(errorMessage) .font(.footnote) .foregroundStyle(WordeckTheme.error) } } } .disabled(isSubmitting) .navigationTitle(isCreate ? "Neue Karte" : "Karte 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(!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(WordeckTheme.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(WordeckTheme.success) } else { Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle") .font(.caption) .foregroundStyle(WordeckTheme.warning) } Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`") .font(.caption2) .foregroundStyle(WordeckTheme.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(WordeckTheme.mutedForeground) } } } private var isCreate: Bool { if case .create = mode { return true } return false } 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 } } private func buildFields() -> [String: String] { switch type { case .basic, .basicReverse: CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) case .cloze: CardFieldsBuilder.cloze(text: clozeText.trimmed) case .typing: CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) case .multipleChoice: CardFieldsBuilder.multipleChoice( front: front.trimmed, answer: multipleChoiceAnswer.trimmed ) } } private func submit() async { isSubmitting = true errorMessage = nil defer { isSubmitting = false } let api = WordeckAPI(auth: auth) let fields = buildFields() do { let card: Card = switch mode { case let .create(deckId): try await api.createCard(CardCreateBody( deckId: deckId, type: type, fields: fields )) case let .edit(existing): try await api.updateCard(id: existing.id, body: CardUpdateBody(fields: fields)) } onSaved(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" } } } private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } }