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:
parent
3b861af3fb
commit
cf1160b270
9 changed files with 930 additions and 19 deletions
187
Sources/Features/Editor/DeckEditorView.swift
Normal file
187
Sources/Features/Editor/DeckEditorView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue