Apps mit Guest-/Login-optional-Modus brauchen kein Full-Screen-
Login-Gate mehr. Aktionen, die einen Account brauchen, werden in
gate.require { ... } gewrappt — Login wird zur Inline-Eskalation,
nicht zum App-Block.
ManaAuthGate — @Observable-State-Maschine:
- require(reason:work:) sync und async Overloads
- Bei .signedIn: Aktion läuft sofort
- Bei .signedOut/.guest: Sign-In-Sheet öffnet, Aktion gemerkt;
nach erfolgreichem Sign-In läuft sie automatisch
- lastReason als optionaler Telemetrie-Hint
ManaAuthGateModifier / View.manaAuthGate(_:signIn:) — hängt das
Sign-In-Sheet an einen Root-View, beobachtet auth.status. Wechsel
auf .signedIn schließt das Sheet + resolved pending; manuelles
Dismiss verwirft.
Konvention für native Apps:
1. bootstrap() → bei .signedOut → enterGuestMode()
2. Root-View zeigt immer App-Inhalte, nie eine Vollbild-Login-Wall
3. Account-Aktionen via gate.require { ... }
Memoro hat das informell schon. Cards komplett umgestellt (siehe
cards-native commit). Manaspur folgt.
7 neue Tests: sofortiger Run bei .signedIn, Defer bei .signedOut/
.guest, resolvePending, cancelPending, lastReason-Tracking.
Setzt mana-swift-core ≥ 1.2.0 voraus.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
4.3 KiB
Swift
130 lines
4.3 KiB
Swift
import ManaCore
|
|
import Observation
|
|
|
|
/// Action-Level-Gate für Apps mit Guest-/Login-optional-Modus.
|
|
///
|
|
/// Hintergrund: in `mana-swift-core` v1.2.0 wurde `AuthClient.Status`
|
|
/// um den anonymen `.guest`-Modus erweitert. Apps sollen ihre Inhalte
|
|
/// in diesem Modus möglichst vollständig zugänglich machen — nur
|
|
/// schreibende Server-Aktionen (KI-Generierung, Publish, Cross-Device-
|
|
/// Sync) sollen Login erzwingen.
|
|
///
|
|
/// `ManaAuthGate` wrappt diese Logik so, dass sie aus Buttons heraus
|
|
/// einzeilig nutzbar bleibt:
|
|
///
|
|
/// ```swift
|
|
/// @Environment(ManaAuthGate.self) private var gate
|
|
///
|
|
/// Button("Karte mit KI generieren") {
|
|
/// gate.require {
|
|
/// // läuft erst, wenn .signedIn — sonst poppt das Login-Sheet,
|
|
/// // und die Aktion läuft nach erfolgreichem Sign-In.
|
|
/// await viewModel.generateCard()
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// In der App-Root wird der Gate einmal aufgebaut und via Modifier
|
|
/// `manaAuthGate(_:signIn:)` an die View gehängt:
|
|
///
|
|
/// ```swift
|
|
/// RootView()
|
|
/// .manaAuthGate(authGate) {
|
|
/// ManaLoginView(
|
|
/// auth: authClient,
|
|
/// onSignUpTapped: { /* push SignUp */ },
|
|
/// onForgotTapped: { /* push Forgot */ }
|
|
/// )
|
|
/// }
|
|
/// .environment(authGate)
|
|
/// ```
|
|
///
|
|
/// Verhalten:
|
|
/// - Status ist `.signedIn` → Action läuft sofort.
|
|
/// - Status ist `.guest`/`.signedOut`/`.error`/`.unknown` → Sign-In-
|
|
/// Sheet wird präsentiert, die Action gemerkt; nach Wechsel auf
|
|
/// `.signedIn` läuft die Action und das Sheet wird geschlossen.
|
|
/// - User schließt das Sheet ohne Login → Action wird verworfen.
|
|
@MainActor
|
|
@Observable
|
|
public final class ManaAuthGate {
|
|
public let auth: AuthClient
|
|
public var isPresentingSignIn: Bool = false
|
|
|
|
/// Letzter aufgetretener Trigger-Grund. Diagnostik/Telemetrie: Apps
|
|
/// können hier ablesen, *was* den User in den Login-Flow geführt
|
|
/// hat (z.B. "KI-Generierung" vs. "Deck publish"), ohne dass das
|
|
/// in der Action selbst stehen muss.
|
|
public private(set) var lastReason: String?
|
|
|
|
private var pending: PendingAction?
|
|
|
|
public init(auth: AuthClient) {
|
|
self.auth = auth
|
|
}
|
|
|
|
/// Führt `action` sofort aus, wenn der User eingeloggt ist. Sonst
|
|
/// wird das Sign-In-Sheet aufgeklappt und die Action wird ausgeführt,
|
|
/// sobald `auth.status` auf `.signedIn` wechselt.
|
|
///
|
|
/// - Parameters:
|
|
/// - reason: Optional, kurzer technischer Kennzeichner für
|
|
/// Telemetrie/Logs. Wird der UI **nicht** angezeigt.
|
|
/// - action: Die zu schützende Aktion. Wird **nicht** ausgeführt,
|
|
/// wenn der User das Sheet ohne Login schließt.
|
|
public func require(reason: String? = nil, _ action: @escaping @MainActor () -> Void) {
|
|
lastReason = reason
|
|
if case .signedIn = auth.status {
|
|
action()
|
|
return
|
|
}
|
|
pending = .sync(action)
|
|
isPresentingSignIn = true
|
|
}
|
|
|
|
/// Async-Variante von ``require(reason:_:)``. Die Action darf
|
|
/// `await`-Aufrufe enthalten; sie wird in einem neuen `Task`
|
|
/// gestartet.
|
|
public func require(
|
|
reason: String? = nil,
|
|
_ action: @escaping @MainActor () async -> Void
|
|
) {
|
|
lastReason = reason
|
|
if case .signedIn = auth.status {
|
|
Task { await action() }
|
|
return
|
|
}
|
|
pending = .async(action)
|
|
isPresentingSignIn = true
|
|
}
|
|
|
|
/// Wechselt der Auth-Status auf `.signedIn`, läuft die ausstehende
|
|
/// Action und das Sheet wird geschlossen. Vom ViewModifier
|
|
/// aufgerufen, kann aber auch manuell genutzt werden.
|
|
public func resolvePending() {
|
|
guard case .signedIn = auth.status else { return }
|
|
let next = pending
|
|
pending = nil
|
|
isPresentingSignIn = false
|
|
switch next {
|
|
case .none:
|
|
return
|
|
case let .sync(action):
|
|
action()
|
|
case let .async(action):
|
|
Task { await action() }
|
|
}
|
|
}
|
|
|
|
/// Verwirft eine ausstehende Aktion. Vom ViewModifier nach
|
|
/// `sheet(onDismiss:)` aufgerufen — wenn der User das Sheet ohne
|
|
/// Login schließt, soll die Aktion *nicht* nachträglich laufen.
|
|
public func cancelPending() {
|
|
pending = nil
|
|
}
|
|
|
|
private enum PendingAction {
|
|
case sync(@MainActor () -> Void)
|
|
case async(@MainActor () async -> Void)
|
|
}
|
|
}
|