feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF

Vervollständigt die Cardecky-Web-Parität für Deck- und Card-Workflows.

γ-1+γ-2 (AI-Deck-Generierung)
- 4-Modi-Picker im DeckEditorView Create-Sheet: Leer/KI/Bild/CSV
- POST /api/v1/decks/generate für Text-Prompt + 10/min Rate-Limit-UI
- POST /api/v1/decks/from-image mit PhotosPicker + PDF-Importer
  (max 5 Files, 10 MiB/Bild, 30 MiB/PDF), Multipart-Body in
  CardsAPI+Generation
- Loading-Overlay mit Task-Cancellation, Error-Mapping für 429/413/502

γ-3 (Card-Edit)
- CardEditorView mit Mode .create(deckId:) / .edit(card:)
- Image-Occlusion + Audio-Front behalten bestehenden Media-Ref, solange
  User nicht ersetzt — MediaCache lädt Bild nach
- Type-Picker im Edit-Modus aus (Server-immutable)
- CardEditorPayload + CardEditorMediaFields als Sub-Views

γ-4 (Pull-Update + Duplicate + Archive)
- POST /marketplace/private/:id/pull-update mit Smart-Merge-Anzeige
- POST /decks/:id/duplicate
- Archive-Toggle im Edit-Modus, Server filtert Liste serverseitig
- DeckSecondaryActions als eigenes Sub-View

γ-6 (CSV-Import)
- RFC-4180-ish Parser (Quote-Escape, Header-Detect, BOM-strip)
- Preview-Liste + sequentielle Card-Inserts mit Live-Progress
- Image-Occlusion/Audio-Front werden geskipped (UI flaggt)

γ-7 (Marketplace-Publish) + Follow-up (Report + Block + Re-Publish)
- MarketplacePublishView mit lazy Author-Setup + Init + Publish 1.0.0
- Re-Publish-Modus: Picker für eigene Marketplace-Decks +
  Auto-Semver-Bump (Minor +1)
- MarketplaceCardConverter (typing → type-in, audio-front → skipped,
  image-occlusion → skipped — Server hat keinen MP-Media-Re-Upload)
- Toolbar-Menü auf PublicDeckView: „Deck melden …" + Author-Blockieren
  (App-Store-Guideline 5.1.1(v))
- ReportDeckSheet mit Reason-Picker (6 Kategorien) + optional Message
- BlockedAuthorsView in Settings mit Swipe-Entblocken

γ-8 (PDF-Export)
- DeckPrintView mit SFSafariViewController auf
  cardecky.mana.how/decks/:id/print — iOS Share-Sheet → PDF speichern

Side-Fixes (mid-stream)
- StudySessionView: Card-Aspect-Ratio springt nicht mehr beim Flip
  (Bottom-Bar in ZStack fixer Höhe)
- RootView: Glass-Pille für „Neues Deck"-Accessory + .guest- und
  .twoFactorRequired-Cases nachgezogen
- DeckListView: Account-Toolbar-Button entfernt (Account-Tab unten
  ist alleinige Anlaufstelle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-14 02:03:59 +02:00
parent 8ca7bd3636
commit 73f9081fa1
26 changed files with 3419 additions and 442 deletions

View file

@ -1,125 +1,530 @@
import ManaCore
import PhotosUI
import SwiftUI
/// Deck-Create und Deck-Edit in einer View. `existing == nil` Create-
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
// 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: Sendable {
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 isSubmitting = false
@State private var errorMessage: String?
@State private var archived: Bool
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
/// freie Hex-Werte später via Picker setzen (β-3-extension).
private static let presetColors: [String] = [
"#10803D", // forest primary light
"#1E3A2F", // forest dark
"#D97706", // amber
"#DC2626", // red
"#2563EB", // blue
"#7C3AED", // violet
"#0D9488", // teal
"#737373", // neutral
]
/// 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<Void, Never>?
@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 ?? Self.presetColors[0])
_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 {
Form {
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(Self.presetColors, id: \.self) { hex in
colorSwatch(hex)
}
}
.padding(.vertical, 4)
ZStack {
Form {
if isCreate {
modePickerSection
}
formSections
errorSection
}
.disabled(isSubmitting)
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 errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
if isSubmitting, activeMode != .manual {
GenerationOverlay(
message: overlayMessage,
onCancel: { generationTask?.cancel() }
)
}
}
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
.navigationTitle(navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
}
.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
}
@ViewBuilder
private func colorSwatch(_ hex: String) -> some View {
let isSelected = color == hex
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<Bool>?
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)
@ -127,51 +532,200 @@ struct DeckEditorView: View {
Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
)
.onTapGesture { color = hex }
.onTapGesture(perform: onTap)
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
// MARK: - AI text form
do {
switch mode {
case .create:
let body = DeckCreateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.createDeck(body)
onSaved(deck)
dismiss()
case let .edit(deckId):
let body = DeckUpdateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.updateDeck(id: deckId, body: body)
onSaved(deck)
dismiss()
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("3500 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 }
}
}
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
} header: {
Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
}
}
private func nonEmpty(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
@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)
@ -179,9 +733,9 @@ extension Color {
guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary
}
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
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)
}
}