cards-native/Sources/App/RootView.swift
Till JS 73f9081fa1 feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF
Vervollständigt die Cardecky-Web-Parität für Deck- und Card-Workflows.

γ-1+γ-2 (AI-Deck-Generierung)
- 4-Modi-Picker im DeckEditorView Create-Sheet: Leer/KI/Bild/CSV
- POST /api/v1/decks/generate für Text-Prompt + 10/min Rate-Limit-UI
- POST /api/v1/decks/from-image mit PhotosPicker + PDF-Importer
  (max 5 Files, 10 MiB/Bild, 30 MiB/PDF), Multipart-Body in
  CardsAPI+Generation
- Loading-Overlay mit Task-Cancellation, Error-Mapping für 429/413/502

γ-3 (Card-Edit)
- CardEditorView mit Mode .create(deckId:) / .edit(card:)
- Image-Occlusion + Audio-Front behalten bestehenden Media-Ref, solange
  User nicht ersetzt — MediaCache lädt Bild nach
- Type-Picker im Edit-Modus aus (Server-immutable)
- CardEditorPayload + CardEditorMediaFields als Sub-Views

γ-4 (Pull-Update + Duplicate + Archive)
- POST /marketplace/private/:id/pull-update mit Smart-Merge-Anzeige
- POST /decks/:id/duplicate
- Archive-Toggle im Edit-Modus, Server filtert Liste serverseitig
- DeckSecondaryActions als eigenes Sub-View

γ-6 (CSV-Import)
- RFC-4180-ish Parser (Quote-Escape, Header-Detect, BOM-strip)
- Preview-Liste + sequentielle Card-Inserts mit Live-Progress
- Image-Occlusion/Audio-Front werden geskipped (UI flaggt)

γ-7 (Marketplace-Publish) + Follow-up (Report + Block + Re-Publish)
- MarketplacePublishView mit lazy Author-Setup + Init + Publish 1.0.0
- Re-Publish-Modus: Picker für eigene Marketplace-Decks +
  Auto-Semver-Bump (Minor +1)
- MarketplaceCardConverter (typing → type-in, audio-front → skipped,
  image-occlusion → skipped — Server hat keinen MP-Media-Re-Upload)
- Toolbar-Menü auf PublicDeckView: „Deck melden …" + Author-Blockieren
  (App-Store-Guideline 5.1.1(v))
- ReportDeckSheet mit Reason-Picker (6 Kategorien) + optional Message
- BlockedAuthorsView in Settings mit Swipe-Entblocken

γ-8 (PDF-Export)
- DeckPrintView mit SFSafariViewController auf
  cardecky.mana.how/decks/:id/print — iOS Share-Sheet → PDF speichern

Side-Fixes (mid-stream)
- StudySessionView: Card-Aspect-Ratio springt nicht mehr beim Flip
  (Bottom-Bar in ZStack fixer Höhe)
- RootView: Glass-Pille für „Neues Deck"-Accessory + .guest- und
  .twoFactorRequired-Cases nachgezogen
