import SwiftUI /// Spiel-Karten-Stack-Visual mit drei gestaffelt-rotierten Hintergrund- /// Layern hinter einer `CardSurface`. Web-Vorbild: /// `cards/apps/web/src/lib/components/DeckStack.svelte`. /// /// Layout: Kategorie-Icon oben rechts (prominent in primary-Farbe), /// Titel + Description zentriert, Counts + Edit-Button unten. /// Tap auf die Tile triggert `onTap` (Study-Mode), Tap auf den /// Edit-Button triggert `onEdit` (Deck-Detail). struct DeckStackTile: View { let deck: CachedDeck let onTap: () -> Void let onEdit: () -> Void var body: some View { ZStack { // Drei Hintergrund-Layer (von hinten nach vorne) ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(CardsTheme.surface) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(CardsTheme.border, lineWidth: 1) ) .opacity(layer.opacity) .rotationEffect(.degrees(layer.tilt)) .offset(x: layer.dx, y: layer.dy) .shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1) } CardSurface(size: .md, elevation: .standard, colorAccentHex: deck.color) { cardContent } } .aspectRatio(5.0 / 7.0, contentMode: .fit) .frame(maxWidth: 280) .contentShape(Rectangle()) .onTapGesture { onTap() } } private var cardContent: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { Spacer() Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") .font(.title2) .foregroundStyle(CardsTheme.primary.opacity(0.85)) } Spacer(minLength: 0) VStack(alignment: .leading, spacing: 6) { Text(deck.name) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(CardsTheme.foreground) .lineLimit(3) if let description = deck.deckDescription, !description.isEmpty { Text(description) .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) .lineLimit(2) } } Spacer(minLength: 0) HStack(spacing: 8) { Label("\(deck.cardCount)", systemImage: "rectangle.stack") .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) if deck.dueCount > 0 { Text("\(deck.dueCount) fällig") .font(.caption2.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(CardsTheme.primary.opacity(0.15), in: Capsule()) .foregroundStyle(CardsTheme.primary) } if deck.isFromMarketplace { Image(systemName: "globe") .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) } Spacer() editButton } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } /// Edit-Button unten rechts. Eigener `Button` mit `.plain` style /// fängt den Tap und triggert nicht das Outer-`onTapGesture`. private var editButton: some View { Button { onEdit() } label: { Image(systemName: "pencil") .font(.footnote.weight(.semibold)) .foregroundStyle(CardsTheme.mutedForeground) .frame(width: 30, height: 30) .background(CardsTheme.muted.opacity(0.7), in: Circle()) .overlay( Circle().stroke(CardsTheme.border, lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel("Deck bearbeiten") } /// Deterministische Stack-Layer aus Deck-ID gehasht. private var layers: [StackLayer] { var hash = UInt64(0) for byte in deck.id.utf8 { hash = hash &* 31 &+ UInt64(byte) } return (0 ..< 3).map { index in let seed = hash &+ UInt64(index) &* 17 let tiltRaw = Double((seed >> 8) & 0xFF) / 255.0 - 0.5 let xRaw = Double((seed >> 16) & 0xFF) / 255.0 - 0.5 let yRaw = Double((seed >> 24) & 0xFF) / 255.0 - 0.5 let depth = Double(index + 1) return StackLayer( tilt: tiltRaw * 4.0, dx: xRaw * 6.0, dy: depth * 3.0 + yRaw * 2.0, opacity: 0.7 - depth * 0.18 ) } } } private struct StackLayer { let tilt: Double let dx: Double let dy: Double let opacity: Double } extension DeckCategory { var systemImageName: String { switch self { case .language: "character.book.closed" case .medicine: "cross.case" case .science: "atom" case .math: "function" case .history: "scroll" case .law: "scale.3d" case .technology: "cpu" case .arts: "paintbrush" case .music: "music.note" case .sport: "figure.run" case .other: "rectangle.stack" } } }