v0.2.0 — Phase β-1 Decks lesen

Deck-Liste mit Web-Parität: alle eigenen Decks aus cardecky-api,
Card-/Due-Counts pro Deck (Web-Pattern: separate Calls), Pull-to-
Refresh, Offline-Read via SwiftData, Inbox-Banner für Marketplace-
Forks.

- Deck-Codable-DTO mit snake_case-CodingKeys (DeckCategory,
  DeckVisibility, FsrsSettings)
- ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz
- CardsAPI.listDecks() + cardCount() + dueCount()
- CachedDeck SwiftData-Model mit lastFetchedAt
- DeckListStore (API + Cache, paralleles Counts-Fetching via TaskGroup)
- DeckListView mit forest-Theme, deck.color-Streifen, Inbox-Banner
- AccountView mit Sign-out
- DashboardView durch DeckListView ersetzt
- 6 Unit-Tests + 1 UI-Test grün

Phasen-Plan: mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:06:28 +02:00
parent 28b20cd934
commit f664a00b64
12 changed files with 809 additions and 85 deletions

View file

@ -1,11 +1,18 @@
import ManaCore
import SwiftData
import SwiftUI
@main
struct CardsNativeApp: App {
let container: ModelContainer
@State private var auth: AuthClient
init() {
do {
container = try ModelContainer(for: CachedDeck.self)
} catch {
fatalError("Failed to init ModelContainer: \(error)")
}
let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap()
_auth = State(initialValue: auth)
@ -18,5 +25,6 @@ struct CardsNativeApp: App {
.environment(auth)
.tint(CardsTheme.primary)
}
.modelContainer(container)
}
}

View file

@ -1,16 +1,16 @@
import ManaCore
import SwiftUI
/// Top-Level-Switch: Login vs Dashboard.
/// Ab Phase β-1 wird Dashboard durch eine echte Tab-Bar (Decks / Study /
/// Stats / Account) ersetzt.
/// Top-Level-Switch: Login vs Deck-Liste.
/// Ab Phase β-3 könnte hier eine Tab-Bar entstehen (Decks / Study /
/// Stats / Account) für β-1 reicht der einfache Switch.
struct RootView: View {
@Environment(AuthClient.self) private var auth
var body: some View {
switch auth.status {
case .signedIn:
DashboardView()
DeckListView()
case .unknown, .signedOut, .signingIn, .error:
LoginView()
}

View file

@ -3,20 +3,86 @@ import ManaCore
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt.
///
/// In Phase β-0 ist die API leer Endpoints kommen ab β-1 (Decks),
/// β-2 (Reviews), β-3 (Editor), β-4 (Media), β-5 (Marketplace).
actor CardsAPI {
private let transport: AuthenticatedTransport
private let decoder: JSONDecoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
}
/// Health-Probe für β-0 verifiziert dass cardecky-api erreichbar
/// ist und der eigene JWT akzeptiert wird.
/// Health-Probe verifiziert dass cardecky-api erreichbar ist
/// und der eigene JWT akzeptiert wird.
func healthCheck() async throws -> Bool {
let (_, http) = try await transport.request(path: "/healthz")
return http.statusCode == 200
}
// MARK: - Decks
/// `GET /api/v1/decks?archived=false` alle aktiven Decks des Users.
/// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks
/// (für den Inbox-Banner).
func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] {
var path = "/api/v1/decks"
if forkedFromMarketplaceOnly {
path += "?forked_from_marketplace=true"
}
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
let body = try decoder.decode(DeckListResponse.self, from: data)
return body.decks
}
/// `GET /api/v1/cards?deck_id=...` Anzahl Karten in einem Deck.
/// Web macht das pro Deck einzeln; identisches Pattern hier.
func cardCount(deckId: String) async throws -> Int {
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).total
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int {
let (data, http) = try await transport.request(
path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500"
)
try ensureOK(http, data: data)
return try decoder.decode(DueReviewsResponse.self, from: data).total
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
throw AuthError.serverError(status: http.statusCode, message: message)
}
}
}
private struct CardsServerError: Decodable {
let error: String?
}
extension JSONDecoder.DateDecodingStrategy {
/// Cards-API liefert ISO8601 mit Fractional-Seconds aus
/// `.toISOString()`. Standard-Strategy `.iso8601` akzeptiert die
/// fractional seconds nicht wir nutzen einen eigenen Formatter.
static let iso8601withFractional: JSONDecoder.DateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = f.date(from: raw) { return date }
f.formatOptions = [.withInternetDateTime]
if let date = f.date(from: raw) { return date }
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode ISO8601 date: \(raw)"
)
}
}

View file

