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:
parent
f528ea448a
commit
e8b898a51d
4 changed files with 169 additions and 17 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue