Drei zusammenhängende Blöcke in einem Commit (Files überlappen sich
zwischen den Themen — sauberer Split nicht ohne Friktion möglich):
1. Wordeck-Text-Only-Cleanup
Image-Occlusion + Audio-Front-Code raus. Server ist seit Migration
0004_wordeck_text_only.sql text-only (in Prod waren 0 Karten der
Typen, 0 Media-Files). Native-Code war Build-11-Altlast.
- Gelöscht: MediaCache, MediaEnvironment, RemoteImage,
AudioPlayerButton, MaskEditorView, CardEditorMediaFields,
CardEditorPayload, Media.swift
- CardType-Enum auf 5 Werte: basic / basic-reverse / cloze /
typing / multiple-choice
- media_refs aus Card, CardCreateBody, CardUpdateBody, call-sites
- WordeckAPI.uploadMedia / .fetchMedia / .deleteMedia + Single-File-
makeMultipartBody gestrichen
- MarketplaceCardConverter ohne Media-Cases
- CardRenderer ohne imageOcclusionView / audioFrontView
2. AI-Media-Mode raus
/decks/from-image-Endpoint existiert serverseitig nicht (server
registriert nur /decks/generate für Text-Prompts). Native-Aufrufe
wären 404 — toter Code.
- aiMedia-Case aus DeckEditorView.CreateMode, ModePicker auf
3 Optionen (Leer / KI / CSV)
- AIMediaFormSections, MediaFileRow, mediaPickers, thumbnail,
ingestPhotoItems, handlePDFImport raus
- generateDeckFromMedia + makeFromImageMultipartBody raus
- GenerationMediaFile-Struct + PhotosUI-Import + PlatformImage-
typealias raus
- NSPhotoLibraryUsageDescription aus project.yml entfernt (es gibt
keinen Photo-Library-Zugriff mehr)
- maxMediaFiles/maxImageBytes/maxPDFBytes + inferImageMimeType +
imageExtension aus DeckEditorHelpers raus
3. ζ-1 Offline-Sync
Konzept in docs/OFFLINE_SYNC.md. Server-authoritative-FSRS bleibt —
kein lokales FSRS, nur Snapshot-Modell.
- Neue SwiftData-Models: CachedCard + CachedDueReview, beide mit
userId/deckId-Indizes
- ModelContainer um die zwei Models erweitert (additive Migration,
sollte automatisch laufen — vor TestFlight verifizieren)
- DueReview bekommt programmatischen init(review:card:) für die
Cache-Rekonstruktion
- DeckListStore.refresh() zieht Cards + Due-Reviews pro Deck
parallel in einer TaskGroup; applyToCache in drei Helpers
gesplittet (applyDecks / applyCards / applyDueReviews)
- Karten: Upsert mit Orphan-Cleanup
- Due-Reviews: voll ersetzt pro Refresh (Server-`due`-Zeiten
ändern sich, Merge wäre falsch)
- StudySession.start() fällt bei Netz-Fehler auf
CachedDueReview-Snapshot zurück, setzt isOfflineSession-Flag
- StudySessionView zeigt offline-Banner und am Ende der Session
einen Hinweis „Weitere Karten erst nach Verbindung verfügbar"
- AccountView.wipeLocalCache(): DSGVO-Wipe vor signOut() und nach
deleteAccount → CachedDeck + CachedCard + CachedDueReview +
PendingGrade werden gelöscht
Plus: Keychain-Test in WordeckNativeTests.swift fix — erwartete
"ev.mana.wordeck", muss seit Cross-App-SSO-Commit 19fee75
ManaSharedKeychainGroup nutzen. Auf Konstant-Reference umgestellt,
damit's nicht wieder driftet.
Verifikation:
- xcodebuild iOS-Simulator: BUILD SUCCEEDED
- swiftlint --strict: 0 violations in 68 files
- swiftformat: clean
- 37/37 Tests grün (inkl. fix-Keychain-Test)
- macOS-Build scheitert an pre-existing .topBarTrailing in
StudySessionView (iOS-only API seit 2026-05-13, nicht durch
diesen Commit verursacht)
Pflicht-Verifikation vor TestFlight (in PLAN.md verewigt):
- SwiftData-Migration auf Bestandsbuilder
- Offline-Endurance (50+ Karten Flugmodus)
- Logout-Wipe mit Account-Switch
- Cross-Check Web ↔ Native nach Offline-Grade
Diff: 35 files, +869 / -1622, netto ~−750 LOC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
17 KiB
Swift
547 lines
17 KiB
Swift
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<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 ?? 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<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)
|
||
.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)
|
||
}
|
||
}
|