import ManaCore import PhotosUI import SwiftUI // swiftlint:disable file_length // swiftlint:disable type_body_length /// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier /// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI"), AI-Vision /// („Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular. /// /// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`. /// `type_body_length` ist bewusst übersprungen — die 4 Sub-Modi teilen /// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing. struct DeckEditorView: View { enum Mode { case create case edit(deckId: String) } /// Vier Sub-Modi im Create-Sheet. enum CreateMode: Hashable { case manual case aiText case aiMedia case csv } let mode: Mode let onSaved: (Deck) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss // Manual fields (Edit + Create.manual) @State private var name: String @State private var description: String @State private var color: String @State private var category: DeckCategory? @State private var visibility: DeckVisibility @State private var archived: Bool /// Create-mode selector @State private var createMode: CreateMode = .manual // AI-shared (Text + Media) @State private var aiPrompt: String = "" @State private var aiCount: Int = 15 @State private var aiLanguage: GenerationLanguage = .de @State private var aiUrl: String = "" // AI-Media @State private var aiMediaFiles: [GenerationMediaFile] = [] @State private var aiPhotoItems: [PhotosPickerItem] = [] @State private var showPDFImporter: Bool = false // CSV-Import @State private var csvRows: [CSVRow] = [] @State private var csvDeckName: String = "" @State private var showCSVImporter: Bool = false @State private var csvImportProgress: Int = 0 // Submission @State private var isSubmitting = false @State private var generationTask: Task? @State private var errorMessage: String? init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { self.mode = mode self.onSaved = onSaved _name = State(initialValue: existing?.name ?? "") _description = State(initialValue: existing?.deckDescription ?? "") _color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0]) _category = State(initialValue: existing?.category) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) _archived = State(initialValue: existing?.archivedAt != nil) } var body: some View { ZStack { Form { if isCreate { modePickerSection } formSections errorSection } .disabled(isSubmitting) if isSubmitting, activeMode != .manual { GenerationOverlay( message: overlayMessage, onCancel: { generationTask?.cancel() } ) } } .navigationTitle(navTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { toolbar } .onChange(of: aiPhotoItems) { _, items in guard !items.isEmpty else { return } Task { await ingestPhotoItems(items) } } .fileImporter( isPresented: $showPDFImporter, allowedContentTypes: [.pdf], allowsMultipleSelection: true, onCompletion: handlePDFImport ) .fileImporter( isPresented: $showCSVImporter, allowedContentTypes: [.commaSeparatedText, .plainText], allowsMultipleSelection: false, onCompletion: handleCSVImport ) } // MARK: - Sections private var modePickerSection: some View { Section { Picker("Modus", selection: $createMode) { Text("Leer").tag(CreateMode.manual) Text("KI").tag(CreateMode.aiText) Text("Bild").tag(CreateMode.aiMedia) Text("CSV").tag(CreateMode.csv) } .pickerStyle(.segmented) } footer: { modeFooter } } @ViewBuilder private var modeFooter: some View { switch createMode { case .manual: Text("Leeres Deck — Karten anschließend selbst anlegen.") case .aiText: Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.") case .aiMedia: Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.") case .csv: Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.") } } @ViewBuilder private var formSections: some View { switch activeMode { case .manual: ManualFormSections( name: $name, description: $description, color: $color, category: $category, visibility: $visibility, archived: isCreate ? nil : $archived ) case .aiText: AITextFormSections(prompt: $aiPrompt) AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) case .aiMedia: AIMediaFormSections( files: $aiMediaFiles, photoItems: $aiPhotoItems, showPDFImporter: $showPDFImporter ) AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) case .csv: CSVImportFormSections( rows: $csvRows, deckName: $csvDeckName, showImporter: $showCSVImporter ) } } @ViewBuilder private var errorSection: some View { if let errorMessage { Section { Text(errorMessage) .font(.footnote) .foregroundStyle(CardsTheme.error) } } } // MARK: - Toolbar @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { generationTask?.cancel() dismiss() } } ToolbarItem(placement: .confirmationAction) { Button(confirmLabel) { startSubmit() } .disabled(!canSubmit || isSubmitting) } } // MARK: - Computed state private var isCreate: Bool { if case .create = mode { return true } return false } private var activeMode: CreateMode { isCreate ? createMode : .manual } private var navTitle: String { switch activeMode { case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten" case .aiText: "Mit KI generieren" case .aiMedia: "Aus Bild generieren" case .csv: "Aus CSV importieren" } } private var confirmLabel: String { switch activeMode { case .manual: isCreate ? "Erstellen" : "Speichern" case .aiText, .aiMedia: "Generieren" case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren" } } private var canSubmit: Bool { switch activeMode { case .manual: !name.trimmingCharacters(in: .whitespaces).isEmpty case .aiText: aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3 case .aiMedia: !aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl) case .csv: !csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty } } private var overlayMessage: String { switch activeMode { case .csv: csvImportProgress > 0 ? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …" : "Import wird vorbereitet …" default: "Karten werden generiert …" } } // MARK: - Photo / PDF ingest private func ingestPhotoItems(_ items: [PhotosPickerItem]) async { for item in items { if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } do { guard let data = try await item.loadTransferable(type: Data.self) else { continue } guard data.count <= DeckEditorPresets.maxImageBytes else { errorMessage = "Bild ist größer als 10 MB und wurde übersprungen." continue } let mime = DeckEditorHelpers.inferImageMimeType(from: data) let ext = DeckEditorHelpers.imageExtension(forMime: mime) let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)" aiMediaFiles.append(GenerationMediaFile( data: data, filename: filename, mimeType: mime )) } catch { errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)" } } aiPhotoItems = [] } private func handleCSVImport(_ result: Result<[URL], Error>) { switch result { case let .success(urls): guard let url = urls.first else { return } let didStart = url.startAccessingSecurityScopedResource() defer { if didStart { url.stopAccessingSecurityScopedResource() } } do { let text = try String(contentsOf: url, encoding: .utf8) let rows = try CSVParser.parse(text) csvRows = rows if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty { csvDeckName = url.deletingPathExtension().lastPathComponent } } catch { errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)" } case let .failure(error): errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)" } } private func handlePDFImport(_ result: Result<[URL], Error>) { switch result { case let .success(urls): for url in urls { if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } let didStart = url.startAccessingSecurityScopedResource() defer { if didStart { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) guard data.count <= DeckEditorPresets.maxPDFBytes else { errorMessage = "\(url.lastPathComponent) ist größer als 30 MB." continue } aiMediaFiles.append(GenerationMediaFile( data: data, filename: url.lastPathComponent, mimeType: "application/pdf" )) } catch { errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)" } } case let .failure(error): errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)" } } // MARK: - Submit private func startSubmit() { errorMessage = nil isSubmitting = true generationTask = Task { await submit() isSubmitting = false generationTask = nil } } private func submit() async { let api = CardsAPI(auth: auth) do { switch (mode, activeMode) { case (.create, .manual): let deck = try await api.createDeck(manualCreateBody) onSaved(deck) dismiss() case let (.edit(deckId), _): let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody) onSaved(deck) dismiss() case (.create, .aiText): let response = try await api.generateDeckFromText(aiTextBody) try Task.checkCancellation() onSaved(response.deck) dismiss() case (.create, .aiMedia): let response = try await api.generateDeckFromMedia( files: aiMediaFiles, language: aiLanguage, count: aiCount, url: DeckEditorHelpers.nonEmpty(aiUrl) ) try Task.checkCancellation() onSaved(response.deck) dismiss() case (.create, .csv): let deck = try await submitCSVImport(api: api) onSaved(deck) dismiss() } } catch is CancellationError { // User-Abbruch → kein Banner. } catch let error as AuthError { errorMessage = DeckEditorHelpers.mapAIError(error) } catch { errorMessage = error.localizedDescription } } private var manualCreateBody: DeckCreateBody { DeckCreateBody( name: name.trimmingCharacters(in: .whitespaces), description: DeckEditorHelpers.nonEmpty(description), color: color, category: category, visibility: visibility ) } private var manualUpdateBody: DeckUpdateBody { DeckUpdateBody( name: name.trimmingCharacters(in: .whitespaces), description: DeckEditorHelpers.nonEmpty(description), color: color, category: category, visibility: visibility, archived: archived ) } private func submitCSVImport(api: CardsAPI) async throws -> Deck { let deck = try await api.createDeck(DeckCreateBody( name: csvDeckName.trimmingCharacters(in: .whitespaces), description: "Aus CSV-Import (\(csvRows.count) Karten)", color: color, category: category, visibility: visibility )) csvImportProgress = 0 for (index, row) in csvRows.enumerated() { try Task.checkCancellation() let fields: [String: String] switch row.type { case .basic, .basicReverse: fields = CardFieldsBuilder.basic(front: row.front, back: row.back) case .cloze: fields = CardFieldsBuilder.cloze(text: row.front) case .typing: fields = CardFieldsBuilder.typing(front: row.front, answer: row.back) case .multipleChoice: fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) case .imageOcclusion, .audioFront: // Media-Types brauchen Uploads — überspringe in CSV-Import. csvImportProgress = index + 1 continue } _ = try await api.createCard(CardCreateBody( deckId: deck.id, type: row.type, fields: fields, mediaRefs: nil )) csvImportProgress = index + 1 } return deck } private var aiTextBody: DeckGenerateBody { DeckGenerateBody( prompt: aiPrompt.trimmingCharacters(in: .whitespaces), language: aiLanguage, count: aiCount, url: DeckEditorHelpers.nonEmpty(aiUrl) ) } } // swiftlint:enable type_body_length // MARK: - Manual form private struct ManualFormSections: View { @Binding var name: String @Binding var description: String @Binding var color: String @Binding var category: DeckCategory? @Binding var visibility: DeckVisibility /// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt. var archived: Binding? var body: some View { Section("Name") { TextField("Deck-Name", text: $name) .textInputAutocapitalization(.sentences) } Section("Beschreibung") { TextField("optional", text: $description, axis: .vertical) .lineLimit(2 ... 4) } Section("Farbe") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(DeckEditorPresets.colors, id: \.self) { hex in ColorSwatchButton(hex: hex, isSelected: color == hex) { color = hex } } } .padding(.vertical, 4) } } Section("Kategorie") { Picker("Kategorie", selection: $category) { Text("Keine").tag(DeckCategory?.none) ForEach(DeckCategory.allCases, id: \.self) { cat in Text(cat.label).tag(DeckCategory?.some(cat)) } } } Section("Sichtbarkeit") { Picker("Sichtbarkeit", selection: $visibility) { Text("Privat").tag(DeckVisibility.private) Text("Space").tag(DeckVisibility.space) Text("Öffentlich").tag(DeckVisibility.public) } .pickerStyle(.segmented) } if let archived { Section { Toggle("Archiviert", isOn: archived) } footer: { Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.") } } } } private struct ColorSwatchButton: View { let hex: String let isSelected: Bool let onTap: () -> Void var body: some View { Circle() .fill(Color.swatchFromHex(hex)) .frame(width: 36, height: 36) .overlay( Circle() .stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) ) .onTapGesture(perform: onTap) } } // MARK: - AI text form private struct AITextFormSections: View { @Binding var prompt: String var body: some View { Section { TextField( "z.B. Bodensee-Geographie, französische Verben", text: $prompt, axis: .vertical ) .lineLimit(3 ... 6) .textInputAutocapitalization(.sentences) } header: { Text("Thema") } footer: { Text("3–500 Zeichen. Je präziser, desto besser die Karten.") } } } // MARK: - AI media form private struct AIMediaFormSections: View { @Binding var files: [GenerationMediaFile] @Binding var photoItems: [PhotosPickerItem] @Binding var showPDFImporter: Bool var body: some View { Section { mediaPickers ForEach(files) { file in MediaFileRow(file: file) { files.removeAll { $0.id == file.id } } } } header: { Text("Quellen") } footer: { Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.") } } @ViewBuilder private var mediaPickers: some View { let remaining = DeckEditorPresets.maxMediaFiles - files.count PhotosPicker( selection: $photoItems, maxSelectionCount: max(remaining, 0), matching: .images ) { Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled") } .disabled(remaining <= 0) Button { showPDFImporter = true } label: { Label("PDFs hinzufügen", systemImage: "doc.text") } .disabled(remaining <= 0) } } private struct MediaFileRow: View { let file: GenerationMediaFile let onRemove: () -> Void var body: some View { HStack(spacing: 12) { thumbnail .frame(width: 40, height: 40) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) VStack(alignment: .leading, spacing: 2) { Text(file.filename) .font(.subheadline) .lineLimit(1) Text(file.sizeLabel) .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) } Spacer() Button(action: onRemove) { Image(systemName: "xmark.circle.fill") .foregroundStyle(CardsTheme.mutedForeground) } .buttonStyle(.plain) .accessibilityLabel("Entfernen") } } @ViewBuilder private var thumbnail: some View { if file.isPDF { ZStack { CardsTheme.muted Image(systemName: "doc.text.fill") .foregroundStyle(CardsTheme.primary) } } else if let img = PlatformImage(data: file.data) { #if canImport(UIKit) Image(uiImage: img) .resizable() .scaledToFill() #else Image(nsImage: img) .resizable() .scaledToFill() #endif } else { CardsTheme.muted } } } // MARK: - Shared AI controls private struct AISharedSections: View { @Binding var count: Int @Binding var language: GenerationLanguage @Binding var url: String var body: some View { Section("Anzahl Karten") { Stepper(value: $count, in: 3 ... 40) { Text("\(count) Karten") } } Section("Sprache") { Picker("Sprache", selection: $language) { ForEach(GenerationLanguage.allCases, id: \.self) { lang in Text(lang.label).tag(lang) } } .pickerStyle(.segmented) } Section { TextField("https://…", text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.URL) } header: { Text("Zusätzliche URL (optional)") } footer: { Text("KI liest den Inhalt der Seite als zusätzliche Quelle.") } } } // MARK: - Generation overlay private struct GenerationOverlay: View { let message: String let onCancel: () -> Void var body: some View { ZStack { Color.black.opacity(0.55) .ignoresSafeArea() VStack(spacing: 16) { ProgressView() .controlSize(.large) .tint(CardsTheme.primary) Text(message) .font(.headline) .foregroundStyle(CardsTheme.foreground) .multilineTextAlignment(.center) Text("Das kann eine Weile dauern.") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) .multilineTextAlignment(.center) Button("Abbrechen", action: onCancel) .buttonStyle(.bordered) .tint(CardsTheme.mutedForeground) .padding(.top, 4) } .padding(24) .frame(maxWidth: 320) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } .transition(.opacity) } } // MARK: - Color helper extension Color { static func swatchFromHex(_ hex: String) -> Color { var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) } guard let rgb = UInt32(trimmed, radix: 16) else { return CardsTheme.primary } let red = Double((rgb >> 16) & 0xFF) / 255.0 let green = Double((rgb >> 8) & 0xFF) / 255.0 let blue = Double(rgb & 0xFF) / 255.0 return Color(red: red, green: green, blue: blue) } }