diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index f9472bc..764d54c 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -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 { diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index e4f907f..cb3d007 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -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 } diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index 045f78c..d037ea1 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -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 { $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" + } + } +} diff --git a/project.yml b/project.yml index 43a5fe4..4f497cc 100644 --- a/project.yml +++ b/project.yml @@ -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: