From 90201d71990baa929de76131bf34d2d73f59a059 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 18:03:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(decks):=20Tile-Redesign=20=E2=80=94=20Tap?= =?UTF-8?q?=3DStudy,=20Edit-Icon,=20Explore-Konsistenz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Feedback umgesetzt: - Tap auf Deck-Tile öffnet jetzt direkt den Study-Mode für dieses Deck (statt Deck-Detail). DeckRoute-Enum mit .study/.detail-Cases + programmatic NavigationPath. - Edit-Icon (Pencil) unten rechts auf der Tile in Muted-Circle-Badge; Tap führt in den Deck-Detail-View (Browse Cards + Bearbeiten). - Kategorie-Icon oben rechts jetzt in primary-Farbe (war muted) + größer (.title2 statt .title3) — visuell prominenter. - Inbox-Banner ist jetzt als Button → Study-Mode mit dem ersten Inbox-Deck. ExploreView/PublicDeckCard: - Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, CardSurface, Kategorie-Icon oben rechts, Footer mit Counts + Owner). - Featured-Star-Badge oben links statt rechts (damit Kategorie-Icon konsistent rechts bleibt). - Star-Count als ausgefüllter Stern in warning-Farbe. - Owner-Name unter den Counts, mit verified-Seal wenn vorhanden. Build 10 → 11. 43 Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Features/Decks/DeckListView.swift | 78 ++++++++----- Sources/Features/Decks/DeckStackTile.swift | 38 ++++++- .../Features/Marketplace/ExploreView.swift | 104 ++++++++++++------ project.yml | 6 +- 4 files changed, 153 insertions(+), 73 deletions(-) diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 8d8fb42..f224839 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -2,6 +2,14 @@ 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`. @@ -14,16 +22,22 @@ struct DeckListView: View { @State private var showAccount = false @State private var showCreate = false @State private var pendingShares: [PendingShare] = [] + @State private var path = NavigationPath() var body: some View { - NavigationStack { + NavigationStack(path: $path) { ZStack { CardsTheme.background.ignoresSafeArea() content } .navigationTitle("Decks") - .navigationDestination(for: String.self) { deckId in - DeckDetailView(deckId: deckId) + .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: { @@ -111,11 +125,12 @@ struct DeckListView: View { 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) + 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) @@ -135,30 +150,35 @@ struct DeckListView: View { @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) + 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) } - 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) } - .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) } } diff --git a/Sources/Features/Decks/DeckStackTile.swift b/Sources/Features/Decks/DeckStackTile.swift index 8b041d0..fd08b43 100644 --- a/Sources/Features/Decks/DeckStackTile.swift +++ b/Sources/Features/Decks/DeckStackTile.swift @@ -4,10 +4,14 @@ import SwiftUI /// 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. +/// 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 { @@ -31,6 +35,8 @@ struct DeckStackTile: View { } .aspectRatio(5.0 / 7.0, contentMode: .fit) .frame(maxWidth: 280) + .contentShape(Rectangle()) + .onTapGesture { onTap() } } private var cardContent: some View { @@ -38,8 +44,8 @@ struct DeckStackTile: View { HStack(alignment: .top) { Spacer() Image(systemName: deck.category?.systemImageName ?? "rectangle.stack") - .font(.title3) - .foregroundStyle(CardsTheme.mutedForeground.opacity(0.85)) + .font(.title2) + .foregroundStyle(CardsTheme.primary.opacity(0.85)) } Spacer(minLength: 0) @@ -72,17 +78,37 @@ struct DeckStackTile: View { .background(CardsTheme.primary.opacity(0.15), in: Capsule()) .foregroundStyle(CardsTheme.primary) } - Spacer() 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) @@ -112,7 +138,7 @@ private struct StackLayer { let opacity: Double } -private extension DeckCategory { +extension DeckCategory { var systemImageName: String { switch self { case .language: "character.book.closed" diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index 34a0c40..27dcef0 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -124,57 +124,91 @@ enum MarketplaceRoute: Hashable { } /// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. +/// Selbes Tile-Layout wie DeckStackTile (5:7 Aspect-Ratio, +/// CardSurface, Kategorie-Icon oben rechts), aber für PublicDeckEntry- +/// Daten. Star-Count statt Edit-Button unten rechts. struct PublicDeckCard: View { let entry: PublicDeckEntry var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(entry.title) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) - .lineLimit(2) - Spacer() + ZStack { + CardSurface(size: .md, elevation: .standard, colorAccentHex: nil) { + cardContent + } + } + .aspectRatio(5.0 / 7.0, contentMode: .fit) + .frame(maxWidth: 280) + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { if entry.isFeatured { Image(systemName: "star.fill") .font(.caption) .foregroundStyle(CardsTheme.warning) } + Spacer() + Image(systemName: categorySymbol) + .font(.title2) + .foregroundStyle(CardsTheme.primary.opacity(0.85)) } - if let description = entry.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - .lineLimit(2) - } - HStack(spacing: 12) { - Label("\(entry.cardCount)", systemImage: "rectangle.stack") - Label("\(entry.starCount)", systemImage: "star") - if entry.isPaid { - Label("\(entry.priceCredits) Credits", systemImage: "creditcard") - .foregroundStyle(CardsTheme.primary) + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + Text(entry.title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(CardsTheme.foreground) + .lineLimit(3) + + if let description = entry.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(2) } } - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - HStack(spacing: 4) { - Text(entry.owner.displayName) - .font(.caption2) - .foregroundStyle(CardsTheme.mutedForeground) - if entry.owner.verifiedMana { - Image(systemName: "checkmark.seal.fill") + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Label("\(entry.cardCount)", systemImage: "rectangle.stack") .font(.caption2) - .foregroundStyle(CardsTheme.primary) + .foregroundStyle(CardsTheme.mutedForeground) + Label("\(entry.starCount)", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.warning) + if entry.isPaid { + Label("\(entry.priceCredits)", systemImage: "creditcard") + .font(.caption2.weight(.semibold)) + .foregroundStyle(CardsTheme.primary) + } + Spacer() + } + HStack(spacing: 4) { + Text(entry.owner.displayName) + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + .lineLimit(1) + if entry.owner.verifiedMana { + Image(systemName: "checkmark.seal.fill") + .font(.caption2) + .foregroundStyle(CardsTheme.primary) + } } } } - .padding(12) - .frame(width: 260, alignment: .leading) - .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(CardsTheme.border, lineWidth: 1) - ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var categorySymbol: String { + guard let category = entry.category, + let parsed = DeckCategory(rawValue: category) + else { + return "rectangle.stack" + } + return parsed.systemImageName } } diff --git a/project.yml b/project.yml index eb17b87..7e06a9c 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "10" + CFBundleVersion: "11" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: