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

@ -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)
}
}