v0.4.0 — Phase β-3 Editor

Voller Editor-Flow für Decks und 5 Card-Types (basic, basic-reverse,
cloze, typing, multiple-choice). image-occlusion + audio-front kommen
mit β-4 (Media). Anki-Import bleibt vorerst aus (Web parsed client-
side, gibt keinen Server-Import-Endpoint zu rufen).

- DeckCreateBody/UpdateBody, CardCreateBody/UpdateBody Encodable
  mit snake_case-CodingKeys, nil-Felder werden weggelassen
- CardFieldsBuilder mit Type-spezifischen Pflicht-Feld-Konstruktoren
- CardsAPI: createDeck/updateDeck/deleteDeck +
  createCard/updateCard/deleteCard
- DeckEditorView (Create + Edit in einer View): Color-Picker mit
  8-Preset-Palette, Category-Picker (11 Kats, deutsche Labels),
  Visibility-Segmented-Control
- CardEditorView mit Type-Picker und dynamischen Feldern je Typ.
  Cloze-Sektion zeigt Live-Cluster-Count und Hint-Syntax-Hinweis.
  image-occlusion/audio-front zeigen β-4-Placeholder
- DeckDetailView mit Action-Buttons (Lernen, Karte hinzufügen,
  Bearbeiten, Löschen mit Confirmation)
- DeckListView: "+"-Button im Toolbar (Leading) für Create-Sheet
- 7 neue Encoding-Tests (24 Unit-Tests + 1 UI-Test grün)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:24:43 +02:00
parent 3b861af3fb
commit cf1160b270
9 changed files with 930 additions and 19 deletions

View file

@ -54,6 +54,84 @@ actor CardsAPI {
return try decoder.decode(DueReviewsResponse.self, from: data).total
}
// MARK: - Deck-Mutations
/// `POST /api/v1/decks` Deck anlegen.
@discardableResult
func createDeck(_ body: DeckCreateBody) async throws -> Deck {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks",
method: "POST",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Deck.self, from: responseData)
}
/// `PATCH /api/v1/decks/:id` Deck-Felder ändern.
@discardableResult
func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks/\(id)",
method: "PATCH",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Deck.self, from: responseData)
}
/// `DELETE /api/v1/decks/:id` Deck löschen (kaskadiert Cards + Reviews).
func deleteDeck(id: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)",
method: "DELETE"
)
try ensureOK(http, data: data)
}
// MARK: - Card-Mutations
/// `POST /api/v1/cards` Karte anlegen. Server validiert `fields`
/// gegen den Card-Type und erstellt automatisch Reviews
/// (1 für basic, 2 für basic-reverse, N für cloze).
@discardableResult
func createCard(_ body: CardCreateBody) async throws -> Card {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/cards",
method: "POST",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Card.self, from: responseData)
}
/// `PATCH /api/v1/cards/:id` nur `fields` und `media_refs`
/// sind änderbar.
@discardableResult
func updateCard(id: String, body: CardUpdateBody) async throws -> Card {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/cards/\(id)",
method: "PATCH",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(Card.self, from: responseData)
}
/// `DELETE /api/v1/cards/:id` Karte + zugehörige Reviews löschen
/// (Cascade auf DB-Ebene).
func deleteCard(id: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/cards/\(id)",
method: "DELETE"
)
try ensureOK(http, data: data)
}
// MARK: - Study
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` fällige Reviews

View file

@ -0,0 +1,56 @@
import Foundation
/// Body für `POST /api/v1/cards`. Aus `CardCreateSchema`.
///
/// `fields` ist type-abhängig Server validiert via
/// `validateFieldsForType()`. Pflicht-Keys pro Type:
/// - basic, basic-reverse: `front`, `back`
/// - cloze: `text` (mit `{{cN::...}}`-Clustern)
/// - typing: `front`, `answer`
/// - multiple-choice: `front`, `answer`
/// - image-occlusion: `image_ref`, `mask_regions` (β-4)
/// - audio-front: `audio_ref`, `back` (β-4)
struct CardCreateBody: Encodable, Sendable {
let deckId: String
let type: CardType
let fields: [String: String]
let mediaRefs: [String]?
enum CodingKeys: String, CodingKey {
case deckId = "deck_id"
case type
case fields
case mediaRefs = "media_refs"
}
}
/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs`
/// Type und deck_id sind immutable (Server-Schema).
struct CardUpdateBody: Encodable, Sendable {
var fields: [String: String]?
var mediaRefs: [String]?
enum CodingKeys: String, CodingKey {
case fields
case mediaRefs = "media_refs"
}
}
/// Hilfs-Builder für Card-Type-spezifische `fields`-Dictionaries.
enum CardFieldsBuilder {
static func basic(front: String, back: String) -> [String: String] {
["front": front, "back": back]
}
static func cloze(text: String) -> [String: String] {
["text": text]
}
static func typing(front: String, answer: String) -> [String: String] {
["front": front, "answer": answer]
}
static func multipleChoice(front: String, answer: String) -> [String: String] {
["front": front, "answer": answer]
}
}

