From 07ada72b0f1f8e59eddac5088878250edd875fea Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 00:51:12 +0200 Subject: [PATCH] =?UTF-8?q?v0.6.0=20=E2=80=94=20Phase=20=CE=B2-5=20Marketp?= =?UTF-8?q?lace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voller Marketplace-Flow mit TabBar und Universal-Link-Handler. Drei Live-Decks (Geografie, English A2, Periodensystem) sind browse-, abonnier- und lernbar. - PublicDeckEntry/PublicDeck/PublicDeckVersion/PublicDeckOwner/ PublicDeckDetail Codable mit snake_case - ExploreResponse, BrowseResponse, SubscribeResponse - MarketplaceSort-Enum (recent/popular/trending) - CardsAPI.explore/browseMarketplace/publicDeck/subscribe/unsubscribe - MarketplaceStore @Observable mit Explore + Browse States - ExploreView: Featured + Trending Horizontal-Carousels, Browse-Link - BrowseView: Searchable + Sort-Picker + List - PublicDeckView: Header/Metadata/Subscribe — Subscribe löst Auto-Fork serverseitig aus, Response liefert private_deck_id, NavigationLink zum eigenen Deck - PublicDeckCard + BrowseRow mit forest-Theme - RootView: TabBar (Decks/Entdecken/Account) statt Single-View - Universal-Link-Handler: onOpenURL + onContinueUserActivity für https://cardecky.mana.how/d/ und cards://d/ - associated-domains: applinks:cardecky.mana.how im entitlement - 5 neue Marketplace-Decoding-Tests (35 Total grün) Universal-Links funktionieren erst nach AASA-Setup auf cardecky.mana.how/.well-known/apple-app-site-association (Web-Aufgabe, heute 404). Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 57 +++-- Sources/App/RootView.swift | 52 ++++- Sources/Core/API/CardsAPI.swift | 66 ++++++ Sources/Core/Domain/Marketplace.swift | 162 ++++++++++++++ Sources/Features/Marketplace/BrowseView.swift | 142 +++++++++++++ .../Features/Marketplace/ExploreView.swift | 180 ++++++++++++++++ .../Marketplace/MarketplaceStore.swift | 56 +++++ .../Features/Marketplace/PublicDeckView.swift | 201 ++++++++++++++++++ .../UnitTests/MarketplaceDecodingTests.swift | 121 +++++++++++ project.yml | 2 + 10 files changed, 1015 insertions(+), 24 deletions(-) create mode 100644 Sources/Core/Domain/Marketplace.swift create mode 100644 Sources/Features/Marketplace/BrowseView.swift create mode 100644 Sources/Features/Marketplace/ExploreView.swift create mode 100644 Sources/Features/Marketplace/MarketplaceStore.swift create mode 100644 Sources/Features/Marketplace/PublicDeckView.swift create mode 100644 Tests/UnitTests/MarketplaceDecodingTests.swift diff --git a/PLAN.md b/PLAN.md index 7ffe6f0..36d7897 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,10 +1,9 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-13 — Phasen β-0 bis β-4 abgeschlossen.** -Alle 7 Card-Types werden gerendert und können erstellt werden, -inklusive image-occlusion (Touch-Drag-Mask-Editor) und audio-front -(File-Picker + AVAudioPlayer). MediaCache mit LRU 200 MB. -30 Unit-Tests + 1 UI-Test grün. +**Stand: 2026-05-13 — Phasen β-0 bis β-5 abgeschlossen.** +Alle 7 Card-Types + voller Marketplace (Explore/Browse/Subscribe) ++ TabBar + Universal-Link-Handling für `cardecky.mana.how/d/`. +35 Unit-Tests + 1 UI-Test grün. Pflicht-Check für β-2: Endurance-Test auf realem Gerät (200+ Karten mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. @@ -28,6 +27,30 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. - `LoginView` (Email/PW gegen mana-auth) - 3 Unit-Tests (AppConfig) +✅ **β-5 — Marketplace (2026-05-13, Tag `v0.6.0`)** +- `PublicDeckEntry`, `PublicDeck`, `PublicDeckVersion`, `PublicDeckOwner`, + `PublicDeckDetail`, `ExploreResponse`, `BrowseResponse`, + `SubscribeResponse` Codable-DTOs mit snake_case +- `MarketplaceSort` Enum (recent/popular/trending) mit deutschen Labels +- `CardsAPI`: explore(), browseMarketplace(query:sort:language:), + publicDeck(slug:), subscribe(slug:), unsubscribe(slug:) +- `MarketplaceStore` @Observable mit Explore-State + Browse-State +- `ExploreView` mit Featured + Trending Carousels, Browse-Link +- `BrowseView` mit Searchable + Sort-Picker + Liste +- `PublicDeckView` mit Header + Version + Owner + Subscribe-Button + (Auto-Fork serverseitig, danach NavigationLink zum eigenen Deck) +- `PublicDeckCard` + `BrowseRow` Komponenten mit forest-Theme +- `RootView` → TabBar (Decks / Entdecken / Account) statt Single-View +- Universal-Link-Handler in `RootView` (onOpenURL + onContinueUserActivity): + `https://cardecky.mana.how/d/` und `cards://d/` → Explore-Tab + öffnet `PublicDeckView` +- `associated-domains: applinks:cardecky.mana.how` im entitlement +- 5 neue Marketplace-Decoding-Tests (35 Total grün) + +**Wichtig:** Universal-Links funktionieren erst, wenn AASA-Endpoint +unter `cardecky.mana.how/.well-known/apple-app-site-association` +ausgeliefert wird — heute 404. Web-seitige Aufgabe. + ✅ **β-4 — Media + Advanced Card-Types (2026-05-13, Tag `v0.5.0`)** - `MediaUploadResponse` DTO + `MediaKind`-Enum - `MaskRegion` Codable mit 0..1-Coordinates, `MaskRegions.parse/encode`- @@ -112,26 +135,20 @@ mit Flugmodus zwischendurch) steht aus — Aufgabe für Till. | β-2 | ✅ 2026-05-13 | Study-Loop, Offline-Grade-Queue (Endurance-Test offen) | | β-3 | ✅ 2026-05-13 | Editor: Deck-CRUD + Card-Create (5 Types); Anki-Import auf β-3-ext verschoben | | β-4 | ✅ 2026-05-13 | Media-Upload, image-occlusion (Touch-Mask-Editor), audio-front (AVAudioPlayer) | -| β-5 | — | Marketplace, Universal-Links | +| β-5 | ✅ 2026-05-13 | Marketplace (Explore/Browse/Subscribe) + TabBar + Universal-Link-Handler (AASA server-side pending) | | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-7 | — | App-Store-Submission | -## Nächste Schritte für β-5 (Marketplace) +## Nächste Schritte für β-6 (Native-Polish) -Aus Greenfield-Plan-Sektion "Phase β-5": +Aus Greenfield-Plan-Sektion "Phase β-6": -1. `ExploreView`: GET `/api/v1/marketplace/explore` — Featured/Trending -2. `BrowseView`: GET `/api/v1/marketplace/decks/browse` mit Filter-Bar -3. `PublicDeckView`: GET `/api/v1/marketplace/decks/:slug` — Detail mit - Subscribe-Button (= POST `/subscribe/:slug`, Auto-Fork) -4. Subscribed-Decks-Liste als zweite Section in `DeckListView` -5. **Universal-Links**: `cardecky.mana.how/d/:slug` öffnet App direkt - -**Erfolgskriterium:** Drei Live-Decks (geografie-welt-top30, english-a2, -periodensystem-elemente) sichtbar, subscribebar, lernbar. - -**Vorbedingung:** AASA auf `cardecky.mana.how/.well-known/apple-app-site-association` -muss aufgesetzt werden — heute 404. Aufgabe ans Cards-Web-Repo. +1. WidgetKit-Extension (Small, Medium, Lock-Screen) mit Due-Count +2. UNUserNotificationCenter — tägliche Reminder zur konfigurierten Zeit +3. Siri-Shortcuts ("Karten lernen" → Default-Deck) +4. Share-Extension "Save as Card" für Safari/Mail +5. Keyboard-Shortcuts iPad/macOS (Space=flip, 1-4=Rating, J/K=next/prev) +6. App-Group `group.ev.mana.cards` für Widget-Daten-Sharing ## Notizen aus β-4 diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 4f7333a..79ab44a 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,18 +1,62 @@ import ManaCore import SwiftUI -/// Top-Level-Switch: Login vs Deck-Liste. -/// Ab Phase β-3 könnte hier eine Tab-Bar entstehen (Decks / Study / -/// Stats / Account) — für β-1 reicht der einfache Switch. +/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit +/// drei Tabs (Decks / Entdecken / Account). struct RootView: View { @Environment(AuthClient.self) private var auth + @State private var selectedTab: AppTab = .decks + @State private var pendingDeepLinkSlug: String? var body: some View { switch auth.status { case .signedIn: - DeckListView() + mainTabs + .onOpenURL { url in handle(url: url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } + } case .unknown, .signedOut, .signingIn, .error: LoginView() } } + + @ViewBuilder + private var mainTabs: some View { + TabView(selection: $selectedTab) { + DeckListView() + .tabItem { Label("Decks", systemImage: "rectangle.stack") } + .tag(AppTab.decks) + + ExploreView(deepLinkSlug: $pendingDeepLinkSlug) + .tabItem { Label("Entdecken", systemImage: "sparkles") } + .tag(AppTab.explore) + + NavigationStack { + AccountView() + } + .tabItem { Label("Account", systemImage: "person.crop.circle") } + .tag(AppTab.account) + } + } + + /// Universal-Link- und URL-Scheme-Handler: + /// - `https://cardecky.mana.how/d/` → Explore-Tab + PublicDeckView + /// - `cards://study/` → später (β-6 Notifications) + private func handle(url: URL) { + Log.app.info("Open URL: \(url.absoluteString, privacy: .public)") + if url.host == "cardecky.mana.how" || url.scheme == "cards" { + let parts = url.pathComponents.filter { $0 != "/" } + if parts.count >= 2, parts[0] == "d" { + pendingDeepLinkSlug = parts[1] + selectedTab = .explore + } + } + } +} + +enum AppTab: Hashable { + case decks + case explore + case account } diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index f40a18c..f9472bc 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -54,6 +54,72 @@ actor CardsAPI { return try decoder.decode(DueReviewsResponse.self, from: data).total } + // MARK: - Marketplace + + /// `GET /api/v1/marketplace/explore` — Featured + Trending. + func explore() async throws -> ExploreResponse { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/explore") + try ensureOK(http, data: data) + return try decoder.decode(ExploreResponse.self, from: data) + } + + /// `GET /api/v1/marketplace/decks` — Browse mit Filtern. + func browseMarketplace( + query: String? = nil, + sort: MarketplaceSort = .recent, + language: String? = nil, + limit: Int = 20, + offset: Int = 0 + ) async throws -> BrowseResponse { + var items: [URLQueryItem] = [ + .init(name: "sort", value: sort.rawValue), + .init(name: "limit", value: "\(limit)"), + .init(name: "offset", value: "\(offset)"), + ] + if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { + items.append(.init(name: "q", value: query)) + } + if let language { + items.append(.init(name: "language", value: language)) + } + var components = URLComponents() + components.queryItems = items + let queryString = components.percentEncodedQuery ?? "" + let path = "/api/v1/marketplace/decks?\(queryString)" + + let (data, http) = try await transport.request(path: path) + try ensureOK(http, data: data) + return try decoder.decode(BrowseResponse.self, from: data) + } + + /// `GET /api/v1/marketplace/decks/:slug`. + func publicDeck(slug: String) async throws -> PublicDeckDetail { + let (data, http) = try await transport.request(path: "/api/v1/marketplace/decks/\(slug)") + try ensureOK(http, data: data) + return try decoder.decode(PublicDeckDetail.self, from: data) + } + + /// `POST /api/v1/marketplace/decks/:slug/subscribe` — Auto-Fork + /// passiert serverseitig, Response liefert `private_deck_id`. + @discardableResult + func subscribe(slug: String) async throws -> SubscribeResponse { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/subscribe", + method: "POST" + ) + try ensureOK(http, data: data) + return try decoder.decode(SubscribeResponse.self, from: data) + } + + /// `DELETE /api/v1/marketplace/decks/:slug/subscribe`. + func unsubscribe(slug: String) async throws { + let (data, http) = try await transport.request( + path: "/api/v1/marketplace/decks/\(slug)/subscribe", + method: "DELETE" + ) + try ensureOK(http, data: data) + } + // MARK: - Media /// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB. diff --git a/Sources/Core/Domain/Marketplace.swift b/Sources/Core/Domain/Marketplace.swift new file mode 100644 index 0000000..b4f929e --- /dev/null +++ b/Sources/Core/Domain/Marketplace.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Browse-Eintrag aus `/api/v1/marketplace/decks` und `.../explore`. +struct PublicDeckEntry: Codable, Hashable, Sendable, Identifiable { + let slug: String + let title: String + let description: String? + let language: String? + let category: String? + let license: String + let priceCredits: Int + let cardCount: Int + let starCount: Int + let subscriberCount: Int + let isFeatured: Bool + let createdAt: Date + let owner: PublicDeckOwner + + var id: String { slug } + + enum CodingKeys: String, CodingKey { + case slug, title, description, language, category, license + case priceCredits = "price_credits" + case cardCount = "card_count" + case starCount = "star_count" + case subscriberCount = "subscriber_count" + case isFeatured = "is_featured" + case createdAt = "created_at" + case owner + } + + var isPaid: Bool { priceCredits > 0 } +} + +struct PublicDeckOwner: Codable, Hashable, Sendable { + let slug: String + let displayName: String + let verifiedMana: Bool + let verifiedCommunity: Bool + let pseudonym: String? + + enum CodingKeys: String, CodingKey { + case slug + case displayName = "display_name" + case verifiedMana = "verified_mana" + case verifiedCommunity = "verified_community" + case pseudonym + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decode(String.self, forKey: .slug) + displayName = try c.decode(String.self, forKey: .displayName) + verifiedMana = try c.decode(Bool.self, forKey: .verifiedMana) + verifiedCommunity = try c.decode(Bool.self, forKey: .verifiedCommunity) + pseudonym = try c.decodeIfPresent(String.self, forKey: .pseudonym) + } +} + +/// Response von `GET /api/v1/marketplace/explore`. +struct ExploreResponse: Decodable, Sendable { + let featured: [PublicDeckEntry] + let trending: [PublicDeckEntry] +} + +/// Response von `GET /api/v1/marketplace/decks`. +struct BrowseResponse: Decodable, Sendable { + let items: [PublicDeckEntry] + let total: Int +} + +/// Vollständiges Public-Deck aus `GET /api/v1/marketplace/decks/:slug`. +struct PublicDeck: Codable, Hashable, Sendable, Identifiable { + let id: String + let slug: String + let title: String + let description: String? + let language: String? + let category: String? + let license: String + let priceCredits: Int + let ownerUserId: String + let latestVersionId: String? + let isFeatured: Bool + let isTakedown: Bool + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id, slug, title, description, language, category, license + case priceCredits = "price_credits" + case ownerUserId = "owner_user_id" + case latestVersionId = "latest_version_id" + case isFeatured = "is_featured" + case isTakedown = "is_takedown" + case createdAt = "created_at" + } +} + +struct PublicDeckVersion: Codable, Hashable, Sendable, Identifiable { + let id: String + let deckId: String + let semver: String + let changelog: String? + let contentHash: String? + let cardCount: Int + let publishedAt: Date + let deprecatedAt: Date? + + enum CodingKeys: String, CodingKey { + case id + case deckId = "deck_id" + case semver + case changelog + case contentHash = "content_hash" + case cardCount = "card_count" + case publishedAt = "published_at" + case deprecatedAt = "deprecated_at" + } +} + +/// Response von `GET /api/v1/marketplace/decks/:slug`. +struct PublicDeckDetail: Decodable, Sendable { + let deck: PublicDeck + let latestVersion: PublicDeckVersion? + let owner: PublicDeckOwner? + + enum CodingKeys: String, CodingKey { + case deck + case latestVersion = "latest_version" + case owner + } +} + +/// Response von `POST /api/v1/marketplace/decks/:slug/subscribe`. +struct SubscribeResponse: Decodable, Sendable { + let subscribed: Bool + let deckSlug: String + let currentVersionId: String? + let privateDeckId: String + + enum CodingKeys: String, CodingKey { + case subscribed + case deckSlug = "deck_slug" + case currentVersionId = "current_version_id" + case privateDeckId = "private_deck_id" + } +} + +/// Browse-Sort-Optionen aus `BrowseQuerySchema`. +enum MarketplaceSort: String, Sendable, CaseIterable { + case recent + case popular + case trending + + var label: String { + switch self { + case .recent: "Neueste" + case .popular: "Beliebt" + case .trending: "Im Trend" + } + } +} diff --git a/Sources/Features/Marketplace/BrowseView.swift b/Sources/Features/Marketplace/BrowseView.swift new file mode 100644 index 0000000..37b9ba9 --- /dev/null +++ b/Sources/Features/Marketplace/BrowseView.swift @@ -0,0 +1,142 @@ +import ManaCore +import SwiftUI + +/// Browse-View: Suche, Sortier-Picker, Sprachfilter, Liste der Resultate. +struct BrowseView: View { + @Environment(AuthClient.self) private var auth + @State private var store: MarketplaceStore? + @State private var queryText: String = "" + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + VStack(spacing: 0) { + filters + Divider().background(CardsTheme.border) + resultsList + } + } + .navigationTitle("Durchsuchen") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .searchable(text: $queryText, placement: .navigationBarDrawer(displayMode: .always), + prompt: "Decks suchen") + .onSubmit(of: .search) { + store?.browseQuery = queryText + Task { await store?.browse() } + } + .task { + if store == nil { + store = MarketplaceStore(auth: auth) + await store?.browse() + } + } + } + + @ViewBuilder + private var filters: some View { + if let store { + HStack { + Picker("Sortierung", selection: Binding( + get: { store.browseSort }, + set: { newValue in + store.browseSort = newValue + Task { await store.browse() } + } + )) { + ForEach(MarketplaceSort.allCases, id: \.self) { sort in + Text(sort.label).tag(sort) + } + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + @ViewBuilder + private var resultsList: some View { + if let store { + if store.isLoadingBrowse, store.browseResults.isEmpty { + Spacer() + ProgressView() + .tint(CardsTheme.primary) + Spacer() + } else if store.browseResults.isEmpty { + ContentUnavailableView( + "Keine Decks gefunden", + systemImage: "magnifyingglass", + description: Text("Versuche eine andere Suche oder Sortierung.") + ) + .foregroundStyle(CardsTheme.foreground) + } else { + List { + ForEach(store.browseResults) { entry in + NavigationLink(value: MarketplaceRoute.publicDeck(slug: entry.slug)) { + BrowseRow(entry: entry) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { + await store.browse() + } + } + } + } +} + +struct BrowseRow: View { + let entry: PublicDeckEntry + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(entry.title) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + if entry.isFeatured { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + } + 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)", systemImage: "creditcard") + .foregroundStyle(CardsTheme.primary) + } + if let language = entry.language { + Text(language.uppercased()) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(CardsTheme.muted, in: Capsule()) + } + } + .font(.caption2) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.vertical, 8) + } +} diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift new file mode 100644 index 0000000..34a0c40 --- /dev/null +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -0,0 +1,180 @@ +import ManaCore +import SwiftUI + +/// Explore-Tab: Featured + Trending Sections, plus Browse-Link. +struct ExploreView: View { + @Binding var deepLinkSlug: String? + + @Environment(AuthClient.self) private var auth + @State private var store: MarketplaceStore? + @State private var path: [MarketplaceRoute] = [] + + init(deepLinkSlug: Binding = .constant(nil)) { + _deepLinkSlug = deepLinkSlug + } + + var body: some View { + NavigationStack(path: $path) { + ZStack { + CardsTheme.background.ignoresSafeArea() + content + } + .navigationTitle("Entdecken") + .navigationDestination(for: MarketplaceRoute.self) { route in + switch route { + case .browse: + BrowseView() + case let .publicDeck(slug): + PublicDeckView(slug: slug) + } + } + .navigationDestination(for: String.self) { deckId in + DeckDetailView(deckId: deckId) + } + .refreshable { + await store?.loadExplore() + } + .task { + if store == nil { + store = MarketplaceStore(auth: auth) + } + await store?.loadExplore() + } + .onChange(of: deepLinkSlug) { _, newSlug in + guard let slug = newSlug else { return } + path = [.publicDeck(slug: slug)] + deepLinkSlug = nil + } + } + } + + @ViewBuilder + private var content: some View { + if let store { + if store.isLoadingExplore, store.featured.isEmpty, store.trending.isEmpty { + ProgressView() + .tint(CardsTheme.primary) + } else if let message = store.errorMessage, store.featured.isEmpty { + ContentUnavailableView( + "Marketplace nicht erreichbar", + systemImage: "wifi.exclamationmark", + description: Text(message) + ) + .foregroundStyle(CardsTheme.foreground) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if !store.featured.isEmpty { + section(title: "Vorgestellt", items: store.featured) + } + if !store.trending.isEmpty { + section(title: "Im Trend", items: store.trending) + } + + NavigationLink(value: MarketplaceRoute.browse) { + HStack { + Label("Alle Decks durchsuchen", systemImage: "magnifyingglass") + Spacer() + Image(systemName: "chevron.right") + .font(.footnote) + } + .padding() + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(CardsTheme.border, lineWidth: 1) + ) + .foregroundStyle(CardsTheme.foreground) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + } + .padding(.vertical, 16) + } + } + } + } + + private func section(title: String, items: [PublicDeckEntry]) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(CardsTheme.foreground) + .padding(.horizontal, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(items) { item in + NavigationLink(value: MarketplaceRoute.publicDeck(slug: item.slug)) { + PublicDeckCard(entry: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + } + } +} + +/// Routen-Enum für die Marketplace-NavigationStack. +enum MarketplaceRoute: Hashable { + case browse + case publicDeck(slug: String) +} + +/// Public-Deck-Karten-Tile in Featured/Trending-Carousels und Browse-Grid. +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() + if entry.isFeatured { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + } + 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) + } + } + .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") + .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) + ) + } +} diff --git a/Sources/Features/Marketplace/MarketplaceStore.swift b/Sources/Features/Marketplace/MarketplaceStore.swift new file mode 100644 index 0000000..fa6f73b --- /dev/null +++ b/Sources/Features/Marketplace/MarketplaceStore.swift @@ -0,0 +1,56 @@ +import Foundation +import ManaCore +import Observation + +/// Holt Explore-Daten und Browse-Resultate. Browse hat einen aktuellen +/// Query-/Sort-State; bei Änderung wird neu gefetcht. +@MainActor +@Observable +final class MarketplaceStore { + private(set) var featured: [PublicDeckEntry] = [] + private(set) var trending: [PublicDeckEntry] = [] + private(set) var browseResults: [PublicDeckEntry] = [] + private(set) var isLoadingExplore = false + private(set) var isLoadingBrowse = false + private(set) var errorMessage: String? + + var browseQuery: String = "" + var browseSort: MarketplaceSort = .recent + var browseLanguage: String? + + private let api: CardsAPI + + init(auth: AuthClient) { + api = CardsAPI(auth: auth) + } + + func loadExplore() async { + isLoadingExplore = true + errorMessage = nil + defer { isLoadingExplore = false } + do { + let res = try await api.explore() + featured = res.featured + trending = res.trending + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + Log.api.error("Explore failed: \(self.errorMessage ?? "", privacy: .public)") + } + } + + func browse() async { + isLoadingBrowse = true + errorMessage = nil + defer { isLoadingBrowse = false } + do { + let res = try await api.browseMarketplace( + query: browseQuery, + sort: browseSort, + language: browseLanguage + ) + browseResults = res.items + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} diff --git a/Sources/Features/Marketplace/PublicDeckView.swift b/Sources/Features/Marketplace/PublicDeckView.swift new file mode 100644 index 0000000..1cf4b54 --- /dev/null +++ b/Sources/Features/Marketplace/PublicDeckView.swift @@ -0,0 +1,201 @@ +import ManaCore +import SwiftData +import SwiftUI + +/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork +/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. +struct PublicDeckView: View { + let slug: String + + @Environment(AuthClient.self) private var auth + @Environment(\.modelContext) private var context + @State private var detail: PublicDeckDetail? + @State private var isLoading = false + @State private var isSubscribing = false + @State private var errorMessage: String? + @State private var subscribed: SubscribeResponse? + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + content + } + .navigationTitle(detail?.deck.title ?? "Deck") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task(id: slug) { + await load() + } + } + + @ViewBuilder + private var content: some View { + if isLoading, detail == nil { + ProgressView() + .tint(CardsTheme.primary) + } else if let detail { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header(detail: detail) + Divider().background(CardsTheme.border) + metadata(detail: detail) + Divider().background(CardsTheme.border) + subscribeSection(detail: detail) + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(CardsTheme.error) + .padding(.horizontal, 16) + } + } + .padding(.vertical, 16) + } + } else if let errorMessage { + ContentUnavailableView( + "Deck nicht gefunden", + systemImage: "questionmark.folder", + description: Text(errorMessage) + ) + .foregroundStyle(CardsTheme.foreground) + } + } + + private func header(detail: PublicDeckDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(detail.deck.title) + .font(.title.bold()) + .foregroundStyle(CardsTheme.foreground) + if detail.deck.isFeatured { + Image(systemName: "star.fill") + .foregroundStyle(CardsTheme.warning) + } + } + if let description = detail.deck.description, !description.isEmpty { + Text(description) + .foregroundStyle(CardsTheme.mutedForeground) + } + } + .padding(.horizontal, 16) + } + + private func metadata(detail: PublicDeckDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + if let owner = detail.owner { + HStack(spacing: 6) { + Image(systemName: "person.crop.circle") + .foregroundStyle(CardsTheme.mutedForeground) + Text(owner.displayName) + .foregroundStyle(CardsTheme.foreground) + if owner.verifiedMana { + Image(systemName: "checkmark.seal.fill") + .font(.caption) + .foregroundStyle(CardsTheme.primary) + } + } + .font(.subheadline) + } + HStack(spacing: 16) { + if let version = detail.latestVersion { + Label("v\(version.semver)", systemImage: "tag") + Label("\(version.cardCount) Karten", systemImage: "rectangle.stack") + } + Label(detail.deck.license, systemImage: "doc.text") + if let language = detail.deck.language { + Label(language.uppercased(), systemImage: "globe") + } + } + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + + if let changelog = detail.latestVersion?.changelog, !changelog.isEmpty { + Text("Changelog") + .font(.caption.weight(.semibold)) + .foregroundStyle(CardsTheme.mutedForeground) + .padding(.top, 8) + Text(changelog) + .font(.caption) + .foregroundStyle(CardsTheme.foreground) + } + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private func subscribeSection(detail: PublicDeckDetail) -> some View { + VStack(spacing: 12) { + if let subscribed { + Label("Abonniert — dein Fork ist in deiner Bibliothek", systemImage: "checkmark.circle.fill") + .foregroundStyle(CardsTheme.success) + .padding() + .frame(maxWidth: .infinity) + .background(CardsTheme.success.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) + NavigationLink(value: subscribed.privateDeckId) { + Label("Zum eigenen Deck", systemImage: "arrow.right.circle") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + } else if detail.deck.isTakedown { + Label("Dieses Deck wurde entfernt", systemImage: "exclamationmark.triangle") + .foregroundStyle(CardsTheme.error) + } else if detail.deck.latestVersionId == nil { + Label("Noch keine Version veröffentlicht", systemImage: "clock") + .foregroundStyle(CardsTheme.mutedForeground) + } else { + Button { + Task { await subscribe(detail: detail) } + } label: { + HStack { + if isSubscribing { + ProgressView() + .controlSize(.small) + .tint(CardsTheme.primaryForeground) + } + Text(detail.deck.priceCredits > 0 + ? "Abonnieren (\(detail.deck.priceCredits) Credits)" + : "Abonnieren") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(CardsTheme.primaryForeground) + } + .buttonStyle(.plain) + .disabled(isSubscribing) + } + } + .padding(.horizontal, 16) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + let api = CardsAPI(auth: auth) + do { + detail = try await api.publicDeck(slug: slug) + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + private func subscribe(detail: PublicDeckDetail) async { + isSubscribing = true + errorMessage = nil + defer { isSubscribing = false } + let api = CardsAPI(auth: auth) + do { + let response = try await api.subscribe(slug: slug) + subscribed = response + // Cache neu laden, damit der Fork in der Liste auftaucht + let store = DeckListStore(auth: auth, context: context) + await store.refresh() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } +} diff --git a/Tests/UnitTests/MarketplaceDecodingTests.swift b/Tests/UnitTests/MarketplaceDecodingTests.swift new file mode 100644 index 0000000..c1d7a80 --- /dev/null +++ b/Tests/UnitTests/MarketplaceDecodingTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Marketplace-JSON-Decoding") +struct MarketplaceDecodingTests { + private func decoder() -> JSONDecoder { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601withFractional + return d + } + + @Test("PublicDeckEntry aus Browse-Response") + func decodesPublicDeckEntry() throws { + let json = """ + { + "slug": "geografie-welt-top30", + "title": "Geografie Welt Top 30", + "description": "Hauptstädte", + "language": "de", + "category": "geography", + "license": "CC0-1.0", + "price_credits": 0, + "card_count": 30, + "star_count": 12, + "subscriber_count": 5, + "is_featured": true, + "created_at": "2026-05-08T10:00:00.000Z", + "owner": { + "slug": "mana-curators", + "display_name": "mana-Kuratoren", + "verified_mana": true, + "verified_community": false, + "pseudonym": null + } + } + """.data(using: .utf8)! + + let entry = try decoder().decode(PublicDeckEntry.self, from: json) + #expect(entry.slug == "geografie-welt-top30") + #expect(entry.cardCount == 30) + #expect(entry.isFeatured == true) + #expect(entry.isPaid == false) + #expect(entry.owner.verifiedMana == true) + } + + @Test("ExploreResponse mit featured + trending") + func decodesExploreResponse() throws { + let json = """ + { + "featured": [], + "trending": [] + } + """.data(using: .utf8)! + let res = try decoder().decode(ExploreResponse.self, from: json) + #expect(res.featured.isEmpty) + #expect(res.trending.isEmpty) + } + + @Test("PublicDeckDetail mit camelCase 'latest_version'") + func decodesPublicDeckDetail() throws { + let json = """ + { + "deck": { + "id": "deck_1", + "slug": "english-a2", + "title": "English A2", + "description": null, + "language": "en", + "category": "language", + "license": "CC-BY-4.0", + "price_credits": 0, + "owner_user_id": "user_1", + "latest_version_id": "v_1", + "is_featured": false, + "is_takedown": false, + "created_at": "2026-05-01T00:00:00.000Z" + }, + "latest_version": { + "id": "v_1", + "deck_id": "deck_1", + "semver": "1.0.0", + "changelog": "Initial release", + "content_hash": "abc", + "card_count": 500, + "published_at": "2026-05-01T00:00:00.000Z", + "deprecated_at": null + }, + "owner": null + } + """.data(using: .utf8)! + + let detail = try decoder().decode(PublicDeckDetail.self, from: json) + #expect(detail.deck.slug == "english-a2") + #expect(detail.latestVersion?.semver == "1.0.0") + #expect(detail.latestVersion?.cardCount == 500) + #expect(detail.owner == nil) + } + + @Test("SubscribeResponse mit private_deck_id") + func decodesSubscribeResponse() throws { + let json = """ + { + "subscribed": true, + "deck_slug": "english-a2", + "current_version_id": "v_1", + "private_deck_id": "private_deck_xyz" + } + """.data(using: .utf8)! + let res = try decoder().decode(SubscribeResponse.self, from: json) + #expect(res.subscribed == true) + #expect(res.privateDeckId == "private_deck_xyz") + } + + @Test("MarketplaceSort-Werte sind exakt wie API erwartet") + func sortRawValues() { + #expect(MarketplaceSort.recent.rawValue == "recent") + #expect(MarketplaceSort.popular.rawValue == "popular") + #expect(MarketplaceSort.trending.rawValue == "trending") + } +} diff --git a/project.yml b/project.yml index b9d33d1..69705b5 100644 --- a/project.yml +++ b/project.yml @@ -64,6 +64,8 @@ targets: com.apple.security.files.user-selected.read-write: true keychain-access-groups: - $(AppIdentifierPrefix)ev.mana.cards + com.apple.developer.associated-domains: + - applinks:cardecky.mana.how settings: base: PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards