cards-native/Sources/Features/Decks/DeckDetailView.swift
Till JS e8b898a51d feat(decks): Card-Liste im DeckDetailView + listCards-API
Bisher zeigte DeckDetailView nur 4 Action-Buttons (Lernen,
Hinzufügen, Bearbeiten, Löschen) — Karten waren nur via Study-Loop
sichtbar. User-Feedback: "ich sehe keine Karten im Deck".

Geändert:
- CardsAPI.listCards(deckId:) → [Card] (war nur cardCount via /total)
- CardListResponse: nimmt cards-Array zusätzlich zu total
- DeckDetailView: ScrollView statt VStack, neue Sektion "Karten"
  unter den Action-Buttons mit CardPreviewRow pro Karte
- CardPreviewRow: Type-Icon + Front-Preview (basic/cloze/audio/
  image-occlusion adaptiv) + Type-Label
- task(id:) + refreshable triggern loadCards()
- Nach CardEditor-Save reloaded die Liste

Build 4 → 5.

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

345 lines
12 KiB
Swift

import ManaCore
import SwiftData
import SwiftUI
/// Deck-Detail mit Aktionen + Card-Liste. 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?
@State private var cards: [Card] = []
@State private var isLoadingCards = false
@State private var cardsError: 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()
await loadCards()
}
}
}
}
.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()
}
}
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(CardsTheme.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(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)
}
@ViewBuilder
private var cardListSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Karten")
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
Spacer()
if !cards.isEmpty {
Text("\(cards.count)")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
if isLoadingCards, cards.isEmpty {
HStack {
Spacer()
ProgressView()
.tint(CardsTheme.primary)
Spacer()
}
.padding(.vertical, 24)
} else if let cardsError {
Text(cardsError)
.font(.caption)
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
} else if cards.isEmpty {
Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 12)
} else {
LazyVStack(spacing: 8) {
ForEach(cards) { card in
CardPreviewRow(card: card)
.padding(.horizontal, 16)
}
}
}
}
}
private func refreshAfterEdit() async {
let store = DeckListStore(auth: auth, context: context)
await store.refresh()
}
private func loadCards() async {
isLoadingCards = true
cardsError = nil
defer { isLoadingCards = false }
let api = CardsAPI(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 = CardsAPI(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)
}
}
}
/// 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(CardsTheme.primary)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
Text(preview(card: card))
.font(.subheadline)
.foregroundStyle(CardsTheme.foreground)
.lineLimit(2)
Text(typeLabel(card.type))
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
}
.padding(12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
private func preview(card: Card) -> String {
switch card.type {
case .basic, .basicReverse, .typing, .multipleChoice:
return card.fields["front"] ?? ""
case .cloze:
return card.fields["text"] ?? ""
case .imageOcclusion:
return card.fields["note"]?.isEmpty == false
? card.fields["note"]!
: "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)"
case .audioFront:
return card.fields["back"] ?? "Audio-Karte"
}
}
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"
case .imageOcclusion: "photo.on.rectangle.angled"
case .audioFront: "waveform"
}
}
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"
case .imageOcclusion: "Bild-Verdeckung"
case .audioFront: "Audio"
}
}
}