Drei zusammenhängende Blöcke in einem Commit (Files überlappen sich
zwischen den Themen — sauberer Split nicht ohne Friktion möglich):
1. Wordeck-Text-Only-Cleanup
Image-Occlusion + Audio-Front-Code raus. Server ist seit Migration
0004_wordeck_text_only.sql text-only (in Prod waren 0 Karten der
Typen, 0 Media-Files). Native-Code war Build-11-Altlast.
- Gelöscht: MediaCache, MediaEnvironment, RemoteImage,
AudioPlayerButton, MaskEditorView, CardEditorMediaFields,
CardEditorPayload, Media.swift
- CardType-Enum auf 5 Werte: basic / basic-reverse / cloze /
typing / multiple-choice
- media_refs aus Card, CardCreateBody, CardUpdateBody, call-sites
- WordeckAPI.uploadMedia / .fetchMedia / .deleteMedia + Single-File-
makeMultipartBody gestrichen
- MarketplaceCardConverter ohne Media-Cases
- CardRenderer ohne imageOcclusionView / audioFrontView
2. AI-Media-Mode raus
/decks/from-image-Endpoint existiert serverseitig nicht (server
registriert nur /decks/generate für Text-Prompts). Native-Aufrufe
wären 404 — toter Code.
- aiMedia-Case aus DeckEditorView.CreateMode, ModePicker auf
3 Optionen (Leer / KI / CSV)
- AIMediaFormSections, MediaFileRow, mediaPickers, thumbnail,
ingestPhotoItems, handlePDFImport raus
- generateDeckFromMedia + makeFromImageMultipartBody raus
- GenerationMediaFile-Struct + PhotosUI-Import + PlatformImage-
typealias raus
- NSPhotoLibraryUsageDescription aus project.yml entfernt (es gibt
keinen Photo-Library-Zugriff mehr)
- maxMediaFiles/maxImageBytes/maxPDFBytes + inferImageMimeType +
imageExtension aus DeckEditorHelpers raus
3. ζ-1 Offline-Sync
Konzept in docs/OFFLINE_SYNC.md. Server-authoritative-FSRS bleibt —
kein lokales FSRS, nur Snapshot-Modell.
- Neue SwiftData-Models: CachedCard + CachedDueReview, beide mit
userId/deckId-Indizes
- ModelContainer um die zwei Models erweitert (additive Migration,
sollte automatisch laufen — vor TestFlight verifizieren)
- DueReview bekommt programmatischen init(review:card:) für die
Cache-Rekonstruktion
- DeckListStore.refresh() zieht Cards + Due-Reviews pro Deck
parallel in einer TaskGroup; applyToCache in drei Helpers
gesplittet (applyDecks / applyCards / applyDueReviews)
- Karten: Upsert mit Orphan-Cleanup
- Due-Reviews: voll ersetzt pro Refresh (Server-`due`-Zeiten
ändern sich, Merge wäre falsch)
- StudySession.start() fällt bei Netz-Fehler auf
CachedDueReview-Snapshot zurück, setzt isOfflineSession-Flag
- StudySessionView zeigt offline-Banner und am Ende der Session
einen Hinweis „Weitere Karten erst nach Verbindung verfügbar"
- AccountView.wipeLocalCache(): DSGVO-Wipe vor signOut() und nach
deleteAccount → CachedDeck + CachedCard + CachedDueReview +
PendingGrade werden gelöscht
Plus: Keychain-Test in WordeckNativeTests.swift fix — erwartete
"ev.mana.wordeck", muss seit Cross-App-SSO-Commit 19fee75
ManaSharedKeychainGroup nutzen. Auf Konstant-Reference umgestellt,
damit's nicht wieder driftet.
Verifikation:
- xcodebuild iOS-Simulator: BUILD SUCCEEDED
- swiftlint --strict: 0 violations in 68 files
- swiftformat: clean
- 37/37 Tests grün (inkl. fix-Keychain-Test)
- macOS-Build scheitert an pre-existing .topBarTrailing in
StudySessionView (iOS-only API seit 2026-05-13, nicht durch
diesen Commit verursacht)
Pflicht-Verifikation vor TestFlight (in PLAN.md verewigt):
- SwiftData-Migration auf Bestandsbuilder
- Offline-Endurance (50+ Karten Flugmodus)
- Logout-Wipe mit Account-Switch
- Cross-Check Web ↔ Native nach Offline-Grade
Diff: 35 files, +869 / -1622, netto ~−750 LOC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
456 lines
16 KiB
Swift
456 lines
16 KiB
Swift
import ManaCore
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
// swiftlint:disable file_length
|
|
// swiftlint:disable type_body_length
|
|
|
|
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
|
|
/// aus der DeckListView geöffnet.
|
|
///
|
|
/// `type_body_length` ist bewusst übersprungen — Detail-View hostet
|
|
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
|
|
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
|
|
/// Plumbing zwischen Parent und Children.
|
|
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?
|
|
@State private var editingCard: Card?
|
|
|
|
@State private var cards: [Card] = []
|
|
@State private var isLoadingCards = false
|
|
@State private var cardsError: String?
|
|
|
|
@State private var isPullingUpdate = false
|
|
@State private var isDuplicating = false
|
|
@State private var pullAlert: AlertMessage?
|
|
@State private var actionError: String?
|
|
@State private var showPublishSheet = false
|
|
@State private var showPrintSheet = false
|
|
|
|
init(deckId: String) {
|
|
self.deckId = deckId
|
|
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
WordeckTheme.background.ignoresSafeArea()
|
|
if let deck = decks.first {
|
|
content(deck: deck)
|
|
} else {
|
|
ContentUnavailableView("Deck nicht gefunden", systemImage: "questionmark.folder")
|
|
.foregroundStyle(WordeckTheme.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(mode: .create(deckId: deckId)) { _ in
|
|
Task {
|
|
await refreshAfterEdit()
|
|
await loadCards()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(item: $editingCard) { card in
|
|
NavigationStack {
|
|
CardEditorView(mode: .edit(card: card)) { _ in
|
|
Task {
|
|
await refreshAfterEdit()
|
|
await loadCards()
|
|
editingCard = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showPublishSheet) {
|
|
if let deck = decks.first {
|
|
NavigationStack {
|
|
MarketplacePublishView(privateDeck: deck) { _ in
|
|
showPublishSheet = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showPrintSheet) {
|
|
NavigationStack {
|
|
DeckPrintView(deckId: deckId)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Fertig") { showPrintSheet = false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.task(id: deckId) {
|
|
await loadCards()
|
|
}
|
|
.refreshable {
|
|
await loadCards()
|
|
}
|
|
.alert(item: $pullAlert) { alert in
|
|
Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")))
|
|
}
|
|
.alert(
|
|
"Aktion fehlgeschlagen",
|
|
isPresented: Binding(
|
|
get: { actionError != nil },
|
|
set: { if !$0 { actionError = nil } }
|
|
),
|
|
presenting: actionError
|
|
) { _ in
|
|
Button("OK") { actionError = nil }
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
|
|
private func content(deck: CachedDeck) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
header(deck: deck)
|
|
actions(deck: deck)
|
|
if let deleteError {
|
|
Text(deleteError)
|
|
.font(.footnote)
|
|
.foregroundStyle(WordeckTheme.error)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
cardListSection
|
|
}
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
|
|
private func header(deck: CachedDeck) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(deck.name)
|
|
.font(.title.bold())
|
|
.foregroundStyle(WordeckTheme.foreground)
|
|
if deck.isFromMarketplace {
|
|
Image(systemName: "globe")
|
|
.foregroundStyle(WordeckTheme.mutedForeground)
|
|
}
|
|
}
|
|
if let description = deck.deckDescription, !description.isEmpty {
|
|
Text(description)
|
|
.foregroundStyle(WordeckTheme.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(WordeckTheme.primary)
|
|
}
|
|
if let category = deck.category {
|
|
Text(category.label)
|
|
.foregroundStyle(WordeckTheme.mutedForeground)
|
|
}
|
|
}
|
|
.font(.footnote)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
private func actions(deck: CachedDeck) -> some View {
|
|
VStack(spacing: 12) {
|
|
primaryActions
|
|
secondaryActions(deck: deck)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var primaryActions: some View {
|
|
Button {
|
|
navigateToStudy = true
|
|
} label: {
|
|
Label("Karten lernen", systemImage: "play.fill")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
|
.foregroundStyle(WordeckTheme.primaryForeground)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled((decks.first?.dueCount ?? 0) == 0)
|
|
|
|
Button {
|
|
showCardEditor = true
|
|
} label: {
|
|
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
|
.foregroundStyle(WordeckTheme.foreground)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(WordeckTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func secondaryActions(deck: CachedDeck) -> some View {
|
|
DeckSecondaryActions(
|
|
isForkedFromMarketplace: deck.isFromMarketplace,
|
|
isPullingUpdate: isPullingUpdate,
|
|
isDuplicating: isDuplicating,
|
|
onPullUpdate: { Task { await pullUpdate() } },
|
|
onDuplicate: { Task { await duplicate() } },
|
|
onPublish: { showPublishSheet = true },
|
|
onPrint: { showPrintSheet = true },
|
|
onEdit: { showEditor = true },
|
|
onDelete: { showDeleteConfirm = true }
|
|
)
|
|
}
|
|
|
|
private var cardListSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Karten")
|
|
.font(.headline)
|
|
.foregroundStyle(WordeckTheme.foreground)
|
|
Spacer()
|
|
if !cards.isEmpty {
|
|
Text("\(cards.count)")
|
|
.font(.footnote)
|
|
.foregroundStyle(WordeckTheme.mutedForeground)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
|
|
if isLoadingCards, cards.isEmpty {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.tint(WordeckTheme.primary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 24)
|
|
} else if let cardsError {
|
|
Text(cardsError)
|
|
.font(.caption)
|
|
.foregroundStyle(WordeckTheme.error)
|
|
.padding(.horizontal, 16)
|
|
} else if cards.isEmpty {
|
|
Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.")
|
|
.font(.footnote)
|
|
.foregroundStyle(WordeckTheme.mutedForeground)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
} else {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(cards) { card in
|
|
Button {
|
|
editingCard = card
|
|
} label: {
|
|
CardPreviewRow(card: card)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityHint("Tippen zum Bearbeiten")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshAfterEdit() async {
|
|
let store = DeckListStore(auth: auth, context: context)
|
|
await store.refresh()
|
|
}
|
|
|
|
private func pullUpdate() async {
|
|
isPullingUpdate = true
|
|
defer { isPullingUpdate = false }
|
|
let api = WordeckAPI(auth: auth)
|
|
do {
|
|
let result = try await api.pullUpdate(deckId: deckId)
|
|
pullAlert = formatPullResult(result)
|
|
await refreshAfterEdit()
|
|
await loadCards()
|
|
} catch let error as AuthError {
|
|
actionError = error.errorDescription ?? "Update fehlgeschlagen"
|
|
} catch {
|
|
actionError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func formatPullResult(_ result: PullUpdateResponse) -> AlertMessage {
|
|
if result.upToDate {
|
|
return AlertMessage(
|
|
title: "Schon aktuell",
|
|
message: "Es gibt keine neue Marketplace-Version dieses Decks."
|
|
)
|
|
}
|
|
let inserted = result.cardsInserted ?? 0
|
|
let parts = [
|
|
inserted > 0 ? "\(inserted) Karten hinzugefügt" : nil,
|
|
result.changed > 0 ? "\(result.changed) Karten geändert" : nil,
|
|
result.removed > 0 ? "\(result.removed) im Marketplace entfernt (lokal behalten)" : nil
|
|
].compactMap(\.self)
|
|
let body = parts.isEmpty ? "Update angewendet." : parts.joined(separator: ", ")
|
|
let versionText = result.to.map { "Version \($0.semver)" } ?? "Update angewendet"
|
|
return AlertMessage(title: versionText, message: body)
|
|
}
|
|
|
|
private func duplicate() async {
|
|
isDuplicating = true
|
|
defer { isDuplicating = false }
|
|
let api = WordeckAPI(auth: auth)
|
|
do {
|
|
_ = try await api.duplicateDeck(id: deckId)
|
|
await refreshAfterEdit()
|
|
dismiss()
|
|
} catch let error as AuthError {
|
|
actionError = error.errorDescription ?? "Duplizieren fehlgeschlagen"
|
|
} catch {
|
|
actionError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func loadCards() async {
|
|
isLoadingCards = true
|
|
cardsError = nil
|
|
defer { isLoadingCards = false }
|
|
let api = WordeckAPI(auth: auth)
|
|
do {
|
|
cards = try await api.listCards(deckId: deckId)
|
|
.sorted { $0.createdAt > $1.createdAt }
|
|
} catch {
|
|
cardsError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
|
}
|
|
}
|
|
|
|
private func delete() async {
|
|
deleteError = nil
|
|
let api = WordeckAPI(auth: auth)
|
|
do {
|
|
try await api.deleteDeck(id: deckId)
|
|
if let deck = decks.first {
|
|
context.delete(deck)
|
|
try? context.save()
|
|
}
|
|
dismiss()
|
|
} catch {
|
|
deleteError = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// swiftlint:enable type_body_length
|
|
|
|
/// Einfacher Alert-Body — Title + Message für `.alert(item:)`-Trigger.
|
|
struct AlertMessage: Identifiable {
|
|
let id = UUID()
|
|
let title: String
|
|
let message: String
|
|
}
|
|
|
|
/// Kompakte Card-Row mit Front-Vorschau und Type-Badge.
|
|
private struct CardPreviewRow: View {
|
|
let card: Card
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: icon(for: card.type))
|
|
.foregroundStyle(WordeckTheme.primary)
|
|
.frame(width: 24)
|
|
.padding(.top, 2)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(preview(card: card))
|
|
.font(.subheadline)
|
|
.foregroundStyle(WordeckTheme.foreground)
|
|
.lineLimit(2)
|
|
Text(typeLabel(card.type))
|
|
.font(.caption2)
|
|
.foregroundStyle(WordeckTheme.mutedForeground)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(WordeckTheme.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func preview(card: Card) -> String {
|
|
switch card.type {
|
|
case .basic, .basicReverse, .typing, .multipleChoice:
|
|
card.fields["front"] ?? "—"
|
|
case .cloze:
|
|
card.fields["text"] ?? "—"
|
|
}
|
|
}
|
|
|
|
private func icon(for type: CardType) -> String {
|
|
switch type {
|
|
case .basic: "rectangle.split.2x1"
|
|
case .basicReverse: "rectangle.2.swap"
|
|
case .cloze: "text.append"
|
|
case .typing: "keyboard"
|
|
case .multipleChoice: "list.bullet"
|
|
}
|
|
}
|
|
|
|
private func typeLabel(_ type: CardType) -> String {
|
|
switch type {
|
|
case .basic: "Einfach"
|
|
case .basicReverse: "Beidseitig"
|
|
case .cloze: "Lückentext"
|
|
case .typing: "Eintippen"
|
|
case .multipleChoice: "Multiple Choice"
|
|
}
|
|
}
|
|
}
|