cards-native/Sources/Features/Decks/DeckDetailView.swift
Till JS cf1160b270 v0.4.0 — Phase β-3 Editor
Voller Editor-Flow für Decks und 5 Card-Types (basic, basic-reverse,
cloze, typing, multiple-choice). image-occlusion + audio-front kommen
mit β-4 (Media). Anki-Import bleibt vorerst aus (Web parsed client-
side, gibt keinen Server-Import-Endpoint zu rufen).

- DeckCreateBody/UpdateBody, CardCreateBody/UpdateBody Encodable
  mit snake_case-CodingKeys, nil-Felder werden weggelassen
- CardFieldsBuilder mit Type-spezifischen Pflicht-Feld-Konstruktoren
- CardsAPI: createDeck/updateDeck/deleteDeck +
  createCard/updateCard/deleteCard
- DeckEditorView (Create + Edit in einer View): Color-Picker mit
  8-Preset-Palette, Category-Picker (11 Kats, deutsche Labels),
  Visibility-Segmented-Control
- CardEditorView mit Type-Picker und dynamischen Feldern je Typ.
  Cloze-Sektion zeigt Live-Cluster-Count und Hint-Syntax-Hinweis.
  image-occlusion/audio-front zeigen β-4-Placeholder
- DeckDetailView mit Action-Buttons (Lernen, Karte hinzufügen,
  Bearbeiten, Löschen mit Confirmation)
- DeckListView: "+"-Button im Toolbar (Leading) für Create-Sheet
- 7 neue Encoding-Tests (24 Unit-Tests + 1 UI-Test grün)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:24:43 +02:00

202 lines
7.3 KiB
Swift

import ManaCore
import SwiftData
import SwiftUI
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen.
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet.
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?
init(deckId: String) {
self.deckId = deckId
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
}
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
if let deck = decks.first {
content(deck: deck)
} else {
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
.foregroundStyle(CardsTheme.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(deckId: deckId) { _ in
Task { await refreshAfterEdit() }
}
}
}
.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)
}
}
}
private func content(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
if let deleteError {
Text(deleteError)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
}
Spacer()
}
.padding(.vertical, 16)
}
private func header(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(deck.name)
.font(.title.bold())
.foregroundStyle(CardsTheme.foreground)
if deck.isFromMarketplace {
Image(systemName: "globe")
.foregroundStyle(CardsTheme.mutedForeground)
}
}
if let description = deck.deckDescription, !description.isEmpty {
Text(description)
.foregroundStyle(CardsTheme.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(CardsTheme.primary)
}
if let category = deck.category {
Text(category.label)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
.font(.footnote)
}
.padding(.horizontal, 16)
}
private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) {
Button {
navigateToStudy = true
} label: {
Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.disabled(deck.dueCount == 0)
Button {
showCardEditor = true
} label: {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
HStack(spacing: 12) {
Button {
showEditor = true
} label: {
Label("Bearbeiten", systemImage: "pencil")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
Button {
showDeleteConfirm = true
} label: {
Label("Löschen", systemImage: "trash")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.error)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
private func refreshAfterEdit() async {
let store = DeckListStore(auth: auth, context: context)
await store.refresh()
}
private func delete() async {
deleteError = nil
let api = CardsAPI(auth: auth)
do {
try await api.deleteDeck(id: deckId)
// Cache nachziehen
if let deck = decks.first {
context.delete(deck)
try? context.save()
}
dismiss()
} catch {
deleteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
}