@ -0,0 +1,130 @@
import Foundation
/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`.
/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable.
struct Deck: Codable, Identifiable, Hashable, Sendable {
let id: String
let userId: String
let name: String
let description: String?
let color: String?
let category: DeckCategory?
let visibility: DeckVisibility
let fsrsSettings: FsrsSettings
let contentHash: String?
let forkedFromMarketplaceDeckId: String?
let forkedFromMarketplaceVersionId: String?
let archivedAt: Date?
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case name
case description
case color
case category
case visibility
case fsrsSettings = "fsrs_settings"
case contentHash = "content_hash"
case forkedFromMarketplaceDeckId = "forked_from_marketplace_deck_id"
case forkedFromMarketplaceVersionId = "forked_from_marketplace_version_id"
case archivedAt = "archived_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
/// Geforkt aus dem Cardecky-Marketplace?
var isFromMarketplace: Bool {
forkedFromMarketplaceDeckId != nil
}
}
enum DeckVisibility: String, Codable, Sendable {
case `private`
case space
case `public`
}
/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`.
enum DeckCategory: String, Codable, Sendable, CaseIterable {
case language
case medicine
case science
case math
case history
case law
case technology
case arts
case music
case sport
case other
/// Deutsche Labels aus `DECK_CATEGORY_LABELS`.
var label: String {
switch self {
case .language: "Sprache"
case .medicine: "Medizin"
case .science: "Wissenschaft"
case .math: "Mathematik"
case .history: "Geschichte"
case .law: "Recht"
case .technology: "Technik"
case .arts: "Kunst"
case .music: "Musik"
case .sport: "Sport"
case .other: "Sonstiges"
}
}
}
/// FSRS-Settings Native bleibt schematisch agnostisch, FSRS rechnet
/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine
/// neue Setting auf dem Server uns nicht bricht.
struct FsrsSettings: Codable, Sendable, Hashable {
let requestRetention: Double?
let maximumInterval: Int?
let enableFuzz: Bool?
enum CodingKeys: String, CodingKey {
case requestRetention = "request_retention"
case maximumInterval = "maximum_interval"
case enableFuzz = "enable_fuzz"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
requestRetention = try container.decodeIfPresent(Double.self, forKey: .requestRetention)
maximumInterval = try container.decodeIfPresent(Int.self, forKey: .maximumInterval)
enableFuzz = try container.decodeIfPresent(Bool.self, forKey: .enableFuzz)
}
static let empty = FsrsSettings()
private init(
requestRetention: Double? = nil,
maximumInterval: Int? = nil,
enableFuzz: Bool? = nil
) {
self.requestRetention = requestRetention
self.maximumInterval = maximumInterval
self.enableFuzz = enableFuzz
}
}
/// Server-Response von `GET /api/v1/decks`.
struct DeckListResponse: Decodable, Sendable {
let decks: [Deck]
let total: Int
}
/// Server-Response von `GET /api/v1/cards?deck_id=...`.
struct CardListResponse: Decodable, Sendable {
let total: Int
}
/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`.
struct DueReviewsResponse: Decodable, Sendable {
let total: Int
}

View file

@ -0,0 +1,79 @@
import Foundation
import SwiftData
/// Lokales Cache-Model für Decks. Spiegelt das Server-DTO + zwei
/// computed Werte (cardCount, dueCount), die Web pro Deck als zusätzliche
/// API-Calls holt.
///
/// Offline-Read: Liste sichtbar ohne Netz. Server bleibt Wahrheit
/// alle Edits laufen über die API, der Cache wird nur beim Re-Fetch
/// aktualisiert.
@Model
final class CachedDeck {
@Attribute(.unique) var id: String
var userId: String
var name: String
var deckDescription: String?
var color: String?
var categoryRaw: String?
var visibilityRaw: String
var contentHash: String?
var forkedFromMarketplaceDeckId: String?
var forkedFromMarketplaceVersionId: String?
var archivedAt: Date?
var createdAt: Date
var updatedAt: Date
/// Anzahl Karten im Deck (über `/api/v1/cards?deck_id=...`).
var cardCount: Int = 0
/// Anzahl fälliger Reviews (über `/api/v1/reviews/due?deck_id=...`).
var dueCount: Int = 0
/// Zeitpunkt des letzten erfolgreichen Server-Pulls für dieses Deck.
var lastFetchedAt: Date
init(deck: Deck, cardCount: Int = 0, dueCount: Int = 0) {
id = deck.id
userId = deck.userId
name = deck.name
deckDescription = deck.description
color = deck.color
categoryRaw = deck.category?.rawValue
visibilityRaw = deck.visibility.rawValue
contentHash = deck.contentHash
forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId
forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId
archivedAt = deck.archivedAt
createdAt = deck.createdAt
updatedAt = deck.updatedAt
self.cardCount = cardCount
self.dueCount = dueCount
lastFetchedAt = .now
}
/// Übernimmt aktualisierte Felder vom Server-DTO.
func update(from deck: Deck, cardCount: Int, dueCount: Int) {
name = deck.name
deckDescription = deck.description
color = deck.color
categoryRaw = deck.category?.rawValue
visibilityRaw = deck.visibility.rawValue
contentHash = deck.contentHash
forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId
forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId
archivedAt = deck.archivedAt
updatedAt = deck.updatedAt
self.cardCount = cardCount
self.dueCount = dueCount
lastFetchedAt = .now
}
var category: DeckCategory? {
categoryRaw.flatMap(DeckCategory.init(rawValue:))
}
var isFromMarketplace: Bool {
forkedFromMarketplaceDeckId != nil
}
}

View file

@ -0,0 +1,99 @@
import Foundation
import ManaCore
import Observation
import SwiftData
/// Orchestriert API + SwiftData-Cache für die Deck-Liste.
/// View bindet sich an `state` und `errorMessage`.
@MainActor
@Observable
final class DeckListStore {
enum State: Sendable {
case idle
case loading
case loaded
case failed
}
private(set) var state: State = .idle
private(set) var errorMessage: String?
private let api: CardsAPI
private let context: ModelContext
init(auth: AuthClient, context: ModelContext) {
api = CardsAPI(auth: auth)
self.context = context
}
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
/// der Cache (offline-readable).
func refresh() async {
state = .loading
errorMessage = nil
do {
let decks = try await api.listDecks()
try await applyToCache(decks: decks)
state = .loaded
Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server")
} catch let error as AuthError {
errorMessage = error.errorDescription
state = .failed
Log.sync.error("Deck refresh failed: \(error.localizedDescription, privacy: .public)")
} catch {
errorMessage = String(describing: error)
state = .failed
Log.sync.error("Deck refresh failed: \(String(describing: error), privacy: .public)")
}
}
private func applyToCache(decks remoteDecks: [Deck]) async throws {
let remoteIDs = Set(remoteDecks.map(\.id))
// 1. Bestehende Cache-Entries finden
let descriptor = FetchDescriptor<CachedDeck>()
let cached = (try? context.fetch(descriptor)) ?? []
let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
// 2. Gelöschte Decks aus Cache entfernen
for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) {
context.delete(cachedDeck)
}
// 3. Counts parallel holen
let counts = await withTaskGroup(of: (String, Int, Int).self) { group in
for deck in remoteDecks {
group.addTask { [api] in
async let cards = api.cardCount(deckId: deck.id)
async let due = api.dueCount(deckId: deck.id)
let cardCount = (try? await cards) ?? 0
let dueCount = (try? await due) ?? 0
return (deck.id, cardCount, dueCount)
}
}
var result: [String: (cardCount: Int, dueCount: Int)] = [:]
for await (id, c, d) in group {
result[id] = (c, d)
}
return result
}
// 4. Neue/aktualisierte Decks einarbeiten
for deck in remoteDecks {
let counts = counts[deck.id] ?? (0, 0)
if let existing = cachedByID[deck.id] {
existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount)
} else {
let cachedDeck = CachedDeck(
deck: deck,
cardCount: counts.cardCount,
dueCount: counts.dueCount
)
context.insert(cachedDeck)
}
}
try context.save()
}
}

View file

@ -0,0 +1,49 @@
import ManaCore
import SwiftUI
struct AccountView: View {
@Environment(AuthClient.self) private var auth
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundStyle(CardsTheme.primary)
if let email = auth.currentEmail {
Text(email)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
}
Spacer()
Button(role: .destructive) {
Task { await auth.signOut() }
} label: {
Text("Abmelden")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(CardsTheme.error)
}
.padding(.horizontal, 32)
}
.padding(.top, 48)
}
.navigationTitle("Account")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
#Preview {
NavigationStack {
AccountView()
.environment(AuthClient(config: AppConfig.manaAppConfig))
}
}

View file

@ -1,58 +0,0 @@
import ManaCore
import SwiftUI
/// Phase β-0-Placeholder. Wird in β-1 durch eine echte Tab-Bar mit
/// Decks / Study / Stats / Account ersetzt.
struct DashboardView: View {
@Environment(AuthClient.self) private var auth
@State private var apiReachable: Bool?
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
VStack(spacing: 24) {
Text("Cards")
.font(.largeTitle.bold())
.foregroundStyle(CardsTheme.primary)
if let email = auth.currentEmail {
Text("Angemeldet als \(email)")
.font(.subheadline)
.foregroundStyle(CardsTheme.mutedForeground)
}
ContentUnavailableView {
Label("β-1 in Vorbereitung", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text("Decks- und Study-Views kommen in der nächsten Phase.")
.foregroundStyle(CardsTheme.mutedForeground)
}
if let reachable = apiReachable {
Label(
reachable ? "cardecky-api erreichbar" : "cardecky-api nicht erreichbar",
systemImage: reachable ? "checkmark.circle.fill" : "xmark.circle.fill"
)
.foregroundStyle(reachable ? CardsTheme.success : CardsTheme.error)
.font(.footnote)
}
Button("Abmelden", role: .destructive) {
Task { await auth.signOut() }
}
.padding(.top, 24)
}
.padding(32)
}
.task {
let api = CardsAPI(auth: auth)
apiReachable = (try? await api.healthCheck()) ?? false
}
}
}
#Preview {
DashboardView()
.environment(AuthClient(config: AppConfig.manaAppConfig))
}

View file

@ -0,0 +1,215 @@
import ManaCore
import SwiftData
import SwiftUI
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts.
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`.
struct DeckListView: View {
@Environment(AuthClient.self) private var auth
@Environment(\.modelContext) private var context
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
@State private var store: DeckListStore?
@State private var showAccount = false
var body: some View {
NavigationStack {
ZStack {
CardsTheme.background.ignoresSafeArea()
content
}
.navigationTitle("Decks")
.toolbar { toolbar }
.refreshable {
await store?.refresh()
}
.task {
if store == nil {
store = DeckListStore(auth: auth, context: context)
}
await store?.refresh()
}
.sheet(isPresented: $showAccount) {
NavigationStack {
AccountView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { showAccount = false }
}
}
}
}
}
}
@ViewBuilder
private var content: some View {
if decks.isEmpty {
emptyState
} else {
List {
inboxBannerSection
ownDecksSection
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
private var emptyState: some View {
VStack(spacing: 16) {
if store?.state == .loading {
ProgressView()
.tint(CardsTheme.primary)
Text("Lade Decks …")
.foregroundStyle(CardsTheme.mutedForeground)
} else if let message = store?.errorMessage {
ContentUnavailableView {
Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text(message)
.foregroundStyle(CardsTheme.mutedForeground)
}
} else {
ContentUnavailableView {
Label("Noch keine Decks", systemImage: "rectangle.stack")
.foregroundStyle(CardsTheme.foreground)
} description: {
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.")
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var inboxBannerSection: some View {
if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) {
Section {
HStack(spacing: 12) {
Image(systemName: "tray.full.fill")
.foregroundStyle(CardsTheme.primary)
VStack(alignment: .leading, spacing: 2) {
Text("Inbox")
.font(.subheadline.weight(.semibold))
.foregroundStyle(CardsTheme.foreground)
Text("\(inbox.dueCount) fällige Karten aus abonnierten Decks")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
Spacer()
}
.padding()
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
private var ownDecksSection: some View {
Section {
ForEach(decks) { deck in
DeckRow(deck: deck)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
showAccount = true
} label: {
Image(systemName: accountIcon)
.foregroundStyle(CardsTheme.primary)
}
.accessibilityLabel("Account")
}
}
private var accountIcon: String {
if case .signedIn = auth.status { return "person.crop.circle.fill" }
return "person.crop.circle.badge.exclamationmark"
}
}
/// Einzelne Deck-Zeile in der Liste.
struct DeckRow: View {
let deck: CachedDeck
var body: some View {
HStack(spacing: 12) {
// Farbiger Streifen aus deck.color (Hex), default forest-primary
RoundedRectangle(cornerRadius: 3)
.fill(deckColor)
.frame(width: 4)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(deck.name)
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
if deck.isFromMarketplace {
Image(systemName: "globe")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
if let category = deck.category {
Text(category.label)
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
HStack(spacing: 12) {
Label("\(deck.cardCount)", systemImage: "rectangle.stack")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
if deck.dueCount > 0 {
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
.font(.caption.weight(.semibold))
.foregroundStyle(CardsTheme.primary)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(CardsTheme.mutedForeground)
}
.padding(.vertical, 12)
.padding(.horizontal, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
}
private var deckColor: Color {
guard let hex = deck.color, let rgb = parseHex(hex) else {
return CardsTheme.primary
}
return Color.manaHexLocal(rgb)
}
private func parseHex(_ hex: String) -> UInt32? {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
return UInt32(trimmed, radix: 16)
}
}
private extension Color {
/// Lokales Hex-Helper analog zu `ManaTokens.Color.manaHex`. Hier
/// dupliziert, weil DeckRow nicht von ManaTokens abhängen muss.
static func manaHexLocal(_ rgb: UInt32) -> Color {
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}