feat(ui): Cardecky-Web-Design — Fan-Stack-Tiles + CardSurface

UI-Refactor angelehnt an cards/apps/web. Drei Killer-Patterns
übernommen:

1. CardSurface (Sources/Core/Theme/CardSurface.swift)
   - Drei Sizes md/lg/hero mit identischem Border-Radius 14pt,
     1pt Border, layered Shadows je nach Elevation
   - Aspect-Ratio 5:7 für md/hero, 12:16.8 für lg
   - Optional Color-Accent-Stripe links (6pt, deck.color)

2. DeckStackTile (Sources/Features/Decks/DeckStackTile.swift)
   - Spielkarten-Stack-Visual: 3 gestaffelt-rotierte
     Hintergrund-Layer hinter der CardSurface
   - Layer-Offsets + Tilts deterministisch aus Deck-ID gehasht
     (gleiches Deck = gleiche Asymmetrie)
   - Inhalt: Category-Icon oben rechts, Titel + Description
     zentriert, Counts unten als Pill für dueCount

3. RatingBar mit Good-Emphasis (Features/Study/RatingBar.swift)
   - "Good" als full primary background (hero action)
   - again/hard/easy mit subtle border-tint + opacity-08-Background
   - Keyboard-Shortcut im Button-Label als kbd-Style-Pill

DeckListView komplett umgebaut:
- Horizontale ScrollView mit scrollTransition + viewAligned-Snap
- Zwei Sektionen: "Eigene Decks" und "Abonniert"
- Inbox-Banner als highlight (primary opacity 0.08 mit border)
- Pending-Share-Banner mit warning-Tint
- Section-Headers mit Icon + Title + Count

StudySessionView.cardSurface nutzt jetzt CardSurface(.hero, .raised).

Build 6 → 7. Drei native Targets bauen, 35 Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 17:28:11 +02:00
parent 0b0872c8c0
commit aa94601409
6 changed files with 396 additions and 162 deletions

View file

@ -0,0 +1,131 @@
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`.
///
/// Die Layer-Offsets + Tilts sind deterministisch aus der Deck-ID
/// gehasht gleiches Deck zeigt immer gleiche Asymmetrie.
struct DeckStackTile: View {
let deck: CachedDeck
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)
}
private var cardContent: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Spacer()
Image(systemName: deck.category?.systemImageName ?? "rectangle.stack")
.font(.title3)
.foregroundStyle(CardsTheme.mutedForeground.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)
}
Spacer()
if deck.isFromMarketplace {
Image(systemName: "globe")
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
/// 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
}
private 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"
}
}
}