- DeckListView: Account-Toolbar-Button entfernt (Account-Tab unten
  ist alleinige Anlaufstelle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:03:59 +02:00

188 lines
6.9 KiB
Swift

import ManaAuthUI
import ManaCore
import SwiftUI
/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
/// Kein harter Login-Gate mehr Cardecky läuft auch im Guest-Modus
/// (lokale Decks lernen, Marketplace browsen). Schreibende Server-
/// Aktionen werden über ``ManaAuthGate`` einzeln auf Login eskaliert.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var selectedTab: AppTab = .decks
@State private var pendingDeepLinkSlug: String?
@State private var showCreateDeck = false
@State private var showSignUpSheet = false
@State private var showForgotSheet = false
@State private var resetPasswordToken: String?
private let sourceAppUrl = URL(string: "https://cardecky.mana.how/auth/verify")!
private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")!
var body: some View {
mainTabs
.onOpenURL { url in handle(url: url) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
.manaBrand(CardsBrand.manaBrand)
.manaAuthGate(authGate) {
gateSignInContent
}
.sheet(item: Binding(
get: { resetPasswordToken.map(IdentifiedString.init) },
set: { resetPasswordToken = $0?.value }
)) { token in
ManaResetPasswordView(
token: token.value,
auth: auth,
onDone: { resetPasswordToken = nil }
)
.manaBrand(CardsBrand.manaBrand)
}
.task {
// DEBUG: Auto-Login mit DebugCredentials, falls signedOut.
// Release: no-op. Danach in Guest-Mode wechseln, wenn weder
// signedIn noch eingebuchtet Cardecky soll *immer* nutzbar
// sein, auch ohne Account.
await auth.ensureSignedIn()
if case .signedOut = auth.status {
do {
_ = try auth.enterGuestMode()
} catch {
Log.auth.warning(
"Guest-Mode konnte nicht aktiviert werden: \(String(describing: error), privacy: .public)"
)
}
}
}
}
/// Content für das ``ManaAuthGate``-Sheet wenn ein gegateter Button
/// gedrückt wird, fliegt der User in den Sign-In-Flow. Sign-Up und
/// Forgot-Password werden als verschachtelte Sheets aufgeklappt,
/// damit aus dem Gate-Sheet alle Auth-Pfade erreichbar bleiben.
private var gateSignInContent: some View {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: { showSignUpSheet = true },
onForgotTapped: { showForgotSheet = true }
)
.manaBrand(CardsBrand.manaBrand)
.sheet(isPresented: $showSignUpSheet) {
ManaSignUpView(
auth: auth,
sourceAppUrl: sourceAppUrl,
onDone: { showSignUpSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
.sheet(isPresented: $showForgotSheet) {
ManaForgotPasswordView(
auth: auth,
resetUniversalLink: resetUniversalLink,
onDone: { showForgotSheet = false }
)
.manaBrand(CardsBrand.manaBrand)
}
}
}
private var mainTabs: some View {
TabView(selection: $selectedTab) {
DeckListView(showCreate: $showCreateDeck)
.tabItem { Label("Decks", systemImage: "rectangle.stack") }
.tag(AppTab.decks)
ExploreView(deepLinkSlug: $pendingDeepLinkSlug)
.tabItem { Label("Entdecken", systemImage: "sparkles") }
.tag(AppTab.explore)
NavigationStack {
AccountView()
}
.tabItem { Label("Account", systemImage: "person.crop.circle") }
.tag(AppTab.account)
}
.decksCreateAccessory(visible: selectedTab == .decks) {
authGate.require(reason: "deck-create-accessory") {
showCreateDeck = true
}
}
}
/// Universal-Link- und URL-Scheme-Handler:
/// - `https://cardecky.mana.how/d/<slug>` Explore-Tab + PublicDeckView
/// - `https://cardecky.mana.how/auth/reset?token=` ManaResetPasswordView
/// - `cards://study/<deckId>` später (β-6 Notifications)
private func handle(url: URL) {
Log.app.info("Open URL: \(url.absoluteString, privacy: .public)")
guard url.host == "cardecky.mana.how" || url.scheme == "cards" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
if parts == ["auth", "reset"] {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value {
resetPasswordToken = token
return
}
}
if parts.count >= 2, parts[0] == "d" {
pendingDeepLinkSlug = parts[1]
selectedTab = .explore
}
}
}
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
private struct IdentifiedString: Identifiable {
let value: String
var id: String {
value
}
}
enum AppTab: Hashable {
case decks
case explore
case account
}
private extension View {
/// iOS 26: floating Neues Deck"-Pille via `.tabViewBottomAccessory`,
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
/// bestehenden `.bottomBar`-+"-Toolbar-Button in `DeckListView` zurück.
///
/// Den Modifier nur konditional anwenden sonst rendert das System
/// auch bei leerem Inhalt die leere Glass-Hülle (sichtbar als toter
/// Streifen über der TabBar auf Entdecken/Account).
@ViewBuilder
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
if #available(iOS 26.0, *), visible {
tabViewBottomAccessory {
DeckCreateAccessoryPill(action: onTap)
}
} else {
self
}
}
}
@available(iOS 26.0, *)
private struct DeckCreateAccessoryPill: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label("Neues Deck", systemImage: "plus")
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.glass)
.tint(CardsTheme.primary)
.accessibilityLabel("Neues Deck erstellen")
}
}