import ManaCore import SwiftUI #if canImport(UIKit) import UIKit #endif // swiftlint:disable type_body_length /// 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. /// /// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende /// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt. 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? // Image-Occlusion-State @State private var occlusionImage: PlatformImage? @State private var occlusionImageData: Data? @State private var occlusionMimeType: String = "image/jpeg" @State private var occlusionRegions: [MaskRegion] @State private var occlusionNote: String /// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten, /// solange der User kein neues Bild wählt. @State private var existingImageRef: String? /// Audio-Front-State @State private var audioFileURL: URL? /// Bestehender `audio_ref` aus der Card im Edit-Modus. @State private var existingAudioRef: String? private static let supportedTypes: [CardType] = [ .basic, .basicReverse, .cloze, .typing, .multipleChoice, .imageOcclusion, .audioFront ] 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 = "" var initialRegions: [MaskRegion] = [] var initialNote = "" var initialImageRef: String? var initialAudioRef: String? 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"] ?? "" case .imageOcclusion: initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]") initialNote = card.fields["note"] ?? "" initialImageRef = card.fields["image_ref"] case .audioFront: initialBack = card.fields["back"] ?? "" initialAudioRef = card.fields["audio_ref"] } } _type = State(initialValue: initialType) _front = State(initialValue: initialFront) _back = State(initialValue: initialBack) _clozeText = State(initialValue: initialCloze) _typingAnswer = State(initialValue: initialTyping) _multipleChoiceAnswer = State(initialValue: initialMC) _occlusionRegions = State(initialValue: initialRegions) _occlusionNote = State(initialValue: initialNote) _existingImageRef = State(initialValue: initialImageRef) _existingAudioRef = State(initialValue: initialAudioRef) } 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(CardsTheme.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(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: ImageOcclusionFields( image: $occlusionImage, imageData: $occlusionImageData, mimeType: $occlusionMimeType, regions: $occlusionRegions, note: $occlusionNote, existingImageRef: $existingImageRef, onLoadError: { errorMessage = $0 } ) case .audioFront: AudioFrontFields( audioFileURL: $audioFileURL, back: $back, existingAudioRef: existingAudioRef ) } } private var isCreate: Bool { if case .create = mode { return true } return false } private var deckId: String { switch mode { case let .create(deckId): deckId case let .edit(card): card.deckId } } private var existingMediaRefs: [String] { if case let .edit(card) = mode { return card.mediaRefs } return [] } 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: (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty case .audioFront: (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty } } // MARK: - Submit private func submit() async { isSubmitting = true errorMessage = nil defer { isSubmitting = false } let api = CardsAPI(auth: auth) do { let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api) let card: Card = switch mode { case let .create(deckId): try await api.createCard(CardCreateBody( deckId: deckId, type: type, fields: payload.fields, mediaRefs: payload.mediaRefs )) case let .edit(existing): try await api.updateCard(id: existing.id, body: CardUpdateBody( fields: payload.fields, mediaRefs: payload.mediaRefs )) } onSaved(card) dismiss() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } private var payloadInputs: CardEditorPayloadInputs { CardEditorPayloadInputs( type: type, front: front.trimmed, back: back.trimmed, clozeText: clozeText.trimmed, typingAnswer: typingAnswer.trimmed, multipleChoiceAnswer: multipleChoiceAnswer.trimmed, occlusionImageData: occlusionImageData, occlusionMimeType: occlusionMimeType, occlusionRegions: occlusionRegions, occlusionNote: occlusionNote.trimmed, existingImageRef: existingImageRef, audioFileURL: audioFileURL, existingAudioRef: existingAudioRef, existingMediaRefs: existingMediaRefs ) } 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" } } } // swiftlint:enable type_body_length private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } }