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)
|
||||
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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:
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue