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
|
|
@ -2,8 +2,9 @@ import ManaCore
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// β-1 Hauptbildschirm: Liste aller Decks mit Card- und Due-Counts.
|
||||
/// Web-Vorbild: `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||
/// 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
|
||||
|
|
@ -69,25 +70,107 @@ struct DeckListView: View {
|
|||
if decks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
List {
|
||||
pendingShareSection
|
||||
inboxBannerSection
|
||||
ownDecksSection
|
||||
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)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Section {
|
||||
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.primary)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Aus Teilen-Menü")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
|
@ -98,16 +181,17 @@ struct DeckListView: View {
|
|||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
.padding()
|
||||
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(14)
|
||||
.background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.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")
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -139,45 +223,6 @@ struct DeckListView: View {
|
|||
.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
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
|
|
@ -205,78 +250,3 @@ struct DeckListView: View {
|
|||
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
|
||||
#endif
|
||||
|
||||
/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating)
|
||||
/// plus Haptic-Feedback.
|
||||
/// Vier Rating-Buttons mit emphasis auf "Good" (full-width primary).
|
||||
/// Web-Vorbild: `cards/apps/web/src/routes/study/[deckId]/+page.svelte`
|
||||
/// — `.grade.again/.hard/.good/.easy`-Klassen.
|
||||
struct RatingBar: View {
|
||||
let onRate: (Rating) -> Void
|
||||
|
||||
|
|
@ -16,17 +17,24 @@ struct RatingBar: View {
|
|||
triggerHaptic(for: rating)
|
||||
onRate(rating)
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(rating.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(rating.shortcut)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(kbdBackground(for: rating), in: RoundedRectangle(cornerRadius: 4))
|
||||
.foregroundStyle(kbdForeground(for: rating))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.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))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(borderColor(for: rating), lineWidth: rating == .good ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
|
@ -34,12 +42,14 @@ struct RatingBar: View {
|
|||
.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 {
|
||||
switch rating {
|
||||
case .again: CardsTheme.error.opacity(0.12)
|
||||
case .hard: CardsTheme.warning.opacity(0.12)
|
||||
case .good: CardsTheme.primary.opacity(0.12)
|
||||
case .easy: CardsTheme.success.opacity(0.12)
|
||||
case .again: CardsTheme.error.opacity(0.06)
|
||||
case .hard: CardsTheme.warning.opacity(0.06)
|
||||
case .good: CardsTheme.primary
|
||||
case .easy: CardsTheme.success.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,17 +57,37 @@ struct RatingBar: View {
|
|||
switch rating {
|
||||
case .again: CardsTheme.error
|
||||
case .hard: CardsTheme.warning
|
||||
case .good: CardsTheme.primary
|
||||
case .good: CardsTheme.primaryForeground
|
||||
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) {
|
||||
#if canImport(UIKit)
|
||||
let generator = UIImpactFeedbackGenerator(
|
||||
style: rating == .easy ? .heavy : .medium
|
||||
)
|
||||
generator.impactOccurred()
|
||||
let style: UIImpactFeedbackGenerator.FeedbackStyle =
|
||||
rating == .easy ? .heavy : .medium
|
||||
UIImpactFeedbackGenerator(style: style).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,21 +101,16 @@ struct StudySessionView: View {
|
|||
}
|
||||
|
||||
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(CardsTheme.surface)
|
||||
.overlay(
|
||||
CardRenderer(
|
||||
card: due.card,
|
||||
subIndex: due.review.subIndex,
|
||||
isFlipped: isFlipped
|
||||
)
|
||||
CardSurface(size: .hero, elevation: .raised) {
|
||||
CardRenderer(
|
||||
card: due.card,
|
||||
subIndex: due.review.subIndex,
|
||||
isFlipped: isFlipped
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
private func finishedView(session: StudySession) -> some View {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue