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

@ -44,6 +44,14 @@ actor CardsAPI {
return try decoder.decode(CardListResponse.self, from: data).total
}
/// `GET /api/v1/cards?deck_id=...` komplette Liste der Karten
/// für den Browse-Modus im DeckDetailView.
func listCards(deckId: String) async throws -> [Card] {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).cards
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int {

View file

@ -121,6 +121,7 @@ struct DeckListResponse: Decodable, Sendable {
/// Server-Response von `GET /api/v1/cards?deck_id=...`.
struct CardListResponse: Decodable, Sendable {
let cards: [Card]
let total: Int
}

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,9 +78,16 @@ 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 {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
@ -83,10 +97,11 @@ struct DeckDetailView: View {
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
}
Spacer()
cardListSection
}
.padding(.vertical, 16)
}
}
private func header(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 8) {
@ -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"
}
}
}

View file

@ -55,7 +55,7 @@ targets:
path: Sources/Resources/Info.plist
properties:
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "4"
CFBundleVersion: "5"
CFBundleDevelopmentRegion: de
CFBundleDisplayName: Cardecky
LSApplicationCategoryType: "public.app-category.education"
@ -111,7 +111,7 @@ targets:
properties:
CFBundleDisplayName: Als Karte speichern
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "4"
CFBundleVersion: "5"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
@ -144,7 +144,7 @@ targets:
properties:
CFBundleDisplayName: Cardecky Widget
CFBundleShortVersionString: "0.1.0"
CFBundleVersion: "4"
CFBundleVersion: "5"
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
entitlements: