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 @State private var showCreate = false @State private var pendingShares: [PendingShare] = [] var body: some View { NavigationStack { ZStack { CardsTheme.background.ignoresSafeArea() content } .navigationTitle("Decks") .navigationDestination(for: String.self) { deckId in DeckDetailView(deckId: deckId) } .navigationDestination(for: PendingShareRoute.self) { route in PendingShareConsumeView(share: route.share, onDone: { PendingShareStore.remove(id: route.share.id) pendingShares = PendingShareStore.readAll() }) } .toolbar { toolbar } .refreshable { await store?.refresh() } .sheet(isPresented: $showCreate) { NavigationStack { DeckEditorView(mode: .create) { _ in Task { await store?.refresh() } } } } .task { if store == nil { store = DeckListStore(auth: auth, context: context) } await store?.refresh() pendingShares = PendingShareStore.readAll() } .onAppear { pendingShares = PendingShareStore.readAll() } .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 { pendingShareSection inboxBannerSection ownDecksSection } .listStyle(.plain) .scrollContentBackground(.hidden) } } @ViewBuilder private var pendingShareSection: some View { if !pendingShares.isEmpty { Section { ForEach(pendingShares) { share in NavigationLink(value: PendingShareRoute(share: share)) { HStack(spacing: 12) { Image(systemName: "square.and.arrow.down") .foregroundStyle(CardsTheme.primary) VStack(alignment: .leading, spacing: 2) { Text("Aus Teilen-Menü") .font(.subheadline.weight(.semibold)) .foregroundStyle(CardsTheme.foreground) Text(share.text) .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) .lineLimit(2) } Spacer() } .padding() .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } } } 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 NavigationLink(value: deck.id) { DeckRow(deck: deck) } .buttonStyle(.plain) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { showCreate = true } label: { Image(systemName: "plus.circle") .foregroundStyle(CardsTheme.primary) } .accessibilityLabel("Deck hinzufügen") } 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) } }