import ManaCore import SwiftUI // swiftlint:disable file_length // swiftlint:disable type_body_length /// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen drei /// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI") und CSV. /// Edit-Modus zeigt nur das manuelle Formular. /// /// Web-Vorbild: `wordeck/apps/web/src/routes/decks/new/+page.svelte`. struct DeckEditorView: View { enum Mode { case create case edit(deckId: String) } /// Drei Sub-Modi im Create-Sheet. enum CreateMode: Hashable { case manual case aiText 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-Text @State private var aiPrompt: String = "" @State private var aiCount: Int = 15 @State private var aiLanguage: GenerationLanguage = .de @State private var aiUrl: String = "" // 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 } .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("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 .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 .csv: CSVImportFormSections( rows: $csvRows, deckName: $csvDeckName, showImporter: $showCSVImporter ) } } @ViewBuilder private var errorSection: some View { if let errorMessage { Section { Text(errorMessage) .font(.footnote) .foregroundStyle(WordeckTheme.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 .csv: "Aus CSV importieren" } } private var confirmLabel: String { switch activeMode { case .manual: isCreate ? "Erstellen" : "Speichern" case .aiText: "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 .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: - CSV ingest 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)" } } // MARK: - Submit private func startSubmit() { errorMessage = nil isSubmitting = true generationTask = Task { await submit() isSubmitting = false generationTask = nil } } private func submit() async { let api = WordeckAPI(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, .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: WordeckAPI) 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: CardFieldsBuilder.basic(front: row.front, back: row.back) case .cloze: CardFieldsBuilder.cloze(text: row.front) case .typing: CardFieldsBuilder.typing(front: row.front, answer: row.back) case .multipleChoice: CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) } _ = try await api.createCard(CardCreateBody( deckId: deck.id, type: row.type, fields: fields )) 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 ? WordeckTheme.foreground : WordeckTheme.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: - 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(WordeckTheme.primary) Text(message) .font(.headline) .foregroundStyle(WordeckTheme.foreground) .multilineTextAlignment(.center) Text("Das kann eine Weile dauern.") .font(.caption) .foregroundStyle(WordeckTheme.mutedForeground) .multilineTextAlignment(.center) Button("Abbrechen", action: onCancel) .buttonStyle(.bordered) .tint(WordeckTheme.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 WordeckTheme.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) } }