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
202
Sources/Features/Decks/DeckDetailView.swift
Normal file
202
Sources/Features/Decks/DeckDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue