feat(auth): Guest-Mode + Login-optionale Surface
RootView ohne Hard-Login-Gate — TabBar zeigt sich immer, beim Start wechselt App bei .signedOut automatisch in den anonymen .guest-Modus (mana-swift-core v1.2.0). Auth-Sheets (Login, SignUp, Forgot, Reset) hängen jetzt als ManaAuthGate-Modifier am Root. AccountView zeigt im Guest-Modus eine eigene CTA-Surface („Anmelden / Konto erstellen" + Hinweis was Login bringt). signOut nutzt keepGuestMode: true → App bleibt nach Logout anonym nutzbar, Marketplace und lokale Daten gehen nicht verloren. DeckListView: Empty-State im Guest-Mode mit Login-CTA + Marketplace- Hinweis. Toolbar-„+"-Button via authGate.require gewrappt — Tap aus dem Guest-Modus öffnet erst das Sign-In-Sheet, danach den Editor. DeckListStore.refresh() skippt im Guest-Mode (kein 401-Spam). Cache wird so wie er ist gerendert (heute leer, später Marketplace-Klone). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da6679770b
commit
8ca7bd3636
5 changed files with 276 additions and 160 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import ManaAuthUI
|
||||||
import ManaCore
|
import ManaCore
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
@ -6,6 +7,7 @@ import SwiftUI
|
||||||
struct CardsNativeApp: App {
|
struct CardsNativeApp: App {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
@State private var auth: AuthClient
|
@State private var auth: AuthClient
|
||||||
|
@State private var authGate: ManaAuthGate
|
||||||
private let mediaCache: MediaCache
|
private let mediaCache: MediaCache
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -17,6 +19,7 @@ struct CardsNativeApp: App {
|
||||||
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
let auth = AuthClient(config: AppConfig.manaAppConfig)
|
||||||
auth.bootstrap()
|
auth.bootstrap()
|
||||||
_auth = State(initialValue: auth)
|
_auth = State(initialValue: auth)
|
||||||
|
_authGate = State(initialValue: ManaAuthGate(auth: auth))
|
||||||
mediaCache = MediaCache(api: CardsAPI(auth: auth))
|
mediaCache = MediaCache(api: CardsAPI(auth: auth))
|
||||||
Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)")
|
Log.app.info("Cardecky starting — auth status: \(String(describing: auth.status), privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +28,7 @@ struct CardsNativeApp: App {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
|
.environment(authGate)
|
||||||
.environment(\.mediaCache, mediaCache)
|
.environment(\.mediaCache, mediaCache)
|
||||||
.tint(CardsTheme.primary)
|
.tint(CardsTheme.primary)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import ManaAuthUI
|
||||||
import ManaCore
|
import ManaCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Top-Level-Switch: Login vs Haupt-App. Haupt-App ist eine TabBar mit
|
/// Top-Level-View: TabBar mit drei Tabs (Decks / Entdecken / Account).
|
||||||
/// 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 {
|
struct RootView: View {
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(ManaAuthGate.self) private var authGate
|
||||||
@State private var selectedTab: AppTab = .decks
|
@State private var selectedTab: AppTab = .decks
|
||||||
@State private var pendingDeepLinkSlug: String?
|
@State private var pendingDeepLinkSlug: String?
|
||||||
@State private var showCreateDeck = false
|
@State private var showCreateDeck = false
|
||||||
|
|
@ -17,65 +20,75 @@ struct RootView: View {
|
||||||
private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")!
|
private let resetUniversalLink = URL(string: "https://cardecky.mana.how/auth/reset")!
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
mainTabs
|
||||||
switch auth.status {
|
.onOpenURL { url in handle(url: url) }
|
||||||
case .signedIn:
|
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||||
mainTabs
|
if let url = activity.webpageURL { handle(url: url) }
|
||||||
.onOpenURL { url in handle(url: url) }
|
}
|
||||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
.manaBrand(CardsBrand.manaBrand)
|
||||||
if let url = activity.webpageURL { handle(url: url) }
|
.manaAuthGate(authGate) {
|
||||||
}
|
gateSignInContent
|
||||||
case .unknown, .signedOut, .signingIn, .error:
|
}
|
||||||
authSurface
|
.sheet(item: Binding(
|
||||||
.onOpenURL { url in handle(url: url) }
|
get: { resetPasswordToken.map(IdentifiedString.init) },
|
||||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
set: { resetPasswordToken = $0?.value }
|
||||||
if let url = activity.webpageURL { handle(url: url) }
|
)) { 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 {
|
private var mainTabs: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
DeckListView(showCreate: $showCreateDeck)
|
DeckListView(showCreate: $showCreateDeck)
|
||||||
|
|
@ -93,7 +106,9 @@ struct RootView: View {
|
||||||
.tag(AppTab.account)
|
.tag(AppTab.account)
|
||||||
}
|
}
|
||||||
.decksCreateAccessory(visible: selectedTab == .decks) {
|
.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.
|
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
|
||||||
if parts == ["auth", "reset"],
|
if parts == ["auth", "reset"],
|
||||||
let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
.queryItems?
|
.queryItems?
|
||||||
.first(where: { $0.name == "token" })?.value
|
.first(where: { $0.name == "token" })?.value
|
||||||
{
|
{
|
||||||
resetPasswordToken = token
|
resetPasswordToken = token
|
||||||
return
|
return
|
||||||
|
|
@ -127,10 +142,11 @@ struct RootView: View {
|
||||||
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
|
/// Helper für `.sheet(item:)` mit einem String-Value (Reset-Token).
|
||||||
private struct IdentifiedString: Identifiable {
|
private struct IdentifiedString: Identifiable {
|
||||||
let value: String
|
let value: String
|
||||||
var id: String { value }
|
var id: String {
|
||||||
|
value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum AppTab: Hashable {
|
enum AppTab: Hashable {
|
||||||
case decks
|
case decks
|
||||||
case explore
|
case explore
|
||||||
|
|
@ -141,13 +157,15 @@ private extension View {
|
||||||
/// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`,
|
/// iOS 26: floating „Neues Deck"-Pille via `.tabViewBottomAccessory`,
|
||||||
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
|
/// nur sichtbar wenn der Decks-Tab aktiv ist. iOS 18 fällt auf den
|
||||||
/// bestehenden `.bottomBar`-„+"-Toolbar-Button in `DeckListView` zurück.
|
/// 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
|
@ViewBuilder
|
||||||
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
|
func decksCreateAccessory(visible: Bool, onTap: @escaping () -> Void) -> some View {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *), visible {
|
||||||
self.tabViewBottomAccessory {
|
tabViewBottomAccessory {
|
||||||
if visible {
|
DeckCreateAccessoryPill(action: onTap)
|
||||||
DeckCreateAccessoryPill(action: onTap)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self
|
self
|
||||||
|
|
@ -163,10 +181,8 @@ private struct DeckCreateAccessoryPill: View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Label("Neues Deck", systemImage: "plus")
|
Label("Neues Deck", systemImage: "plus")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.glass)
|
||||||
.tint(CardsTheme.primary)
|
.tint(CardsTheme.primary)
|
||||||
.accessibilityLabel("Neues Deck erstellen")
|
.accessibilityLabel("Neues Deck erstellen")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import WidgetKit
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class DeckListStore {
|
final class DeckListStore {
|
||||||
enum State: Sendable {
|
enum State {
|
||||||
case idle
|
case idle
|
||||||
case loading
|
case loading
|
||||||
case loaded
|
case loaded
|
||||||
|
|
@ -21,15 +21,25 @@ final class DeckListStore {
|
||||||
|
|
||||||
private let api: CardsAPI
|
private let api: CardsAPI
|
||||||
private let context: ModelContext
|
private let context: ModelContext
|
||||||
|
private let auth: AuthClient
|
||||||
|
|
||||||
init(auth: AuthClient, context: ModelContext) {
|
init(auth: AuthClient, context: ModelContext) {
|
||||||
api = CardsAPI(auth: auth)
|
api = CardsAPI(auth: auth)
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.auth = auth
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
|
/// 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 {
|
func refresh() async {
|
||||||
|
guard case .signedIn = auth.status else {
|
||||||
|
state = .idle
|
||||||
|
errorMessage = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
state = .loading
|
state = .loading
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
|
|
@ -69,8 +79,8 @@ final class DeckListStore {
|
||||||
group.addTask { [api] in
|
group.addTask { [api] in
|
||||||
async let cards = api.cardCount(deckId: deck.id)
|
async let cards = api.cardCount(deckId: deck.id)
|
||||||
async let due = api.dueCount(deckId: deck.id)
|
async let due = api.dueCount(deckId: deck.id)
|
||||||
let cardCount = (try? await cards) ?? 0
|
let cardCount = await (try? cards) ?? 0
|
||||||
let dueCount = (try? await due) ?? 0
|
let dueCount = await (try? due) ?? 0
|
||||||
return (deck.id, cardCount, dueCount)
|
return (deck.id, cardCount, dueCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import SwiftUI
|
||||||
|
|
||||||
struct AccountView: View {
|
struct AccountView: View {
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(ManaAuthGate.self) private var authGate
|
||||||
@State private var showChangeEmail = false
|
@State private var showChangeEmail = false
|
||||||
@State private var showChangePassword = false
|
@State private var showChangePassword = false
|
||||||
@State private var showDeleteAccount = false
|
@State private var showDeleteAccount = false
|
||||||
|
|
@ -11,63 +12,16 @@ struct AccountView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
CardsTheme.background.ignoresSafeArea()
|
CardsTheme.background.ignoresSafeArea()
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
Image(systemName: "person.crop.circle.fill")
|
switch auth.status {
|
||||||
.resizable()
|
case .signedIn:
|
||||||
.frame(width: 80, height: 80)
|
signedInContent
|
||||||
.foregroundStyle(CardsTheme.primary)
|
case .guest, .signedOut, .error, .unknown:
|
||||||
|
guestContent
|
||||||
if let email = auth.currentEmail {
|
case .signingIn, .twoFactorRequired:
|
||||||
Text(email)
|
ProgressView().tint(CardsTheme.primary)
|
||||||
.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)
|
|
||||||
}
|
|
||||||
.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")
|
.navigationTitle("Account")
|
||||||
#if os(iOS)
|
#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 {
|
private func rowLabel(_ title: String, systemImage: String) -> some View {
|
||||||
Label(title, systemImage: systemImage)
|
Label(title, systemImage: systemImage)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import ManaAuthUI
|
||||||
import ManaCore
|
import ManaCore
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
@ -15,13 +16,17 @@ enum DeckRoute: Hashable {
|
||||||
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||||
struct DeckListView: View {
|
struct DeckListView: View {
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(ManaAuthGate.self) private var authGate
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
|
@Query(sort: \CachedDeck.updatedAt, order: .reverse) private var decks: [CachedDeck]
|
||||||
|
|
||||||
@Binding var showCreate: Bool
|
@Binding var showCreate: Bool
|
||||||
|
|
||||||
|
private var isGuest: Bool {
|
||||||
|
if case .signedIn = auth.status { false } else { true }
|
||||||
|
}
|
||||||
|
|
||||||
@State private var store: DeckListStore?
|
@State private var store: DeckListStore?
|
||||||
@State private var showAccount = false
|
|
||||||
@State private var pendingShares: [PendingShare] = []
|
@State private var pendingShares: [PendingShare] = []
|
||||||
@State private var path = NavigationPath()
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
|
|
@ -67,16 +72,6 @@ struct DeckListView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
pendingShares = PendingShareStore.readAll()
|
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] {
|
private var subscribedDecks: [CachedDeck] {
|
||||||
decks.filter { $0.isFromMarketplace }
|
decks.filter(\.isFromMarketplace)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -172,7 +167,10 @@ struct DeckListView: View {
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.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(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1)
|
.stroke(CardsTheme.primary.opacity(0.18), lineWidth: 1)
|
||||||
|
|
@ -207,7 +205,10 @@ struct DeckListView: View {
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
@ -231,13 +232,31 @@ struct DeckListView: View {
|
||||||
Text(message)
|
Text(message)
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.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 {
|
} else {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("Noch keine Decks", systemImage: "rectangle.stack")
|
Label("Noch keine Decks", systemImage: "rectangle.stack")
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
} description: {
|
} description: {
|
||||||
Text("Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab.")
|
Text(
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
"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) {
|
if #unavailable(iOS 26.0) {
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
Button {
|
Button {
|
||||||
showCreate = true
|
authGate.require(reason: "deck-create-toolbar") {
|
||||||
|
showCreate = true
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Deck hinzufügen", systemImage: "plus")
|
Label("Deck hinzufügen", systemImage: "plus")
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
|
@ -262,19 +283,5 @@ struct DeckListView: View {
|
||||||
Spacer()
|
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue