diff --git a/Sources/App/CardsNativeApp.swift b/Sources/App/CardsNativeApp.swift index 46bcc2e..dfb6626 100644 --- a/Sources/App/CardsNativeApp.swift +++ b/Sources/App/CardsNativeApp.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI @@ -6,6 +7,7 @@ import SwiftUI struct CardsNativeApp: App { let container: ModelContainer @State private var auth: AuthClient + @State private var authGate: ManaAuthGate private let mediaCache: MediaCache init() { @@ -17,6 +19,7 @@ struct CardsNativeApp: App { let auth = AuthClient(config: AppConfig.manaAppConfig) auth.bootstrap() _auth = State(initialValue: auth) + _authGate = State(initialValue: ManaAuthGate(auth: auth)) mediaCache = MediaCache(api: CardsAPI(auth: auth)) Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)") } @@ -25,6 +28,7 @@ struct CardsNativeApp: App { WindowGroup { RootView() .environment(auth) + .environment(authGate) .environment(\.mediaCache, mediaCache) .tint(CardsTheme.primary) } diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index a41195f..599df9b 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -2,10 +2,13 @@ import ManaAuthUI import ManaCore import SwiftUI -/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit -/// drei Tabs (Decks / Entdecken / Account). +/// 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 @@ -17,65 +20,75 @@ struct RootView: View { private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")! var body: some View { - Group { - switch auth.status { - case .signedIn: - mainTabs - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } - } - case .unknown, .signedOut, .signingIn, .error: - authSurface - .onOpenURL { url in handle(url: url) } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } + 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) } } - .manaBrand(CardsBrand.manaBrand) - .task { - await auth.ensureSignedIn() - } } - @ViewBuilder - private var authSurface: some View { - ManaLoginView( - auth: auth, - onSignUpTapped: { showSignUpSheet = true }, - onForgotTapped: { showForgotSheet = true } - ) - .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) - } - .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) - } - } - - @ViewBuilder private var mainTabs: some View { TabView(selection: $selectedTab) { DeckListView(showCreate: $showCreateDeck) @@ -93,7 +106,9 @@ struct RootView: View { .tag(AppTab.account) } .decksCreateAccessory(visible: selectedTab == .decks) { - showCreateDeck = true + authGate.require(reason: "deck-create-accessory") { + showCreateDeck = true + } } } @@ -110,8 +125,8 @@ struct RootView: View { // Auth-Reset-Link aus der Passwort-Vergessen-Email. if parts == ["auth", "reset"], let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "token" })?.value + .queryItems? + .first(where: { $0.name == "token" })?.value { resetPasswordToken = token return @@ -127,10 +142,11 @@ struct RootView: View { /// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token). private struct IdentifiedString: Identifiable { let value: String - var id: String { value } + var id: String { + value + } } - enum AppTab: Hashable { case decks case explore @@ -141,13 +157,15 @@ 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, *) { - self.tabViewBottomAccessory { - if visible { - DeckCreateAccessoryPill(action: onTap) - } + if #available(iOS 26.0, *), visible { + tabViewBottomAccessory { + DeckCreateAccessoryPill(action: onTap) } } else { self @@ -163,10 +181,8 @@ private struct DeckCreateAccessoryPill: View { Button(action: action) { Label("Neues Deck", systemImage: "plus") .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 8) } - .buttonStyle(.borderedProminent) + .buttonStyle(.glass) .tint(CardsTheme.primary) .accessibilityLabel("Neues Deck erstellen") } diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift index 09f6a09..28b1a37 100644 --- a/Sources/Core/Sync/DeckListStore.swift +++ b/Sources/Core/Sync/DeckListStore.swift @@ -9,7 +9,7 @@ import WidgetKit @MainActor @Observable final class DeckListStore { - enum State: Sendable { + enum State { case idle case loading case loaded @@ -21,15 +21,25 @@ final class DeckListStore { private let api: CardsAPI private let context: ModelContext + private let auth: AuthClient init(auth: AuthClient, context: ModelContext) { api = CardsAPI(auth: auth) self.context = context + self.auth = auth } /// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt - /// der Cache (offline-readable). + /// der Cache (offline-readable). Im Guest-Mode wird kein Server-Call + /// versucht — der Cache (leer oder über Marketplace-Klone gefüllt) + /// wird so wie er ist gerendert. func refresh() async { + guard case .signedIn = auth.status else { + state = .idle + errorMessage = nil + return + } + state = .loading errorMessage = nil @@ -69,8 +79,8 @@ final class DeckListStore { group.addTask { [api] in async let cards = api.cardCount(deckId: deck.id) async let due = api.dueCount(deckId: deck.id) - let cardCount = (try? await cards) ?? 0 - let dueCount = (try? await due) ?? 0 + let cardCount = await (try? cards) ?? 0 + let dueCount = await (try? due) ?? 0 return (deck.id, cardCount, dueCount) } } diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index f47b527..dabd824 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -4,6 +4,7 @@ import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @State private var showChangeEmail = false @State private var showChangePassword = false @State private var showDeleteAccount = false @@ -11,63 +12,16 @@ struct AccountView: View { var body: some View { ZStack { CardsTheme.background.ignoresSafeArea() - VStack(spacing: 20) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .frame(width: 80, height: 80) - .foregroundStyle(CardsTheme.primary) - - if let email = auth.currentEmail { - Text(email) - .font(.headline) - .foregroundStyle(CardsTheme.foreground) + Group { + switch auth.status { + case .signedIn: + signedInContent + case .guest, .signedOut, .error, .unknown: + guestContent + case .signingIn, .twoFactorRequired: + ProgressView().tint(CardsTheme.primary) } - - VStack(spacing: 12) { - NavigationLink { - SettingsView() - } label: { - rowLabel("Einstellungen", systemImage: "gear") - } - .buttonStyle(.plain) - - Button { showChangeEmail = true } label: { - rowLabel("Email ändern", systemImage: "envelope") - } - .buttonStyle(.plain) - - Button { showChangePassword = true } label: { - rowLabel("Passwort ändern", systemImage: "key") - } - .buttonStyle(.plain) - } - .padding(.horizontal, 32) - - Spacer() - - Button(role: .destructive) { - Task { await auth.signOut() } - } label: { - Text("Abmelden") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - .foregroundStyle(CardsTheme.error) - } - .padding(.horizontal, 32) - - // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS - // eine Account-Löschung anbieten. - Button(role: .destructive) { - showDeleteAccount = true - } label: { - Text("Account löschen…") - .font(.footnote) - .foregroundStyle(CardsTheme.mutedForeground) - } - .padding(.bottom, 16) } - .padding(.top, 48) } .navigationTitle("Account") #if os(iOS) @@ -98,7 +52,132 @@ struct AccountView: View { } } - @ViewBuilder + private var signedInContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(CardsTheme.primary) + + if let email = auth.currentEmail { + Text(email) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + } + + VStack(spacing: 12) { + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + + Button { showChangeEmail = true } label: { + rowLabel("Email ändern", systemImage: "envelope") + } + .buttonStyle(.plain) + + Button { showChangePassword = true } label: { + rowLabel("Passwort ändern", systemImage: "key") + } + .buttonStyle(.plain) + + ManaTwoFactorAccountRow(auth: auth) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(CardsTheme.border, lineWidth: 1) + ) + } + .padding(.horizontal, 32) + + Spacer() + + Button(role: .destructive) { + // Logout behält die Guest-Identity → App bleibt im + // anonymen Modus nutzbar (lokale Decks, Marketplace + // browsen). Wer „alles vergessen" will, nutzt + // „Account löschen". + Task { await auth.signOut(keepGuestMode: true) } + } label: { + Text("Abmelden") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(CardsTheme.error) + } + .padding(.horizontal, 32) + + // App-Store-Guideline 5.1.1(v): jede App mit Sign-Up MUSS + // eine Account-Löschung anbieten. + Button(role: .destructive) { + showDeleteAccount = true + } label: { + Text("Account löschen…") + .font(.footnote) + .foregroundStyle(CardsTheme.mutedForeground) + } + .padding(.bottom, 16) + } + .padding(.top, 48) + } + + private var guestContent: some View { + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.dashed") + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(CardsTheme.mutedForeground) + + VStack(spacing: 8) { + Text("Du nutzt Cardecky anonym") + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + Text( + """ + Marketplace und lokale Decks funktionieren ohne Konto. \ + Für KI-Karten, eigene Decks im Cloud-Sync und Marketplace-\ + Veröffentlichung brauchst du ein Konto. + """ + ) + .font(.subheadline) + .foregroundStyle(CardsTheme.mutedForeground) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + + VStack(spacing: 12) { + Button { + // Trigger ohne pending-Action — wir wollen einfach + // das Sign-In-Sheet öffnen. `require` mit no-op + // schaltet die Sheet-Logik des Gates ein. + authGate.require(reason: "account-tab") {} + } label: { + Text("Anmelden / Konto erstellen") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + NavigationLink { + SettingsView() + } label: { + rowLabel("Einstellungen", systemImage: "gear") + } + .buttonStyle(.plain) + } + .padding(.horizontal, 32) + + Spacer() + } + .padding(.top, 48) + } + private func rowLabel(_ title: String, systemImage: String) -> some View { Label(title, systemImage: systemImage) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 3cdd9f0..3b3a302 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -1,3 +1,4 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI @@ -15,13 +16,17 @@ enum DeckRoute: Hashable { /// `cards/apps/web/src/routes/decks/+page.svelte`. struct DeckListView: View { @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck] @Binding var showCreate: Bool + private var isGuest: Bool { + if case .signedIn = auth.status { false } else { true } + } + @State private var store: DeckListStore? - @State private var showAccount = false @State private var pendingShares: [PendingShare] = [] @State private var path = NavigationPath() @@ -67,16 +72,6 @@ struct DeckListView: View { .onAppear { pendingShares = PendingShareStore.readAll() } - .sheet(isPresented: $showAccount) { - NavigationStack { - AccountView() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Fertig") { showAccount = false } - } - } - } - } } } @@ -104,7 +99,7 @@ struct DeckListView: View { } private var subscribedDecks: [CachedDeck] { - decks.filter { $0.isFromMarketplace } + decks.filter(\.isFromMarketplace) } @ViewBuilder @@ -172,7 +167,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) - .background(CardsTheme.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background( + CardsTheme.primary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1) @@ -207,7 +205,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.mutedForeground) } .padding(14) - .background(CardsTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background( + CardsTheme.warning.opacity(0.12), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) } .buttonStyle(.plain) } @@ -231,13 +232,31 @@ struct DeckListView: View { Text(message) .foregroundStyle(CardsTheme.mutedForeground) } + } else if isGuest { + ContentUnavailableView { + Label("Cardecky ohne Konto", systemImage: "person.crop.circle.dashed") + .foregroundStyle(CardsTheme.foreground) + } description: { + Text( + "Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein." + ) + .foregroundStyle(CardsTheme.mutedForeground) + } actions: { + Button("Anmelden / Konto erstellen") { + authGate.require(reason: "deck-list-empty") {} + } + .buttonStyle(.borderedProminent) + .tint(CardsTheme.primary) + } } else { ContentUnavailableView { Label("Noch keine Decks", systemImage: "rectangle.stack") .foregroundStyle(CardsTheme.foreground) } description: { - Text("Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.") - .foregroundStyle(CardsTheme.mutedForeground) + Text( + "Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab." + ) + .foregroundStyle(CardsTheme.mutedForeground) } } } @@ -252,7 +271,9 @@ struct DeckListView: View { if #unavailable(iOS 26.0) { ToolbarItemGroup(placement: .bottomBar) { Button { - showCreate = true + authGate.require(reason: "deck-create-toolbar") { + showCreate = true + } } label: { Label("Deck hinzufügen", systemImage: "plus") .labelStyle(.iconOnly) @@ -262,19 +283,5 @@ struct DeckListView: View { Spacer() } } - ToolbarItem(placement: .topBarTrailing) { - Button { - showAccount = true - } label: { - Image(systemName: accountIcon) - .foregroundStyle(CardsTheme.primary) - } - .accessibilityLabel("Account") - } - } - - private var accountIcon: String { - if case .signedIn = auth.status { return "person.crop.circle.fill" } - return "person.crop.circle.badge.exclamationmark" } }