import ManaAuthUI import ManaCore import SwiftData import SwiftUI /// Navigations-Routen für die DeckListView. Tap auf eine Tile geht /// direkt in Study-Mode, Tap auf den Edit-Button in den Deck-Detail- /// View für Browse + Edit. enum DeckRoute: Hashable { case study(deckId: String, deckName: String) case detail(deckId: String) } /// 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(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] @Binding var showCreate: Bool private var isGuest: Bool { if case .signedIn = auth.status { false } else { true } } @State private var store: DeckListStore? @State private var pendingShares: [PendingShare] = [] @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { ZStack { CardsTheme.background.ignoresSafeArea() content } .navigationTitle("Decks") .navigationDestination(for: DeckRoute.self) { route in switch route { case let .study(deckId, deckName): StudySessionView(deckId: deckId, deckName: deckName) case let .detail(deckId): 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() } } } @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(\.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 DeckStackTile( deck: deck, onTap: { path.append(DeckRoute.study(deckId: deck.id, deckName: deck.name)) }, onEdit: { path.append(DeckRoute.detail(deckId: deck.id)) } ) .frame(width: 240) .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 }) { Button { path.append(DeckRoute.study(deckId: inbox.id, deckName: inbox.name)) } label: { 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) } .buttonStyle(.plain) } } @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 if isGuest { ContentUnavailableView { Label("Cardecky ohne Konto", systemImage: "person.crop.circle.dashed") .foregroundStyle(CardsTheme.foreground) } description: { Text( "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." ) .foregroundStyle(CardsTheme.mutedForeground) } actions: { Button("Anmelden / Konto erstellen") { authGate.require(reason: "deck-list-empty") {} } .buttonStyle(.borderedProminent) .tint(CardsTheme.primary) } } else { ContentUnavailableView { Label("Noch keine Decks", systemImage: "rectangle.stack") .foregroundStyle(CardsTheme.foreground) } description: { Text( "Tippe unten 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 { // Auf iOS 26 übernimmt das `.tabViewBottomAccessory` aus RootView die // „Neues Deck"-Pille. Doppelten „+"-Button im Liquid-Glass-Layout // vermeiden — bottomBar-Button nur auf iOS < 26 zeigen. if #unavailable(iOS 26.0) { ToolbarItemGroup(placement: .bottomBar) { Button { authGate.require(reason: "deck-create-toolbar") { showCreate = true } } label: { Label("Deck hinzufügen", systemImage: "plus") .labelStyle(.iconOnly) .foregroundStyle(CardsTheme.primary) } .accessibilityLabel("Deck hinzufügen") Spacer() } } } }