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 { $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(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(CardsTheme.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(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) { 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(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) .foregroundStyle(CardsTheme.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(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) .foregroundStyle(CardsTheme.foreground) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(CardsTheme.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(CardsTheme.foreground) Spacer() if !cards.isEmpty { Text("\(cards.count)") .font(.footnote) .foregroundStyle(CardsTheme.mutedForeground) } } .padding(.horizontal, 16) .padding(.top, 8) if isLoadingCards, cards.isEmpty { HStack { Spacer() ProgressView() .tint(CardsTheme.primary) Spacer() } .padding(.vertical, 24) } else if let cardsError { Text(cardsError) .font(.caption) .foregroundStyle(CardsTheme.error) .padding(.horizontal, 16) } else if cards.isEmpty { Text("Noch keine Karten. Tippe auf »Karte hinzufügen«.") .font(.footnote) .foregroundStyle(CardsTheme.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 = CardsAPI(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 = CardsAPI(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 = CardsAPI(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 = CardsAPI(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(CardsTheme.primary) .frame(width: 24) .padding(.top, 2) VStack(alignment: .leading, spacing: 4) { Text(preview(card: card)) .font(.subheadline) .foregroundStyle(CardsTheme.foreground) .lineLimit(2) Text(typeLabel(card.type)) .font(.caption2) .foregroundStyle(CardsTheme.mutedForeground) } Spacer() } .padding(12) .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(CardsTheme.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"] ?? "—" case .imageOcclusion: card.fields["note"]?.isEmpty == false ? card.fields["note"]! : "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)" case .audioFront: card.fields["back"] ?? "Audio-Karte" } } 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" case .imageOcclusion: "photo.on.rectangle.angled" case .audioFront: "waveform" } } 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" case .imageOcclusion: "Bild-Verdeckung" case .audioFront: "Audio" } } }