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:
parent
0b0872c8c0
commit
aa94601409
6 changed files with 396 additions and 162 deletions
108
Sources/Core/Theme/CardSurface.swift
Normal file
108
Sources/Core/Theme/CardSurface.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Wiederverwendbare Karten-Hülle in drei Größen — entspricht den
|
||||||
|
/// Web-`CardSurface.svelte`-Varianten. Konsistenter Border-Radius (14pt),
|
||||||
|
/// gleicher Border-Stil, gleiche Shadow-Behandlung über alle Größen,
|
||||||
|
/// optional ein linker Color-Accent-Streifen.
|
||||||
|
///
|
||||||
|
/// Spec aus `cards/apps/web/src/lib/components/CardSurface.svelte`:
|
||||||
|
/// - Alle Größen Border-Radius 0.875rem (14pt)
|
||||||
|
/// - Border 1px hsl(--color-border)
|
||||||
|
/// - Background hsl(--color-surface)
|
||||||
|
/// - Aspect-Ratio 5/7 für `.md` und `.hero`, fix für `.lg`
|
||||||
|
struct CardSurface<Content: View>: View {
|
||||||
|
enum Size: Sendable {
|
||||||
|
case md // Deck-Tile in der Liste (max-width 18rem)
|
||||||
|
case lg // Fan-Detail (12rem x 16.8rem)
|
||||||
|
case hero // Study-Lernkarte (max-width 24rem)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Elevation: Sendable {
|
||||||
|
case flat // Subtle shadow
|
||||||
|
case standard // Default Karten-Shadow
|
||||||
|
case raised // Study-Hero
|
||||||
|
}
|
||||||
|
|
||||||
|
let size: Size
|
||||||
|
let elevation: Elevation
|
||||||
|
let colorAccentHex: String?
|
||||||
|
let content: () -> Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
size: Size = .md,
|
||||||
|
elevation: Elevation = .standard,
|
||||||
|
colorAccentHex: String? = nil,
|
||||||
|
@ViewBuilder content: @escaping () -> Content
|
||||||
|
) {
|
||||||
|
self.size = size
|
||||||
|
self.elevation = elevation
|
||||||
|
self.colorAccentHex = colorAccentHex
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(CardsTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let colorAccentHex {
|
||||||
|
Color.swatchFromHex(colorAccentHex)
|
||||||
|
.frame(width: 6)
|
||||||
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
topLeadingRadius: 14,
|
||||||
|
bottomLeadingRadius: 14,
|
||||||
|
bottomTrailingRadius: 0,
|
||||||
|
topTrailingRadius: 0,
|
||||||
|
style: .continuous
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
.padding(EdgeInsets(top: 16, leading: 22, bottom: 18, trailing: 16))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: maxWidth)
|
||||||
|
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||||
|
.shadow(color: shadowColor, radius: shadowRadius, x: 0, y: shadowY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maxWidth: CGFloat? {
|
||||||
|
switch size {
|
||||||
|
case .md: 288 // 18rem
|
||||||
|
case .lg: 192 // 12rem
|
||||||
|
case .hero: 384 // 24rem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aspectRatio: CGFloat? {
|
||||||
|
switch size {
|
||||||
|
case .md, .hero: 5.0 / 7.0
|
||||||
|
case .lg: 12.0 / 16.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowColor: Color {
|
||||||
|
CardsTheme.foreground.opacity(elevation == .raised ? 0.18 : 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowRadius: CGFloat {
|
||||||
|
switch elevation {
|
||||||
|
case .flat: 3
|
||||||
|
case .standard: 8
|
||||||
|
case .raised: 18
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowY: CGFloat {
|
||||||
|
switch elevation {
|
||||||
|
case .flat: 1
|
||||||
|
case .standard: 4
|
||||||
|
case .raised: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@ import ManaCore
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts.
|
/// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen
|
||||||
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`.
|
/// mit Fan-Stack-Karten-Tiles. Web-Vorbild:
|
||||||
|
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||||
struct DeckListView: View {
|
struct DeckListView: View {
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
@ -69,25 +70,107 @@ struct DeckListView: View {
|
||||||
if decks.isEmpty {
|
if decks.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
List {
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
pendingShareSection
|
pendingShareSection
|
||||||
inboxBannerSection
|
inboxBanner
|
||||||
ownDecksSection
|
deckSection(title: "Eigene Decks", icon: "rectangle.stack", decks: ownDecks)
|
||||||
|
if !subscribedDecks.isEmpty {
|
||||||
|
deckSection(title: "Abonniert", icon: "globe", decks: subscribedDecks)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.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
|
@ViewBuilder
|
||||||
private var pendingShareSection: some View {
|
private var pendingShareSection: some View {
|
||||||
if !pendingShares.isEmpty {
|
if !pendingShares.isEmpty {
|
||||||
Section {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(pendingShares) { share in
|
ForEach(pendingShares) { share in
|
||||||
NavigationLink(value: PendingShareRoute(share: share)) {
|
NavigationLink(value: PendingShareRoute(share: share)) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "square.and.arrow.down")
|
Image(systemName: "square.and.arrow.down")
|
||||||
.foregroundStyle(CardsTheme.primary)
|
.foregroundStyle(CardsTheme.warning)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Aus Teilen-Menü")
|
Text("Aus Teilen-Menü")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
@ -98,16 +181,17 @@ struct DeckListView: View {
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(14)
|
||||||
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +215,7 @@ struct DeckListView: View {
|
||||||
Label("Noch keine Decks", systemImage: "rectangle.stack")
|
Label("Noch keine Decks", systemImage: "rectangle.stack")
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
} description: {
|
} description: {
|
||||||
Text("Erstelle dein erstes Deck auf cardecky.mana.how oder ziehe nach unten zum Aktualisieren.")
|
Text("Tippe oben auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.")
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,45 +223,6 @@ struct DeckListView: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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
|
@ToolbarContentBuilder
|
||||||
private var toolbar: some ToolbarContent {
|
private var toolbar: some ToolbarContent {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
|
@ -205,78 +250,3 @@ struct DeckListView: View {
|
||||||
return "person.crop.circle.badge.exclamationmark"
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
131
Sources/Features/Decks/DeckStackTile.swift
Normal file
131
Sources/Features/Decks/DeckStackTile.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,9 @@ import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating)
|
/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary).
|
||||||
/// plus Haptic-Feedback.
|
/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte`
|
||||||
|
/// — `.grade.again/.hard/.good/.easy`-Klassen.
|
||||||
struct RatingBar: View {
|
struct RatingBar: View {
|
||||||
let onRate: (Rating) -> Void
|
let onRate: (Rating) -> Void
|
||||||
|
|
||||||
|
|
@ -16,17 +17,24 @@ struct RatingBar: View {
|
||||||
triggerHaptic(for: rating)
|
triggerHaptic(for: rating)
|
||||||
onRate(rating)
|
onRate(rating)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 2) {
|
HStack(spacing: 6) {
|
||||||
Text(rating.label)
|
Text(rating.label)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
Text(rating.shortcut)
|
Text(rating.shortcut)
|
||||||
.font(.caption2)
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
.foregroundStyle(kbdForeground(for: rating))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10))
|
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.foregroundStyle(foreground(for: rating))
|
.foregroundStyle(foreground(for: rating))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
@ -34,12 +42,14 @@ struct RatingBar: View {
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `good` ist die Hero-Action (primary full background) — analog
|
||||||
|
/// zum Web-Default-Klick. Andere bekommen subtle tinted borders.
|
||||||
private func background(for rating: Rating) -> Color {
|
private func background(for rating: Rating) -> Color {
|
||||||
switch rating {
|
switch rating {
|
||||||
case .again: CardsTheme.error.opacity(0.12)
|
case .again: CardsTheme.error.opacity(0.06)
|
||||||
case .hard: CardsTheme.warning.opacity(0.12)
|
case .hard: CardsTheme.warning.opacity(0.06)
|
||||||
case .good: CardsTheme.primary.opacity(0.12)
|
case .good: CardsTheme.primary
|
||||||
case .easy: CardsTheme.success.opacity(0.12)
|
case .easy: CardsTheme.success.opacity(0.06)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,17 +57,37 @@ struct RatingBar: View {
|
||||||
switch rating {
|
switch rating {
|
||||||
case .again: CardsTheme.error
|
case .again: CardsTheme.error
|
||||||
case .hard: CardsTheme.warning
|
case .hard: CardsTheme.warning
|
||||||
case .good: CardsTheme.primary
|
case .good: CardsTheme.primaryForeground
|
||||||
case .easy: CardsTheme.success
|
case .easy: CardsTheme.success
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func borderColor(for rating: Rating) -> Color {
|
||||||
|
switch rating {
|
||||||
|
case .again: CardsTheme.error.opacity(0.4)
|
||||||
|
case .hard: CardsTheme.warning.opacity(0.4)
|
||||||
|
case .good: .clear
|
||||||
|
case .easy: CardsTheme.success.opacity(0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kbdBackground(for rating: Rating) -> Color {
|
||||||
|
rating == .good
|
||||||
|
? CardsTheme.primaryForeground.opacity(0.18)
|
||||||
|
: CardsTheme.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kbdForeground(for rating: Rating) -> Color {
|
||||||
|
rating == .good
|
||||||
|
? CardsTheme.primaryForeground.opacity(0.85)
|
||||||
|
: CardsTheme.mutedForeground
|
||||||
|
}
|
||||||
|
|
||||||
private func triggerHaptic(for rating: Rating) {
|
private func triggerHaptic(for rating: Rating) {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
let generator = UIImpactFeedbackGenerator(
|
let style: UIImpactFeedbackGenerator.FeedbackStyle =
|
||||||
style: rating == .easy ? .heavy : .medium
|
rating == .easy ? .heavy : .medium
|
||||||
)
|
UIImpactFeedbackGenerator(style: style).impactOccurred()
|
||||||
generator.impactOccurred()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,19 +101,14 @@ struct StudySessionView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
CardSurface(size: .hero, elevation: .raised) {
|
||||||
.fill(CardsTheme.surface)
|
|
||||||
.overlay(
|
|
||||||
CardRenderer(
|
CardRenderer(
|
||||||
card: due.card,
|
card: due.card,
|
||||||
subIndex: due.review.subIndex,
|
subIndex: due.review.subIndex,
|
||||||
isFlipped: isFlipped
|
isFlipped: isFlipped
|
||||||
)
|
)
|
||||||
)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.overlay(
|
}
|
||||||
RoundedRectangle(cornerRadius: 16)
|
|
||||||
.stroke(CardsTheme.border, lineWidth: 1)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ targets:
|
||||||
path: Sources/Resources/Info.plist
|
path: Sources/Resources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleShortVersionString: "0.1.0"
|
CFBundleShortVersionString: "0.1.0"
|
||||||
CFBundleVersion: "6"
|
CFBundleVersion: "7"
|
||||||
CFBundleDevelopmentRegion: de
|
CFBundleDevelopmentRegion: de
|
||||||
CFBundleDisplayName: Cardecky
|
CFBundleDisplayName: Cardecky
|
||||||
LSApplicationCategoryType: "public.app-category.education"
|
LSApplicationCategoryType: "public.app-category.education"
|
||||||
|
|
@ -111,7 +111,7 @@ targets:
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Als Karte speichern
|
CFBundleDisplayName: Als Karte speichern
|
||||||
CFBundleShortVersionString: "0.1.0"
|
CFBundleShortVersionString: "0.1.0"
|
||||||
CFBundleVersion: "6"
|
CFBundleVersion: "7"
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.share-services
|
NSExtensionPointIdentifier: com.apple.share-services
|
||||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
||||||
|
|
@ -144,7 +144,7 @@ targets:
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Cardecky Widget
|
CFBundleDisplayName: Cardecky Widget
|
||||||
CFBundleShortVersionString: "0.1.0"
|
CFBundleShortVersionString: "0.1.0"
|
||||||
CFBundleVersion: "6"
|
CFBundleVersion: "7"
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||||
entitlements:
|
entitlements:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue