From f664a00b6427ac85ab27ab54a09a90e07e9975d4 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 00:06:28 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20Phase=20=CE=B2-1=20Decks?= =?UTF-8?q?=20lesen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deck-Liste mit Web-Parität: alle eigenen Decks aus cardecky-api, Card-/Due-Counts pro Deck (Web-Pattern: separate Calls), Pull-to- Refresh, Offline-Read via SwiftData, Inbox-Banner für Marketplace- Forks. - Deck-Codable-DTO mit snake_case-CodingKeys (DeckCategory, DeckVisibility, FsrsSettings) - ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz - CardsAPI.listDecks() + cardCount() + dueCount() - CachedDeck SwiftData-Model mit lastFetchedAt - DeckListStore (API + Cache, paralleles Counts-Fetching via TaskGroup) - DeckListView mit forest-Theme, deck.color-Streifen, Inbox-Banner - AccountView mit Sign-out - DashboardView durch DeckListView ersetzt - 6 Unit-Tests + 1 UI-Test grün Phasen-Plan: mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 50 +++-- Sources/App/CardsNativeApp.swift | 8 + Sources/App/RootView.swift | 8 +- Sources/Core/API/CardsAPI.swift | 76 +++++++- Sources/Core/Domain/Deck.swift | 130 +++++++++++++ Sources/Core/Storage/CachedDeck.swift | 79 ++++++++ Sources/Core/Sync/DeckListStore.swift | 99 ++++++++++ Sources/Features/Account/AccountView.swift | 49 +++++ Sources/Features/Decks/DashboardView.swift | 58 ------ Sources/Features/Decks/DeckListView.swift | 215 +++++++++++++++++++++ Tests/UnitTests/DeckDecodingTests.swift | 105 ++++++++++ project.yml | 17 ++ 12 files changed, 809 insertions(+), 85 deletions(-) create mode 100644 Sources/Core/Domain/Deck.swift create mode 100644 Sources/Core/Storage/CachedDeck.swift create mode 100644 Sources/Core/Sync/DeckListStore.swift create mode 100644 Sources/Features/Account/AccountView.swift delete mode 100644 Sources/Features/Decks/DashboardView.swift create mode 100644 Sources/Features/Decks/DeckListView.swift create mode 100644 Tests/UnitTests/DeckDecodingTests.swift diff --git a/PLAN.md b/PLAN.md index 85659e1..96f3863 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,8 +1,9 @@ # Plan — cards-native (SwiftUI Universal) -**Stand: 2026-05-12 — Phase β-0 abgeschlossen.** Repo lebt lokal, -ManaCore + ManaTokens als Package-Dependency, Login funktioniert, -Cardecky-API-Reachability-Probe. +**Stand: 2026-05-13 — Phasen β-0 + β-1 abgeschlossen.** Repo lebt +auf Forgejo, Login funktioniert, Deck-Liste mit Card-/Due-Counts + +Offline-SwiftData-Cache + Pull-to-Refresh + Inbox-Banner für +Marketplace-Forks. 6 Unit-Tests + 1 UI-Test grün. > **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`. > Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc @@ -10,7 +11,7 @@ Cardecky-API-Reachability-Probe. ## Aktueller Stand -✅ **β-0 — Setup** +✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)** - Repo-Skelett unter `git.mana.how/till/cards-native` - `project.yml` mit Bundle-ID `ev.mana.cards`, ManaSwiftCore via `path: ../mana-swift-core` @@ -21,16 +22,27 @@ Cardecky-API-Reachability-Probe. - `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus `mana/packages/themes/src/variants/forest.css`) - `LoginView` (Email/PW gegen mana-auth) -- `DashboardView` als β-1-Placeholder mit API-Reachability-Indikator - 3 Unit-Tests (AppConfig) -- iOS-Simulator-Build grün + +✅ **β-1 — Decks lesen (2026-05-13, Tag `v0.2.0`)** +- `Deck`-Codable-DTO mit snake_case-CodingKeys, plus + `DeckCategory`, `DeckVisibility`, `FsrsSettings` +- ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz +- `CardsAPI.listDecks()`, `cardCount(deckId:)`, `dueCount(deckId:)` +- `CachedDeck` als SwiftData-Model mit `lastFetchedAt` (Offline-Read) +- `DeckListStore` orchestriert API + Cache, paralleles Counts-Fetching + via TaskGroup +- `DeckListView` mit Pull-to-Refresh, Card/Due-Counts, deck.color-Streifen, + Inbox-Banner für Marketplace-Forks +- `AccountView` mit Sign-out-Button +- iOS-Simulator-Build + Tests grün (6 Unit-Tests, 1 UI-Test) ## Phasen (Detail in Greenfield-Plan) | Phase | Status | Inhalt | |---|---|---| | β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe | -| β-1 | ⏳ | Decks lesen, SwiftData-Cache | +| β-1 | ✅ 2026-05-13 | Decks lesen, SwiftData-Cache, Pull-to-Refresh | | β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test | | β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) | | β-4 | — | Media, image-occlusion (PencilKit), audio-front | @@ -38,20 +50,22 @@ Cardecky-API-Reachability-Probe. | β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) | | β-7 | — | App-Store-Submission | -## Nächste Schritte für β-1 +## Nächste Schritte für β-2 -Aus Greenfield-Plan-Sektion "Phase β-1 — Decks lesen": +Aus Greenfield-Plan-Sektion "Phase β-2 — Study-Loop": -1. `Deck`-`Codable`-Struct nach Wire-Format aus - `../cards/apps/api/src/routes/decks.ts` + `cards/packages/cards-domain/src/schemas/` -2. `CardsAPI.decks() -> [Deck]` mit `GET /api/v1/decks` -3. `DeckListView` mit Pull-to-Refresh, Card/Due-Counts -4. `CachedDeck` als SwiftData-Model mit `lastFetchedAt` -5. Offline-Display bei fehlendem Netz -6. Inbox-Banner aus `?forked_from_marketplace=true`-Query +1. `Card`-DTO + `Review`-DTO aus `cards/apps/api/src/lib/dto.ts` +2. `CardsAPI.dueCards(deckId:)` → fetcht `/reviews/due` + zugehörige + `/cards/:id`-Details für die Karten-Inhalte +3. `StudySessionView` mit `CardRenderer`-switch (basic + basic-reverse + + cloze; cloze-Rendering kommt vom Server via `renderClozePrompt`) +4. Flip-Animation, Rating-Bar (`again | hard | good | easy`) +5. `POST /api/v1/reviews/:cardId/:subIndex/grade` mit Haptic-Feedback +6. `PendingGrade` SwiftData-Model als Offline-Queue, Drain bei Reconnect +7. Endurance-Test auf realem Gerät (200+ Karten, Flugmodus zwischendurch) -**Erfolgskriterium:** Web-Account-Decks vollständig in identischer -Reihenfolge sichtbar, Pull-to-Refresh aktualisiert Counts. +**Erfolgskriterium:** 50 Karten am Stück im Simulator durchgraden, +Web zeigt nach Refresh die gleichen Reviews-States. ## Cross-Refs diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index 6c146f2..fd2a88d 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -1,11 +1,18 @@ import ManaCore +import SwiftData import SwiftUI @main struct CardsNativeApp: App { + let container: ModelContainer @State private var auth: AuthClient init() { + do { + container = try ModelContainer(for: CachedDeck.self) + } catch { + fatalError("Failed to init ModelContainer: \(error)") + } let auth = AuthClient(config: AppConfig.manaAppConfig) auth.bootstrap() _auth = State(initialValue: auth) @@ -18,5 +25,6 @@ struct CardsNativeApp: App { .environment(auth) .tint(CardsTheme.primary) } + .modelContainer(container) } } diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 0b225cc..4f7333a 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,16 +1,16 @@ import ManaCore import SwiftUI -/// Top-Level-Switch: Login vs Dashboard. -/// Ab Phase β-1 wird Dashboard durch eine echte Tab-Bar (Decks / Study / -/// Stats / Account) ersetzt. +/// 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. struct RootView: View { @Environment(AuthClient.self) private var auth var body: some View { switch auth.status { case .signedIn: - DashboardView() + DeckListView() case .unknown, .signedOut, .signingIn, .error: LoginView() } diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 2ba705b..77f7924 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -3,20 +3,86 @@ import ManaCore /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// aus ManaCore, der die Cardecky-Endpoints kennt. -/// -/// In Phase β-0 ist die API leer — Endpoints kommen ab β-1 (Decks), -/// β-2 (Reviews), β-3 (Editor), β-4 (Media), β-5 (Marketplace). actor CardsAPI { private let transport: AuthenticatedTransport + private let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional } - /// Health-Probe für β-0 — verifiziert dass cardecky-api erreichbar - /// ist und der eigene JWT akzeptiert wird. + /// Health-Probe — verifiziert dass cardecky-api erreichbar ist + /// und der eigene JWT akzeptiert wird. func healthCheck() async throws -> Bool { let (_, http) = try await transport.request(path: "/healthz") return http.statusCode == 200 } + + // MARK: - Decks + + /// `GET /api/v1/decks?archived=false` — alle aktiven Decks des Users. + /// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks + /// (für den Inbox-Banner). + func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] { + var path = "/api/v1/decks" + if forkedFromMarketplaceOnly { + path += "?forked_from_marketplace=true" + } + let (data, http) = try await transport.request(path: path) + try ensureOK(http, data: data) + let body = try decoder.decode(DeckListResponse.self, from: data) + return body.decks + } + + /// `GET /api/v1/cards?deck_id=...` — Anzahl Karten in einem Deck. + /// Web macht das pro Deck einzeln; identisches Pattern hier. + func cardCount(deckId: String) async throws -> Int { + let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") + try ensureOK(http, data: data) + return try decoder.decode(CardListResponse.self, from: data).total + } + + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger + /// Reviews in einem Deck. + func dueCount(deckId: String) async throws -> Int { + let (data, http) = try await transport.request( + path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500" + ) + try ensureOK(http, data: data) + return try decoder.decode(DueReviewsResponse.self, from: data).total + } + + // MARK: - Helpers + + private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { + guard (200 ..< 300).contains(http.statusCode) else { + let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error + throw AuthError.serverError(status: http.statusCode, message: message) + } + } +} + +private struct CardsServerError: Decodable { + let error: String? +} + +extension JSONDecoder.DateDecodingStrategy { + /// Cards-API liefert ISO8601 mit Fractional-Seconds aus + /// `.toISOString()`. Standard-Strategy `.iso8601` akzeptiert die + /// fractional seconds nicht — wir nutzen einen eigenen Formatter. + static let iso8601withFractional: JSONDecoder.DateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = f.date(from: raw) { return date } + f.formatOptions = [.withInternetDateTime] + if let date = f.date(from: raw) { return date } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode ISO8601 date: \(raw)" + ) + } } diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift new file mode 100644 index 0000000..e4f907f --- /dev/null +++ b/Sources/Core/Domain/Deck.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`. +/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable. +struct Deck: Codable, Identifiable, Hashable, Sendable { + let id: String + let userId: String + let name: String + let description: String? + let color: String? + let category: DeckCategory? + let visibility: DeckVisibility + let fsrsSettings: FsrsSettings + let contentHash: String? + let forkedFromMarketplaceDeckId: String? + let forkedFromMarketplaceVersionId: String? + let archivedAt: Date? + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId = "user_id" + case name + case description + case color + case category + case visibility + case fsrsSettings = "fsrs_settings" + case contentHash = "content_hash" + case forkedFromMarketplaceDeckId = "forked_from_marketplace_deck_id" + case forkedFromMarketplaceVersionId = "forked_from_marketplace_version_id" + case archivedAt = "archived_at" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + /// Geforkt aus dem Cardecky-Marketplace? + var isFromMarketplace: Bool { + forkedFromMarketplaceDeckId != nil + } +} + +enum DeckVisibility: String, Codable, Sendable { + case `private` + case space + case `public` +} + +/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`. +enum DeckCategory: String, Codable, Sendable, CaseIterable { + case language + case medicine + case science + case math + case history + case law + case technology + case arts + case music + case sport + case other + + /// Deutsche Labels aus `DECK_CATEGORY_LABELS`. + var label: String { + switch self { + case .language: "Sprache" + case .medicine: "Medizin" + case .science: "Wissenschaft" + case .math: "Mathematik" + case .history: "Geschichte" + case .law: "Recht" + case .technology: "Technik" + case .arts: "Kunst" + case .music: "Musik" + case .sport: "Sport" + case .other: "Sonstiges" + } + } +} + +/// FSRS-Settings — Native bleibt schematisch agnostisch, FSRS rechnet +/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine +/// neue Setting auf dem Server uns nicht bricht. +struct FsrsSettings: Codable, Sendable, Hashable { + let requestRetention: Double? + let maximumInterval: Int? + let enableFuzz: Bool? + + enum CodingKeys: String, CodingKey { + case requestRetention = "request_retention" + case maximumInterval = "maximum_interval" + case enableFuzz = "enable_fuzz" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + requestRetention = try container.decodeIfPresent(Double.self, forKey: .requestRetention) + maximumInterval = try container.decodeIfPresent(Int.self, forKey: .maximumInterval) + enableFuzz = try container.decodeIfPresent(Bool.self, forKey: .enableFuzz) + } + + static let empty = FsrsSettings() + + private init( + requestRetention: Double? = nil, + maximumInterval: Int? = nil, + enableFuzz: Bool? = nil + ) { + self.requestRetention = requestRetention + self.maximumInterval = maximumInterval + self.enableFuzz = enableFuzz + } +} + +/// Server-Response von `GET /api/v1/decks`. +struct DeckListResponse: Decodable, Sendable { + let decks: [Deck] + let total: Int +} + +/// Server-Response von `GET /api/v1/cards?deck_id=...`. +struct CardListResponse: Decodable, Sendable { + let total: Int +} + +/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`. +struct DueReviewsResponse: Decodable, Sendable { + let total: Int +} diff --git a/Sources/Core/Storage/CachedDeck.swift b/Sources/Core/Storage/CachedDeck.swift new file mode 100644 index 0000000..e4c731e --- /dev/null +++ b/Sources/Core/Storage/CachedDeck.swift @@ -0,0 +1,79 @@ +import Foundation +import SwiftData + +/// Lokales Cache-Model für Decks. Spiegelt das Server-DTO + zwei +/// computed Werte (cardCount, dueCount), die Web pro Deck als zusätzliche +/// API-Calls holt. +/// +/// Offline-Read: Liste sichtbar ohne Netz. Server bleibt Wahrheit — +/// alle Edits laufen über die API, der Cache wird nur beim Re-Fetch +/// aktualisiert. +@Model +final class CachedDeck { + @Attribute(.unique) var id: String + var userId: String + var name: String + var deckDescription: String? + var color: String? + var categoryRaw: String? + var visibilityRaw: String + var contentHash: String? + var forkedFromMarketplaceDeckId: String? + var forkedFromMarketplaceVersionId: String? + var archivedAt: Date? + var createdAt: Date + var updatedAt: Date + + /// Anzahl Karten im Deck (über `/api/v1/cards?deck_id=...`). + var cardCount: Int = 0 + + /// Anzahl fälliger Reviews (über `/api/v1/reviews/due?deck_id=...`). + var dueCount: Int = 0 + + /// Zeitpunkt des letzten erfolgreichen Server-Pulls für dieses Deck. + var lastFetchedAt: Date + + init(deck: Deck, cardCount: Int = 0, dueCount: Int = 0) { + id = deck.id + userId = deck.userId + name = deck.name + deckDescription = deck.description + color = deck.color + categoryRaw = deck.category?.rawValue + visibilityRaw = deck.visibility.rawValue + contentHash = deck.contentHash + forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId + forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId + archivedAt = deck.archivedAt + createdAt = deck.createdAt + updatedAt = deck.updatedAt + self.cardCount = cardCount + self.dueCount = dueCount + lastFetchedAt = .now + } + + /// Übernimmt aktualisierte Felder vom Server-DTO. + func update(from deck: Deck, cardCount: Int, dueCount: Int) { + name = deck.name + deckDescription = deck.description + color = deck.color + categoryRaw = deck.category?.rawValue + visibilityRaw = deck.visibility.rawValue + contentHash = deck.contentHash + forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId + forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId + archivedAt = deck.archivedAt + updatedAt = deck.updatedAt + self.cardCount = cardCount + self.dueCount = dueCount + lastFetchedAt = .now + } + + var category: DeckCategory? { + categoryRaw.flatMap(DeckCategory.init(rawValue:)) + } + + var isFromMarketplace: Bool { + forkedFromMarketplaceDeckId != nil + } +} diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift new file mode 100644 index 0000000..9157a27 --- /dev/null +++ b/Sources/Core/Sync/DeckListStore.swift @@ -0,0 +1,99 @@ +import Foundation +import ManaCore +import Observation +import SwiftData + +/// Orchestriert API + SwiftData-Cache für die Deck-Liste. +/// View bindet sich an `state` und `errorMessage`. +@MainActor +@Observable +final class DeckListStore { + enum State: Sendable { + case idle + case loading + case loaded + case failed + } + + private(set) var state: State = .idle + private(set) var errorMessage: String? + + private let api: CardsAPI + private let context: ModelContext + + init(auth: AuthClient, context: ModelContext) { + api = CardsAPI(auth: auth) + self.context = context + } + + /// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt + /// der Cache (offline-readable). + func refresh() async { + state = .loading + errorMessage = nil + + do { + let decks = try await api.listDecks() + try await applyToCache(decks: decks) + state = .loaded + Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server") + } catch let error as AuthError { + errorMessage = error.errorDescription + state = .failed + Log.sync.error("Deck refresh failed: \(error.localizedDescription, privacy: .public)") + } catch { + errorMessage = String(describing: error) + state = .failed + Log.sync.error("Deck refresh failed: \(String(describing: error), privacy: .public)") + } + } + + private func applyToCache(decks remoteDecks: [Deck]) async throws { + let remoteIDs = Set(remoteDecks.map(\.id)) + + // 1. Bestehende Cache-Entries finden + let descriptor = FetchDescriptor() + let cached = (try? context.fetch(descriptor)) ?? [] + let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) }) + + // 2. Gelöschte Decks aus Cache entfernen + for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) { + context.delete(cachedDeck) + } + + // 3. Counts parallel holen + let counts = await withTaskGroup(of: (String, Int, Int).self) { group in + for deck in remoteDecks { + group.addTask { [api] in + async let cards = api.cardCount(deckId: deck.id) + async let due = api.dueCount(deckId: deck.id) + let cardCount = (try? await cards) ?? 0 + let dueCount = (try? await due) ?? 0 + return (deck.id, cardCount, dueCount) + } + } + var result: [String: (cardCount: Int, dueCount: Int)] = [:] + for await (id, c, d) in group { + result[id] = (c, d) + } + return result + } + + // 4. Neue/aktualisierte Decks einarbeiten + for deck in remoteDecks { + let counts = counts[deck.id] ?? (0, 0) + if let existing = cachedByID[deck.id] { + existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount) + } else { + let cachedDeck = CachedDeck( + deck: deck, + cardCount: counts.cardCount, + dueCount: counts.dueCount + ) + context.insert(cachedDeck) + } + } + + try context.save() + } +} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift new file mode 100644 index 0000000..083497e --- /dev/null +++ b/Sources/Features/Account/AccountView.swift @@ -0,0 +1,49 @@ +import ManaCore +import SwiftUI + +struct AccountView: View { + @Environment(AuthClient.self) private var auth + + var body: some View { + ZStack { + CardsTheme.background.ignoresSafeArea() + VStack(spacing: 24) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(CardsTheme.primary) + + if let email = auth.currentEmail { + Text(email) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + } + + Spacer() + + Button(role: .destructive) { + Task { await auth.signOut() } + } label: { + Text("Abmelden") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.error) + } + .padding(.horizontal, 32) + } + .padding(.top, 48) + } + .navigationTitle("Account") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } +} + +#Preview { + NavigationStack { + AccountView() + .environment(AuthClient(config: AppConfig.manaAppConfig)) + } +} diff --git a/Sources/Features/Decks/DashboardView.swift b/Sources/Features/Decks/DashboardView.swift deleted file mode 100644 index 363a9d9..0000000 --- a/Sources/Features/Decks/DashboardView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import ManaCore -import SwiftUI - -/// Phase β-0-Placeholder. Wird in β-1 durch eine echte Tab-Bar mit -/// Decks / Study / Stats / Account ersetzt. -struct DashboardView: View { - @Environment(AuthClient.self) private var auth - @State private var apiReachable: Bool? - - var body: some View { - ZStack { - CardsTheme.background.ignoresSafeArea() - VStack(spacing: 24) { - Text("Cards") - .font(.largeTitle.bold()) - .foregroundStyle(CardsTheme.primary) - - if let email = auth.currentEmail { - Text("Angemeldet als \(email)") - .font(.subheadline) - .foregroundStyle(CardsTheme.mutedForeground) - } - - ContentUnavailableView { - Label("β-1 in Vorbereitung", systemImage: "rectangle.stack") - .foregroundStyle(CardsTheme.foreground) - } description: { - Text("Decks- und Study-Views kommen in der nächsten Phase.") - .foregroundStyle(CardsTheme.mutedForeground) - } - - if let reachable = apiReachable { - Label( - reachable ? "cardecky-api erreichbar" : "cardecky-api nicht erreichbar", - systemImage: reachable ? "checkmark.circle.fill" : "xmark.circle.fill" - ) - .foregroundStyle(reachable ? CardsTheme.success : CardsTheme.error) - .font(.footnote) - } - - Button("Abmelden", role: .destructive) { - Task { await auth.signOut() } - } - .padding(.top, 24) - } - .padding(32) - } - .task { - let api = CardsAPI(auth: auth) - apiReachable = (try? await api.healthCheck()) ?? false - } - } -} - -#Preview { - DashboardView() - .environment(AuthClient(config: AppConfig.manaAppConfig)) -} diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift new file mode 100644 index 0000000..9e94851 --- /dev/null +++ b/Sources/Features/Decks/DeckListView.swift @@ -0,0 +1,215 @@ +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`. +struct DeckListView: View { + @Environment(AuthClient.self) private var auth + @Environment(\.modelContext) private var context + @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] + + @State private var store: DeckListStore? + @State private var showAccount = false + + var body: some View { + NavigationStack { + ZStack { + CardsTheme.background.ignoresSafeArea() + content + } + .navigationTitle("Decks") + .toolbar { toolbar } + .refreshable { + await store?.refresh() + } + .task { + if store == nil { + store = DeckListStore(auth: auth, context: context) + } + await store?.refresh() + } + .sheet(isPresented: $showAccount) { + NavigationStack { + AccountView() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Fertig") { showAccount = false } + } + } + } + } + } + } + + @ViewBuilder + private var content: some View { + if decks.isEmpty { + emptyState + } else { + List { + inboxBannerSection + ownDecksSection + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + if store?.state == .loading { + ProgressView() + .tint(CardsTheme.primary) + Text("Lade Decks …") + .foregroundStyle(CardsTheme.mutedForeground) + } else if let message = store?.errorMessage { + ContentUnavailableView { + Label("Decks konnten nicht geladen werden", systemImage: "wifi.exclamationmark") + .foregroundStyle(CardsTheme.foreground) + } description: { + Text(message) + .foregroundStyle(CardsTheme.mutedForeground) + } + } else { + ContentUnavailableView { + 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.") + .foregroundStyle(CardsTheme.mutedForeground) + } + } + } + .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 + DeckRow(deck: deck) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAccount = true + } label: { + Image(systemName: accountIcon) + .foregroundStyle(CardsTheme.primary) + } + .accessibilityLabel("Account") + } + } + + private var accountIcon: String { + if case .signedIn = auth.status { return "person.crop.circle.fill" } + 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) + } +} diff --git a/Tests/UnitTests/DeckDecodingTests.swift b/Tests/UnitTests/DeckDecodingTests.swift new file mode 100644 index 0000000..9f6a5f1 --- /dev/null +++ b/Tests/UnitTests/DeckDecodingTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Deck-JSON-Decoding") +struct DeckDecodingTests { + @Test("Wire-Format aus toDeckDto decodet sauber") + func decodesDeckFromWireFormat() throws { + let json = """ + { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "user_id": "user_123", + "name": "Spanisch A1", + "description": "Grundwortschatz", + "color": "#10803D", + "category": "language", + "visibility": "private", + "fsrs_settings": {"request_retention": 0.9, "maximum_interval": 365}, + "content_hash": "abc123", + "forked_from_marketplace_deck_id": null, + "forked_from_marketplace_version_id": null, + "archived_at": null, + "created_at": "2026-05-12T10:30:00.123Z", + "updated_at": "2026-05-12T15:45:00.456Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional + let deck = try decoder.decode(Deck.self, from: json) + + #expect(deck.id == "01ARZ3NDEKTSV4RRFFQ69G5FAV") + #expect(deck.name == "Spanisch A1") + #expect(deck.description == "Grundwortschatz") + #expect(deck.color == "#10803D") + #expect(deck.category == .language) + #expect(deck.visibility == .private) + #expect(deck.isFromMarketplace == false) + #expect(deck.archivedAt == nil) + } + + @Test("Marketplace-Forks werden erkannt") + func recognizesMarketplaceFork() throws { + let json = """ + { + "id": "deck_456", + "user_id": "user_123", + "name": "Geografie", + "description": null, + "color": null, + "category": null, + "visibility": "private", + "fsrs_settings": {}, + "content_hash": null, + "forked_from_marketplace_deck_id": "mp_deck_789", + "forked_from_marketplace_version_id": "mp_ver_1", + "archived_at": null, + "created_at": "2026-05-01T00:00:00.000Z", + "updated_at": "2026-05-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional + let deck = try decoder.decode(Deck.self, from: json) + + #expect(deck.isFromMarketplace == true) + #expect(deck.forkedFromMarketplaceDeckId == "mp_deck_789") + #expect(deck.category == nil) + } + + @Test("DeckListResponse-Wrapper") + func decodesListResponse() throws { + let json = """ + { + "decks": [ + { + "id": "d1", + "user_id": "u1", + "name": "Deck 1", + "description": null, + "color": null, + "category": null, + "visibility": "private", + "fsrs_settings": {}, + "content_hash": null, + "forked_from_marketplace_deck_id": null, + "forked_from_marketplace_version_id": null, + "archived_at": null, + "created_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-01-01T00:00:00.000Z" + } + ], + "total": 1 + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601withFractional + let response = try decoder.decode(DeckListResponse.self, from: json) + + #expect(response.total == 1) + #expect(response.decks.first?.name == "Deck 1") + } +} diff --git a/project.yml b/project.yml index 444935c..b9d33d1 100644 --- a/project.yml +++ b/project.yml @@ -95,3 +95,20 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests GENERATE_INFOPLIST_FILE: "YES" + +schemes: + CardsNative: + build: + targets: + CardsNative: all + CardsNativeTests: [test] + CardsNativeUITests: [test] + test: + targets: + - CardsNativeTests + - CardsNativeUITests + gatherCoverageData: false + run: + config: Debug + archive: + config: Release