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

50
PLAN.md
View file

@ -1,8 +1,9 @@
# Plan — cards-native (SwiftUI Universal) # Plan — cards-native (SwiftUI Universal)
**Stand: 2026-05-12 — Phase β-0 abgeschlossen.** Repo lebt lokal, **Stand: 2026-05-13 — Phasen β-0 + β-1 abgeschlossen.** Repo lebt
ManaCore + ManaTokens als Package-Dependency, Login funktioniert, auf Forgejo, Login funktioniert, Deck-Liste mit Card-/Due-Counts +
Cardecky-API-Reachability-Probe. Offline-SwiftData-Cache + Pull-to-Refresh + Inbox-Banner für
Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün.
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
@ -10,7 +11,7 @@ Cardecky-API-Reachability-Probe.
## Aktueller Stand ## Aktueller Stand
✅ **β-0 — Setup** ✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)**
- Repo-Skelett unter `git.mana.how/till/cards-native` - Repo-Skelett unter `git.mana.how/till/cards-native`
- `project.yml` mit Bundle-ID `ev.mana.cards`, ManaSwiftCore via - `project.yml` mit Bundle-ID `ev.mana.cards`, ManaSwiftCore via
`path: ../mana-swift-core` `path: ../mana-swift-core`
@ -21,16 +22,27 @@ Cardecky-API-Reachability-Probe.
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus - `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus
`mana/packages/themes/src/variants/forest.css`) `mana/packages/themes/src/variants/forest.css`)
- `LoginView` (Email/PW gegen mana-auth) - `LoginView` (Email/PW gegen mana-auth)
- `DashboardView` als β-1-Placeholder mit API-Reachability-Indikator
- 3 Unit-Tests (AppConfig) - 3 Unit-Tests (AppConfig)
- iOS-Simulator-Build grün
✅ **β-1 — Decks lesen (2026-05-13, Tag `v0.2.0`)**
- `Deck`-Codable-DTO mit snake_case-CodingKeys, plus
`DeckCategory`, `DeckVisibility`, `FsrsSettings`
- ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz
- `CardsAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)`
- `CachedDeck` als SwiftData-Model mit `lastFetchedAt` (Offline-Read)
- `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching
via TaskGroup
- `DeckListView` mit Pull-to-Refresh, Card/Due-Counts, deck.color-Streifen,
Inbox-Banner für Marketplace-Forks
- `AccountView` mit Sign-out-Button
- iOS-Simulator-Build + Tests grün (6 Unit-Tests, 1 UI-Test)
## Phasen (Detail in Greenfield-Plan) ## Phasen (Detail in Greenfield-Plan)
| Phase | Status | Inhalt | | Phase | Status | Inhalt |
|---|---|---| |---|---|---|
| β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe | | β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe |
| β-1 | ⏳ | Decks lesen, SwiftData-Cache | | β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh |
| β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test | | β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test |
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) | | β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
| β-4 | — | Media, image-occlusion (PencilKit), audio-front | | β-4 | — | Media, image-occlusion (PencilKit), audio-front |
@ -38,20 +50,22 @@ Cardecky-API-Reachability-Probe.
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
| β-7 | — | App-Store-Submission | | β-7 | — | App-Store-Submission |
## Nächste Schritte für β-1 ## Nächste Schritte für β-2
Aus Greenfield-Plan-Sektion "Phase β-1 — Decks lesen": Aus Greenfield-Plan-Sektion "Phase β-2 — Study-Loop":
1. `Deck`-`Codable`-Struct nach Wire-Format aus 1. `Card`-DTO + `Review`-DTO aus `cards/apps/api/src/lib/dto.ts`
`../cards/apps/api/src/routes/decks.ts` + `cards/packages/cards-domain/src/schemas/` 2. `CardsAPI.dueCards(deckId:)` → fetcht `/reviews/due` + zugehörige
2. `CardsAPI.decks() -> [Deck]` mit `GET /api/v1/decks` `/cards/:id`-Details für die Karten-Inhalte
3. `DeckListView` mit Pull-to-Refresh, Card/Due-Counts 3. `StudySessionView` mit `CardRenderer`-switch (basic + basic-reverse
4. `CachedDeck` als SwiftData-Model mit `lastFetchedAt` + cloze; cloze-Rendering kommt vom Server via `renderClozePrompt`)
5. Offline-Display bei fehlendem Netz 4. Flip-Animation, Rating-Bar (`again | hard | good | easy`)
6. Inbox-Banner aus `?forked_from_marketplace=true`-Query 5. `POST /api/v1/reviews/:cardId/:subIndex/grade` mit Haptic-Feedback
6. `PendingGrade` SwiftData-Model als Offline-Queue, Drain bei Reconnect
7. Endurance-Test auf realem Gerät (200+ Karten, Flugmodus zwischendurch)
**Erfolgskriterium:** Web-Account-Decks vollständig in identischer **Erfolgskriterium:** 50 Karten am Stück im Simulator durchgraden,
Reihenfolge sichtbar, Pull-to-Refresh aktualisiert Counts. Web zeigt nach Refresh die gleichen Reviews-States.
## Cross-Refs ## Cross-Refs

