import ManaCore import SwiftData import SwiftUI /// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen /// mit Fan-Stack-Karten-Tiles. 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 { ScrollView { VStack(alignment: .leading, spacing: 24) { pendingShareSection inboxBanner deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks) if !subscribedDecks.isEmpty { deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks) } } .padding(.vertical, 12) } } } private var ownDecks: [CachedDeck] { decks.filter { !$0.isFromMarketplace } } private var subscribedDecks: [CachedDeck] { decks.filter { $0.isFromMarketplace } } @ViewBuilder private func deckSection(title: String, icon: String, decks: [CachedDeck]) -> some View { if !decks.isEmpty { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(CardsTheme.primary) Text(title) .font(.title3.weight(.semibold)) .foregroundStyle(CardsTheme.foreground) Text("\(decks.count)") .font(.subheadline) .foregroundStyle(CardsTheme.mutedForeground) } .padding(.horizontal, 20) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 16) { ForEach(decks) { deck in NavigationLink(value: deck.id) { DeckStackTile(deck: deck) .frame(width: 240) } .buttonStyle(.plain) .scrollTransition(.animated) { content, phase in content .scaleEffect(phase.isIdentity ? 1 : 0.92) .opacity(phase.isIdentity ? 1 : 0.7) } } } .padding(.horizontal, 20) .padding(.bottom, 12) .scrollTargetLayout() } .scrollTargetBehavior(.viewAligned) } } } @ViewBuilder private var inboxBanner: some View { if let inbox = decks.first(where: { $0.isFromMarketplace && $0.dueCount > 0 }) { HStack(spacing: 12) { Image(systemName: "tray.full.fill") .font(.title3) .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() Image(systemName: "chevron.right") .font(.footnote) .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) ) .padding(.horizontal, 20) } } @ViewBuilder private var pendingShareSection: some View { if !pendingShares.isEmpty { VStack(alignment: .leading, spacing: 8) { ForEach(pendingShares) { share in NavigationLink(value: PendingShareRoute(share: share)) { HStack(spacing: 12) { Image(systemName: "square.and.arrow.down") .foregroundStyle(CardsTheme.warning) 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() Image(systemName: "chevron.right") .font(.footnote) .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) } } .padding(.horizontal, 20) } } 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("Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") .foregroundStyle(CardsTheme.mutedForeground) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } @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" } }