import ManaCore import PhotosUI import SwiftUI #if canImport(UIKit) import UIKit #endif /// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. /// Deckt alle 7 Card-Types ab. 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? // Image-Occlusion-State @State private var imagePickerItem: PhotosPickerItem? @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 = "" // Audio-Front-State @State private var audioFileURL: URL? @State private var showAudioPicker = false private static let supportedTypes: [CardType] = [ .basic, .basicReverse, .cloze, .typing, .multipleChoice, .imageOcclusion, .audioFront, ] 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: imageOcclusionFields case .audioFront: audioFrontFields } } /// Extrahiert in eine eigene Property, weil PhotosPickers Label-Closure /// unter Swift-6-Strict-Concurrency den direkten Zugriff auf /// `@State`-Properties als Sendable-Verletzung markiert. Indirektion /// über eine MainActor-isolierte computed Property löst das. private var pickerLabel: some View { Group { if occlusionImage == nil { Label("Bild auswählen", systemImage: "photo") } else { Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") } } } @ViewBuilder private var imageOcclusionFields: some View { Section("Bild") { PhotosPicker(selection: $imagePickerItem, matching: .images) { pickerLabel } .onChange(of: imagePickerItem) { _, newItem in Task { await loadPickedImage(newItem) } } } if let image = occlusionImage { Section("Masken") { MaskEditorView(image: image, regions: $occlusionRegions) } } Section("Hinweis (optional)") { TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical) .lineLimit(1 ... 3) } Section { if occlusionImage == nil { Label("Erst Bild wählen", systemImage: "info.circle") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) } else if occlusionRegions.isEmpty { Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") .font(.caption) .foregroundStyle(CardsTheme.warning) } else { Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews", systemImage: "checkmark.circle.fill") .font(.caption) .foregroundStyle(CardsTheme.success) } } } @ViewBuilder private var audioFrontFields: some View { Section("Audio-Datei") { Button { showAudioPicker = true } label: { if let audioFileURL { Label(audioFileURL.lastPathComponent, systemImage: "waveform") } else { Label("Audio auswählen", systemImage: "waveform.badge.plus") } } .fileImporter( isPresented: $showAudioPicker, allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], allowsMultipleSelection: false ) { result in if case let .success(urls) = result, let first = urls.first { audioFileURL = first } } } Section("Antwort") { TextField("Was zu hören ist", text: $back, axis: .vertical) .lineLimit(2 ... 4) } } private func loadPickedImage(_ item: PhotosPickerItem?) async { guard let item else { return } do { guard let data = try await item.loadTransferable(type: Data.self) else { return } occlusionImageData = data occlusionMimeType = inferMimeType(from: data) if let img = PlatformImage(data: data) { occlusionImage = img occlusionRegions = [] // neue Bildauswahl resetet Masken } } catch { errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)" } } private func inferMimeType(from data: Data) -> String { // Schneller Magic-Byte-Check für die häufigsten Formate guard data.count > 4 else { return "image/jpeg" } let bytes = Array(data.prefix(8)) if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } // WebP: starts with "RIFF" + 4 bytes size + "WEBP" if bytes.count >= 8, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } return "image/jpeg" } private func audioMimeType(for url: URL) -> String { switch url.pathExtension.lowercased() { case "mp3": "audio/mpeg" case "wav": "audio/wav" case "m4a", "mp4": "audio/mp4" case "ogg", "oga": "audio/ogg" default: "audio/mpeg" } } 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 && !occlusionRegions.isEmpty case .audioFront: audioFileURL != nil && !back.trimmed.isEmpty } } private func submit() async { isSubmitting = true errorMessage = nil defer { isSubmitting = false } let api = CardsAPI(auth: auth) do { let fields: [String: String] var mediaRefs: [String]? = nil 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: guard let data = occlusionImageData else { return } let media = try await api.uploadMedia( data: data, filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")", mimeType: occlusionMimeType ) fields = CardFieldsBuilder.imageOcclusion( imageRef: media.id, regions: occlusionRegions, note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed ) mediaRefs = [media.id] case .audioFront: guard let url = audioFileURL else { return } let didStart = url.startAccessingSecurityScopedResource() defer { if didStart { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) let media = try await api.uploadMedia( data: data, filename: url.lastPathComponent, mimeType: audioMimeType(for: url) ) fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed) mediaRefs = [media.id] } let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs) 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) } }