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:
parent
28b20cd934
commit
f664a00b64
12 changed files with 809 additions and 85 deletions
215
Sources/Features/Decks/DeckListView.swift
Normal file
215
Sources/Features/Decks/DeckListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue