v0.2.0 — Phase β-1 Decks lesen
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) <noreply@anthropic.com>
This commit is contained in:
parent
28b20cd934
commit
f664a00b64
12 changed files with 809 additions and 85 deletions
50
PLAN.md
50
PLAN.md
|
|
@ -1,8 +1,9 @@
|
||||||
# Plan — cards-native (SwiftUI Universal)
|
# Plan — cards-native (SwiftUI Universal)
|
||||||
|
|
||||||
**Stand: 2026-05-12 — Phase β-0 abgeschlossen.** Repo lebt lokal,
|
**Stand: 2026-05-13 — Phasen β-0 + β-1 abgeschlossen.** Repo lebt
|
||||||
ManaCore + ManaTokens als Package-Dependency, Login funktioniert,
|
auf Forgejo, Login funktioniert, Deck-Liste mit Card-/Due-Counts +
|
||||||
Cardecky-API-Reachability-Probe.
|
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`.
|
> **SOT:** `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md`.
|
||||||
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
|
> Dieses File ist die App-lokale Status-Spur, das Greenfield-Doc
|
||||||
|
|
@ -10,7 +11,7 @@ Cardecky-API-Reachability-Probe.
|
||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
✅ **β-0 — Setup**
|
✅ **β-0 — Setup (2026-05-12, Tag `v0.1.0`)**
|
||||||
- Repo-Skelett unter `git.mana.how/till/cards-native`
|
- Repo-Skelett unter `git.mana.how/till/cards-native`
|
||||||
- `project.yml` mit Bundle-ID `ev.mana.cards`, ManaSwiftCore via
|
- `project.yml` mit Bundle-ID `ev.mana.cards`, ManaSwiftCore via
|
||||||
`path: ../mana-swift-core`
|
`path: ../mana-swift-core`
|
||||||
|
|
@ -21,16 +22,27 @@ Cardecky-API-Reachability-Probe.
|
||||||
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus
|
- `CardsTheme.swift` mit forest-Werten (lokal nachgebaut aus
|
||||||
`mana/packages/themes/src/variants/forest.css`)
|
`mana/packages/themes/src/variants/forest.css`)
|
||||||
- `LoginView` (Email/PW gegen mana-auth)
|
- `LoginView` (Email/PW gegen mana-auth)
|
||||||
- `DashboardView` als β-1-Placeholder mit API-Reachability-Indikator
|
|
||||||
- 3 Unit-Tests (AppConfig)
|
- 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)
|
## Phasen (Detail in Greenfield-Plan)
|
||||||
|
|
||||||
| Phase | Status | Inhalt |
|
| Phase | Status | Inhalt |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| β-0 | ✅ 2026-05-12 | Setup, Login, API-Probe |
|
| β-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 |
|
| β-2 | — | Study-Loop, Offline-Grade-Queue, Endurance-Test |
|
||||||
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
|
| β-3 | — | Card-/Deck-Editor (basic, cloze, typing, multiple-choice) |
|
||||||
| β-4 | — | Media, image-occlusion (PencilKit), audio-front |
|
| β-4 | — | Media, image-occlusion (PencilKit), audio-front |
|
||||||
|
|
@ -38,20 +50,22 @@ Cardecky-API-Reachability-Probe.
|
||||||
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
|
| β-6 | — | Native-Polish (Widgets, Notifications, Share-Extension) |
|
||||||
| β-7 | — | App-Store-Submission |
|
| β-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
|
1. `Card`-DTO + `Review`-DTO aus `cards/apps/api/src/lib/dto.ts`
|
||||||
`../cards/apps/api/src/routes/decks.ts` + `cards/packages/cards-domain/src/schemas/`
|
2. `CardsAPI.dueCards(deckId:)` → fetcht `/reviews/due` + zugehörige
|
||||||
2. `CardsAPI.decks() -> [Deck]` mit `GET /api/v1/decks`
|
`/cards/:id`-Details für die Karten-Inhalte
|
||||||
3. `DeckListView` mit Pull-to-Refresh, Card/Due-Counts
|
3. `StudySessionView` mit `CardRenderer`-switch (basic + basic-reverse
|
||||||
4. `CachedDeck` als SwiftData-Model mit `lastFetchedAt`
|
+ cloze; cloze-Rendering kommt vom Server via `renderClozePrompt`)
|
||||||
5. Offline-Display bei fehlendem Netz
|
4. Flip-Animation, Rating-Bar (`again | hard | good | easy`)
|
||||||
6. Inbox-Banner aus `?forked_from_marketplace=true`-Query
|
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
|
**Erfolgskriterium:** 50 Karten am Stück im Simulator durchgraden,
|
||||||
Reihenfolge sichtbar, Pull-to-Refresh aktualisiert Counts.
|
Web zeigt nach Refresh die gleichen Reviews-States.
|
||||||
|
|
||||||
## Cross-Refs
|
## Cross-Refs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import ManaCore
|
import ManaCore
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CardsNativeApp: App {
|
struct CardsNativeApp: App {
|
||||||
|
let container: ModelContainer
|
||||||
@State private var auth: AuthClient
|
@State private var auth: AuthClient
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
do {
|
||||||
|
container = try ModelContainer(for: CachedDeck.self)
|
||||||
|
} catch {
|
||||||
|
fatalError("Failed to init ModelContainer: \(error)")
|
||||||
|
}
|
||||||
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
||||||
auth.bootstrap()
|
auth.bootstrap()
|
||||||
_auth = State(initialValue: auth)
|
_auth = State(initialValue: auth)
|
||||||
|
|
@ -18,5 +25,6 @@ struct CardsNativeApp: App {
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
.tint(CardsTheme.primary)
|
.tint(CardsTheme.primary)
|
||||||
}
|
}
|
||||||
|
.modelContainer(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import ManaCore
|
import ManaCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Top-Level-Switch: Login vs Dashboard.
|
/// Top-Level-Switch: Login vs Deck-Liste.
|
||||||
/// Ab Phase β-1 wird Dashboard durch eine echte Tab-Bar (Decks / Study /
|
/// Ab Phase β-3 könnte hier eine Tab-Bar entstehen (Decks / Study /
|
||||||
/// Stats / Account) ersetzt.
|
/// Stats / Account) — für β-1 reicht der einfache Switch.
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch auth.status {
|
switch auth.status {
|
||||||
case .signedIn:
|
case .signedIn:
|
||||||
DashboardView()
|
DeckListView()
|
||||||
case .unknown, .signedOut, .signingIn, .error:
|
case .unknown, .signedOut, .signingIn, .error:
|
||||||
LoginView()
|
LoginView()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,86 @@ import ManaCore
|
||||||
|
|
||||||
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
||||||
/// aus ManaCore, der die Cardecky-Endpoints kennt.
|
/// 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 {
|
actor CardsAPI {
|
||||||
private let transport: AuthenticatedTransport
|
private let transport: AuthenticatedTransport
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
init(auth: AuthClient) {
|
init(auth: AuthClient) {
|
||||||
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
||||||
|
decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601withFractional
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health-Probe für β-0 — verifiziert dass cardecky-api erreichbar
|
/// Health-Probe — verifiziert dass cardecky-api erreichbar ist
|
||||||
/// ist und der eigene JWT akzeptiert wird.
|
/// und der eigene JWT akzeptiert wird.
|
||||||
func healthCheck() async throws -> Bool {
|
func healthCheck() async throws -> Bool {
|
||||||
let (_, http) = try await transport.request(path: "/healthz")
|
let (_, http) = try await transport.request(path: "/healthz")
|
||||||
return http.statusCode == 200
|
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)"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
Sources/Core/Domain/Deck.swift
Normal file
130
Sources/Core/Domain/Deck.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
79
Sources/Core/Storage/CachedDeck.swift
Normal file
79
Sources/Core/Storage/CachedDeck.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Sources/Core/Sync/DeckListStore.swift
Normal file
99
Sources/Core/Sync/DeckListStore.swift
Normal file
|
|
@ -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<CachedDeck>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Sources/Features/Account/AccountView.swift
Normal file
49
Sources/Features/Account/AccountView.swift
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
215
Sources/Features/Decks/DeckListView.swift
Normal file
215
Sources/Features/Decks/DeckListView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
Tests/UnitTests/DeckDecodingTests.swift
Normal file
105
Tests/UnitTests/DeckDecodingTests.swift
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
17
project.yml
17
project.yml
|
|
@ -95,3 +95,20 @@ targets:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests
|
PRODUCT_BUNDLE_IDENTIFIER: ev.mana.cards.uitests
|
||||||
GENERATE_INFOPLIST_FILE: "YES"
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue