wordeck-native/Sources/Features/Decks/DeckDetailView.swift
Till JS 9527240bcc feat(offline): text-only Cleanup + ζ-1 Offline-Sync
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>
2026-05-18 22:06:41 +02:00

456 lines
16 KiB
Swift

import ManaCore
import SwiftData
import SwiftUI
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
/// aus der DeckListView geöffnet.
///
/// `type_body_length` ist bewusst übersprungen Detail-View hostet
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
/// Plumbing zwischen Parent und Children.
struct DeckDetailView: View {
let deckId: String
@Environment(AuthClient.self) private var auth
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var decks: [CachedDeck]
@State private var showEditor = false
@State private var showCardEditor = false
@State private var showDeleteConfirm = false
@State private var navigateToStudy = false
@State private var deleteError: String?
@State private var editingCard: Card?
@State private var cards: [Card] = []
@State private var isLoadingCards = false
@State private var cardsError: String?
@State private var isPullingUpdate = false
@State private var isDuplicating = false
@State private var pullAlert: AlertMessage?
@State private var actionError: String?
@State private var showPublishSheet = false
@State private var showPrintSheet = false
init(deckId: String) {
self.deckId = deckId
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
}
var body: some View {
ZStack {
WordeckTheme.background.ignoresSafeArea()
if let deck = decks.first {
content(deck: deck)
} else {
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
.navigationTitle(decks.first?.name ?? "")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.sheet(isPresented: $showEditor) {
NavigationStack {
DeckEditorView(
mode: .edit(deckId: deckId),
existing: decks.first
) { _ in
Task { await refreshAfterEdit() }
}
}
}
.sheet(isPresented: $showCardEditor) {
NavigationStack {
CardEditorView(mode: .create(deckId: deckId)) { _ in
Task {
await refreshAfterEdit()
await loadCards()
}
}
}
}
.sheet(item: $editingCard) { card in
NavigationStack {
CardEditorView(mode: .edit(card: card)) { _ in
Task {
await refreshAfterEdit()
await loadCards()
editingCard = nil
}
}
}
}
.sheet(isPresented: $showPublishSheet) {
if let deck = decks.first {
NavigationStack {
MarketplacePublishView(privateDeck: deck) { _ in
showPublishSheet = false
}
}
}
}
.sheet(isPresented: $showPrintSheet) {
NavigationStack {
DeckPrintView(deckId: deckId)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { showPrintSheet = false }
}
}
}
}
.confirmationDialog(
"Deck löschen?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
Task { await delete() }
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text(
"""
Alle Karten und Reviews dieses Decks werden ebenfalls \
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
"""
)
}
.navigationDestination(isPresented: $navigateToStudy) {
if let deck = decks.first {
StudySessionView(deckId: deck.id, deckName: deck.name)
}
}
.task(id: deckId) {
await loadCards()
}
.refreshable {
await loadCards()
}
.alert(item: $pullAlert) { alert in
Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")))
}
.alert(
"Aktion fehlgeschlagen",
isPresented: Binding(
get: { actionError != nil },
set: { if !$0 { actionError = nil } }
),
presenting: actionError
) { _ in
Button("OK") { actionError = nil }
} message: { message in
Text(message)
}
}
private func content(deck: CachedDeck) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
if let deleteError {
Text(deleteError)
.font(.footnote)
.foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16)
}
cardListSection
}
.padding(.vertical, 16)
}
}
private func header(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(deck.name)
.font(.title.bold())
.foregroundStyle(WordeckTheme.foreground)
if deck.isFromMarketplace {
Image(systemName: "globe")
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
if let description = deck.deckDescription, !description.isEmpty {
Text(description)
.foregroundStyle(WordeckTheme.mutedForeground)
}
HStack(spacing: 16) {
Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack")
if deck.dueCount > 0 {
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
.foregroundStyle(WordeckTheme.primary)
}
if let category = deck.category {
Text(category.label)
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
.font(.footnote)
}
.padding(.horizontal, 16)
}
private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) {
primaryActions
secondaryActions(deck: deck)
}
.padding(.horizontal, 16)
}
@ViewBuilder
private var primaryActions: some View {
Button {
navigateToStudy = true
} label: {
Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.primaryForeground)
}
.buttonStyle(.plain)
.disabled((decks.first?.dueCount ?? 0) == 0)
Button {
showCardEditor = true
} label: {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(WordeckTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func secondaryActions(deck: CachedDeck) -> some View {
DeckSecondaryActions(
isForkedFromMarketplace: deck.isFromMarketplace,
isPullingUpdate: isPullingUpdate,
isDuplicating: isDuplicating,
onPullUpdate: { Task { await pullUpdate() } },
onDuplicate: { Task { await duplicate() } },
onPublish: { showPublishSheet = true },
onPrint: { showPrintSheet = true },
onEdit: { showEditor = true },
onDelete: { showDeleteConfirm = true }
)
}
private var cardListSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Karten")
.font(.headline)
.foregroundStyle(WordeckTheme.foreground)
Spacer()
if !cards.isEmpty {
Text("\(cards.count)")
.font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
if isLoadingCards, cards.isEmpty {
HStack {
Spacer()
ProgressView()
.tint(WordeckTheme.primary)
Spacer()
}
.padding(.vertical, 24)
} else if let cardsError {
Text(cardsError)
.font(.caption)
.foregroundStyle(WordeckTheme.error)
.padding(.horizontal, 16)
} else if cards.isEmpty {
Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.")
.font(.footnote)
.foregroundStyle(WordeckTheme.mutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 12)
} else {
LazyVStack(spacing: 8) {
ForEach(cards) { card in
Button {
editingCard = card
} label: {
CardPreviewRow(card: card)
.padding(.horizontal, 16)
}
.buttonStyle(.plain)
.accessibilityHint("Tippen zum Bearbeiten")
}
}
}
}
}
private func refreshAfterEdit() async {
let store = DeckListStore(auth: auth, context: context)
await store.refresh()
}
private func pullUpdate() async {
isPullingUpdate = true
defer { isPullingUpdate = false }
let api = WordeckAPI(auth: auth)
do {
let result = try await api.pullUpdate(deckId: deckId)
pullAlert = formatPullResult(result)
await refreshAfterEdit()
await loadCards()
} catch let error as AuthError {
actionError = error.errorDescription ?? "Update fehlgeschlagen"
} catch {
actionError = error.localizedDescription
}
}
private func formatPullResult(_ result: PullUpdateResponse) -> AlertMessage {
if result.upToDate {
return AlertMessage(
title: "Schon aktuell",
message: "Es gibt keine neue Marketplace-Version dieses Decks."
)
}
let inserted = result.cardsInserted ?? 0
let parts = [
inserted > 0 ? "\(inserted) Karten hinzugefügt" : nil,
result.changed > 0 ? "\(result.changed) Karten geändert" : nil,
result.removed > 0 ? "\(result.removed) im Marketplace entfernt (lokal behalten)" : nil
].compactMap(\.self)
let body = parts.isEmpty ? "Update angewendet." : parts.joined(separator: ", ")
let versionText = result.to.map { "Version \($0.semver)" } ?? "Update angewendet"
return AlertMessage(title: versionText, message: body)
}
private func duplicate() async {
isDuplicating = true
defer { isDuplicating = false }
let api = WordeckAPI(auth: auth)
do {
_ = try await api.duplicateDeck(id: deckId)
await refreshAfterEdit()
dismiss()
} catch let error as AuthError {
actionError = error.errorDescription ?? "Duplizieren fehlgeschlagen"
} catch {
actionError = error.localizedDescription
}
}
private func loadCards() async {
isLoadingCards = true
cardsError = nil
defer { isLoadingCards = false }
let api = WordeckAPI(auth: auth)
do {
cards = try await api.listCards(deckId: deckId)
.sorted { $0.createdAt > $1.createdAt }
} catch {
cardsError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func delete() async {
deleteError = nil
let api = WordeckAPI(auth: auth)
do {
try await api.deleteDeck(id: deckId)
if let deck = decks.first {
context.delete(deck)
try? context.save()
}
dismiss()
} catch {
deleteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
}
// swiftlint:enable type_body_length
/// Einfacher Alert-Body Title + Message für `.alert(item:)`-Trigger.
struct AlertMessage: Identifiable {
let id = UUID()
let title: String
let message: String
}
/// Kompakte Card-Row mit Front-Vorschau und Type-Badge.
private struct CardPreviewRow: View {
let card: Card
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon(for: card.type))
.foregroundStyle(WordeckTheme.primary)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(preview(card: card))
.font(.subheadline)
.foregroundStyle(WordeckTheme.foreground)
.lineLimit(2)
Text(typeLabel(card.type))
.font(.caption2)
.foregroundStyle(WordeckTheme.mutedForeground)
}
Spacer()
}
.padding(12)
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(WordeckTheme.border, lineWidth: 1)
)
}
private func preview(card: Card) -> String {
switch card.type {
case .basic, .basicReverse, .typing, .multipleChoice:
card.fields["front"] ?? ""
case .cloze:
card.fields["text"] ?? ""
}
}
private func icon(for type: CardType) -> String {
switch type {
case .basic: "rectangle.split.2x1"
case .basicReverse: "rectangle.2.swap"
case .cloze: "text.append"
case .typing: "keyboard"
case .multipleChoice: "list.bullet"
}
}
private func typeLabel(_ type: CardType) -> String {
switch type {
case .basic: "Einfach"
case .basicReverse: "Beidseitig"
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
}
}
}