View file

@ -0,0 +1,38 @@
import Foundation
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
struct DeckCreateBody: Encodable, Sendable {
let name: String
let description: String?
let color: String?
let category: DeckCategory?
let visibility: DeckVisibility?
enum CodingKeys: String, CodingKey {
case name
case description
case color
case category
case visibility
}
}
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
struct DeckUpdateBody: Encodable, Sendable {
var name: String?
var description: String?
var color: String?
var category: DeckCategory?
var visibility: DeckVisibility?
var archived: Bool?
enum CodingKeys: String, CodingKey {
case name
case description
case color
case category
case visibility
case archived
}
}

View file

@ -0,0 +1,202 @@
import ManaCore
import SwiftData
import SwiftUI
/// Deck-Detail mit Aktionen: Lernen, Karte hinzufügen, Bearbeiten, Löschen.
/// Wird per Tap auf eine Deck-Row aus der DeckListView geöffnet.
struct DeckDetailView: View {
let deckId: String
@Environment(AuthClient.self) private var auth
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var decks: [CachedDeck]
@State private var showEditor = false
@State private var showCardEditor = false
@State private var showDeleteConfirm = false
@State private var navigateToStudy = false
@State private var deleteError: String?
init(deckId: String) {
self.deckId = deckId
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
}
var body: some View {
ZStack {
CardsTheme.background.ignoresSafeArea()
if let deck = decks.first {
content(deck: deck)
} else {
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
.foregroundStyle(CardsTheme.mutedForeground)
}
}
.navigationTitle(decks.first?.name ?? "")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.sheet(isPresented: $showEditor) {
NavigationStack {
DeckEditorView(
mode: .edit(deckId: deckId),
existing: decks.first
) { _ in
Task { await refreshAfterEdit() }
}
}
}
.sheet(isPresented: $showCardEditor) {
NavigationStack {
CardEditorView(deckId: deckId) { _ in
Task { await refreshAfterEdit() }
}
}
}
.confirmationDialog(
"Deck löschen?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
Task { await delete() }
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.")
}
.navigationDestination(isPresented: $navigateToStudy) {
if let deck = decks.first {
StudySessionView(deckId: deck.id, deckName: deck.name)
}
}
}
private func content(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 16) {
header(deck: deck)
actions(deck: deck)
if let deleteError {
Text(deleteError)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
.padding(.horizontal, 16)
}
Spacer()
}
.padding(.vertical, 16)
}
private func header(deck: CachedDeck) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(deck.name)
.font(.title.bold())
.foregroundStyle(CardsTheme.foreground)
if deck.isFromMarketplace {
Image(systemName: "globe")
.foregroundStyle(CardsTheme.mutedForeground)
}
}
if let description = deck.deckDescription, !description.isEmpty {
Text(description)
.foregroundStyle(CardsTheme.mutedForeground)
}
HStack(spacing: 16) {
Label("\(deck.cardCount) Karten", systemImage: "rectangle.stack")
if deck.dueCount > 0 {
Label("\(deck.dueCount) fällig", systemImage: "clock.badge.exclamationmark")
.foregroundStyle(CardsTheme.primary)
}
if let category = deck.category {
Text(category.label)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
.font(.footnote)
}
.padding(.horizontal, 16)
}
private func actions(deck: CachedDeck) -> some View {
VStack(spacing: 12) {
Button {
navigateToStudy = true
} label: {
Label("Karten lernen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.primaryForeground)
}
.buttonStyle(.plain)
.disabled(deck.dueCount == 0)
Button {
showCardEditor = true
} label: {
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
HStack(spacing: 12) {
Button {
showEditor = true
} label: {
Label("Bearbeiten", systemImage: "pencil")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.foreground)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
Button {
showDeleteConfirm = true
} label: {
Label("Löschen", systemImage: "trash")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(CardsTheme.error)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
private func refreshAfterEdit() async {
let store = DeckListStore(auth: auth, context: context)
await store.refresh()
}
private func delete() async {
deleteError = nil
let api = CardsAPI(auth: auth)
do {
try await api.deleteDeck(id: deckId)
// Cache nachziehen
if let deck = decks.first {
context.delete(deck)
try? context.save()
}
dismiss()
} catch {
deleteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
}

View file

@ -11,6 +11,7 @@ struct DeckListView: View {
@State private var store: DeckListStore?
@State private var showAccount = false
@State private var showCreate = false
var body: some View {
NavigationStack {
@ -20,14 +21,19 @@ struct DeckListView: View {
}
.navigationTitle("Decks")
.navigationDestination(for: String.self) { deckId in
if let deck = decks.first(where: { $0.id == deckId }) {
StudySessionView(deckId: deck.id, deckName: deck.name)
}
DeckDetailView(deckId: deckId)
}
.toolbar { toolbar }
.refreshable {
await store?.refresh()
}
.sheet(isPresented: $showCreate) {
NavigationStack {
DeckEditorView(mode: .create) { _ in
Task { await store?.refresh() }
}
}
}
.task {
if store == nil {
store = DeckListStore(auth: auth, context: context)
@ -130,6 +136,15 @@ struct DeckListView: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
showCreate = true
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(CardsTheme.primary)
}
.accessibilityLabel("Deck hinzufügen")
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showAccount = true

View file

@ -0,0 +1,202 @@
import ManaCore
import SwiftUI
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
/// Deckt basic, basic-reverse, cloze, typing, multiple-choice ab
/// image-occlusion und audio-front kommen in β-4 (brauchen Media).
struct CardEditorView: View {
let deckId: String
let onCreated: (Card) -> Void
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var type: CardType = .basic
@State private var front: String = ""
@State private var back: String = ""
@State private var clozeText: String = ""
@State private var typingAnswer: String = ""
@State private var multipleChoiceAnswer: String = ""
@State private var isSubmitting = false
@State private var errorMessage: String?
/// β-3-Card-Types (β-4 ergänzt image-occlusion + audio-front).
private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
]
var body: some View {
Form {
Section("Card-Type") {
Picker("Typ", selection: $type) {
ForEach(Self.supportedTypes, id: \.self) { t in
Text(label(for: t)).tag(t)
}
}
.pickerStyle(.menu)
}
typeFields
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
}
}
.navigationTitle("Neue Karte")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") { Task { await submit() } }
.disabled(!canSubmit || isSubmitting)
}
}
}
@ViewBuilder
private var typeFields: some View {
switch type {
case .basic, .basicReverse:
Section("Vorderseite") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 6)
}
Section("Rückseite") {
TextField("Back", text: $back, axis: .vertical)
.lineLimit(2 ... 6)
}
if type == .basicReverse {
Section {
Text("Beide Richtungen werden gelernt — front→back und back→front.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
case .cloze:
Section("Cloze-Text") {
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
text: $clozeText, axis: .vertical)
.lineLimit(3 ... 8)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.monospaced()
}
Section {
let count = Cloze.subIndexCount(clozeText)
if count > 0 {
Label("\(count) Lücken erkannt → \(count) Reviews", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
} else {
Label("Mindestens ein Cluster `{{c1::...}}` erforderlich", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
}
Text("Mit Hint: `{{c1::Berlin::Hauptstadt von DE}}`")
.font(.caption2)
.foregroundStyle(CardsTheme.mutedForeground)
}
case .typing:
Section("Frage") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Erwartete Antwort") {
TextField("Answer", text: $typingAnswer)
}
case .multipleChoice:
Section("Frage") {
TextField("Front", text: $front, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Richtige Antwort") {
TextField("Answer", text: $multipleChoiceAnswer)
}
Section {
Text("Distractor-Optionen werden zur Lernzeit automatisch aus anderen Karten desselben Decks gezogen.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
case .imageOcclusion, .audioFront:
Section {
Label("Dieser Typ kommt in Phase β-4 (Media)", systemImage: "clock")
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
private var canSubmit: Bool {
switch type {
case .basic, .basicReverse:
!front.trimmed.isEmpty && !back.trimmed.isEmpty
case .cloze:
Cloze.subIndexCount(clozeText) > 0
case .typing:
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion, .audioFront:
false
}
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
let fields: [String: String]
switch type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
case .imageOcclusion, .audioFront:
return // disabled
}
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: nil)
do {
let card = try await api.createCard(body)
onCreated(card)
dismiss()
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func label(for type: CardType) -> String {
switch type {
case .basic: "Einfach (Vorder/Rück)"
case .basicReverse: "Beidseitig"
case .cloze: "Lückentext"
case .typing: "Eintippen"
case .multipleChoice: "Multiple Choice"
case .imageOcclusion: "Bild-Verdeckung"
case .audioFront: "Audio"
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View file

@ -0,0 +1,187 @@
import ManaCore
import SwiftUI
/// Deck-Create und Deck-Edit in einer View. `existing == nil` Create-
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
struct DeckEditorView: View {
enum Mode: Sendable {
case create
case edit(deckId: String)
}
let mode: Mode
let onSaved: (Deck) -> Void
@Environment(AuthClient.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var description: String
@State private var color: String
@State private var category: DeckCategory?
@State private var visibility: DeckVisibility
@State private var isSubmitting = false
@State private var errorMessage: String?
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
/// freie Hex-Werte später via Picker setzen (β-3-extension).
private static let presetColors: [String] = [
"#10803D", // forest primary light
"#1E3A2F", // forest dark
"#D97706", // amber
"#DC2626", // red
"#2563EB", // blue
"#7C3AED", // violet
"#0D9488", // teal
"#737373", // neutral
]
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
self.mode = mode
self.onSaved = onSaved
_name = State(initialValue: existing?.name ?? "")
_description = State(initialValue: existing?.deckDescription ?? "")
_color = State(initialValue: existing?.color ?? Self.presetColors[0])
_category = State(initialValue: existing?.category)
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
}
var body: some View {
Form {
Section("Name") {
TextField("Deck-Name", text: $name)
.textInputAutocapitalization(.sentences)
}
Section("Beschreibung") {
TextField("optional", text: $description, axis: .vertical)
.lineLimit(2 ... 4)
}
Section("Farbe") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Self.presetColors, id: \.self) { hex in
colorSwatch(hex)
}
}
.padding(.vertical, 4)
}
}
Section("Kategorie") {
Picker("Kategorie", selection: $category) {
Text("Keine").tag(DeckCategory?.none)
ForEach(DeckCategory.allCases, id: \.self) { cat in
Text(cat.label).tag(DeckCategory?.some(cat))
}
}
}
Section("Sichtbarkeit") {
Picker("Sichtbarkeit", selection: $visibility) {
Text("Privat").tag(DeckVisibility.private)
Text("Space").tag(DeckVisibility.space)
Text("Öffentlich").tag(DeckVisibility.public)
}
.pickerStyle(.segmented)
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(CardsTheme.error)
}
}
}
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isCreate ? "Erstellen" : "Speichern") {
Task { await submit() }
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
}
}
}
private var isCreate: Bool {
if case .create = mode { return true }
return false
}
@ViewBuilder
private func colorSwatch(_ hex: String) -> some View {
let isSelected = color == hex
Circle()
.fill(Color.swatchFromHex(hex))
.frame(width: 36, height: 36)
.overlay(
Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
)
.onTapGesture { color = hex }
}
private func submit() async {
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
do {
switch mode {
case .create:
let body = DeckCreateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.createDeck(body)
onSaved(deck)
dismiss()
case let .edit(deckId):
let body = DeckUpdateBody(
name: name.trimmingCharacters(in: .whitespaces),
description: nonEmpty(description),
color: color,
category: category,
visibility: visibility
)
let deck = try await api.updateDeck(id: deckId, body: body)
onSaved(deck)
dismiss()
}
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
}
}
private func nonEmpty(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? nil : trimmed
}
}
extension Color {
static func swatchFromHex(_ hex: String) -> Color {
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary
}
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)
}
}