View file

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

View file

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

View file

@ -3,20 +3,86 @@ import ManaCore
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt. /// 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 { actor CardsAPI {
private let transport: AuthenticatedTransport private let transport: AuthenticatedTransport
private let decoder: JSONDecoder
init(auth: AuthClient) { init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
} }
/// Health-Probe für β-0 verifiziert dass cardecky-api erreichbar /// Health-Probe verifiziert dass cardecky-api erreichbar ist
/// ist und der eigene JWT akzeptiert wird. /// und der eigene JWT akzeptiert wird.
func healthCheck() async throws -> Bool { func healthCheck() async throws -> Bool {
let (_, http) = try await transport.request(path: "/healthz") let (_, http) = try await transport.request(path: "/healthz")
return http.statusCode == 200 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)
}
}

View file

@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import CardsNative
@Suite("Deck-JSON-Decoding")
struct DeckDecodingTests {
@Test("Wire-Format aus toDeckDto decodet sauber")
func decodesDeckFromWireFormat() throws {
let json = """
{
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"user_id": "user_123",
"name": "Spanisch A1",
"description": "Grundwortschatz",
"color": "#10803D",
"category": "language",
"visibility": "private",
"fsrs_settings": {"request_retention": 0.9, "maximum_interval": 365},
"content_hash": "abc123",
"forked_from_marketplace_deck_id": null,
"forked_from_marketplace_version_id": null,
"archived_at": null,
"created_at": "2026-05-12T10:30:00.123Z",
"updated_at": "2026-05-12T15:45:00.456Z"
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
let deck = try decoder.decode(Deck.self, from: json)
#expect(deck.id == "01ARZ3NDEKTSV4RRFFQ69G5FAV")
#expect(deck.name == "Spanisch A1")
#expect(deck.description == "Grundwortschatz")
#expect(deck.color == "#10803D")
#expect(deck.category == .language)
#expect(deck.visibility == .private)
#expect(deck.isFromMarketplace == false)
#expect(deck.archivedAt == nil)
}
@Test("Marketplace-Forks werden erkannt")
func recognizesMarketplaceFork() throws {
let json = """
{
"id": "deck_456",
"user_id": "user_123",
"name": "Geografie",
"description": null,
"color": null,
"category": null,
"visibility": "private",
"fsrs_settings": {},
"content_hash": null,
"forked_from_marketplace_deck_id": "mp_deck_789",
"forked_from_marketplace_version_id": "mp_ver_1",
"archived_at": null,
"created_at": "2026-05-01T00:00:00.000Z",
"updated_at": "2026-05-01T00:00:00.000Z"
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
let deck = try decoder.decode(Deck.self, from: json)
#expect(deck.isFromMarketplace == true)
#expect(deck.forkedFromMarketplaceDeckId == "mp_deck_789")
#expect(deck.category == nil)
}
@Test("DeckListResponse-Wrapper")
func decodesListResponse() throws {
let json = """
{
"decks": [
{
"id": "d1",
"user_id": "u1",
"name": "Deck 1",
"description": null,
"color": null,
"category": null,
"visibility": "private",
"fsrs_settings": {},
"content_hash": null,
"forked_from_marketplace_deck_id": null,
"forked_from_marketplace_version_id": null,
"archived_at": null,
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
],
"total": 1
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
let response = try decoder.decode(DeckListResponse.self, from: json)
#expect(response.total == 1)
#expect(response.decks.first?.name == "Deck 1")
}
}

View file

@ -95,3 +95,20 @@ targets:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests
GENERATE_INFOPLIST_FILE: "YES" GENERATE_INFOPLIST_FILE: "YES"
schemes:
CardsNative:
build:
targets:
CardsNative: all
CardsNativeTests: [test]
CardsNativeUITests: [test]
test:
targets:
- CardsNativeTests
- CardsNativeUITests
gatherCoverageData: false
run:
config: Debug
archive:
config: Release