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>
This commit is contained in:
Till JS 2026-05-13 16:02:59 +02:00
parent f528ea448a
commit e8b898a51d
4 changed files with 169 additions and 17 deletions

View file

@ -2,8 +2,8 @@ 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.
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
/// aus der DeckListView geöffnet.
struct DeckDetailView: View {
let deckId: String
@ -18,6 +18,10 @@ struct DeckDetailView: View {
@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 })
@ -50,7 +54,10 @@ struct DeckDetailView: View {
.sheet(isPresented: $showCardEditor) {
NavigationStack {
CardEditorView(deckId: deckId) { _ in
Task { await refreshAfterEdit() }
Task {
await refreshAfterEdit()
await loadCards()
}
}
}
}
@ -71,21 +78,29 @@ struct DeckDetailView: View {
StudySessionView(deckId: deck.id, deckName: deck.name)
}
}
.task(id: deckId) {
await loadCards()
}
.refreshable {
await loadCards()
}
}
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)
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
}
Spacer()
.padding(.vertical, 16)
}
.padding(.vertical, 16)
}
private func header(deck: CachedDeck) -> some View {
@ -179,17 +194,76 @@ struct DeckDetailView: View {
.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)
// Cache nachziehen
if let deck = decks.first {
context.delete(deck)
try? context.save()
@ -200,3 +274,72 @@ struct DeckDetailView: View {
}
}
}
/// 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"
}
}
}