mana-swift-ui/Sources/ManaAuthUI/Account/ManaAccountView.swift
Till JS fd7d37f257 ManaScreenHeader: monochromer Titel-Default + Unterseiten-Adoption
- Default-Titelfarbe primary (Akzent) -> foreground (monochrom). Der
  unterstrichene Marken-Titel wirkt jetzt ruhig/einheitlich auf Haupt-
  und Unterseiten; Akzent bleibt Aktionen/Icons vorbehalten.
- Neu in ManaComponentsUI: ManaScreenHeaderRow (Header als erste,
  randlos mitscrollende List-/Form-Zeile) + View.manaScreenHeaderTitleHidden()
  (versteckt System-navigationTitle, Zurueck-Pfeil bleibt).
- ManaAccountView: optionaler title: -> traegt Konto-Titel ueber den
  globalen Header statt System-Titel (Default nil = altes Verhalten).
- ManaView (Mana-Guthaben): globaler Header statt navigationTitle.
- Package.swift: ManaComponentsUI-Dep fuer ManaAuthUI + ManaCreditsUI.

CHANGELOG bewusst ausgelassen (Parallel-Session-WIP in der Datei).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:29:50 +02:00

483 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ManaComponentsUI
import ManaCore
import ManaCreditsUI
import ManaFeedbackUI
import ManaThemeUI
import SwiftUI
/// Vereinheitlichtes Konto-Gerüst für alle Verein-Apps. Rendert den
/// **Standard-Block** der Konto-Verwaltung — E-Mail-Anzeige, E-Mail ändern,
/// Passwort ändern, 2FA, Konto löschen (Apple 5.1.1(v)), Abmelden — aus einem
/// `AuthClient`, plus einen Sign-In-CTA im abgemeldeten Zustand. Pendant zur
/// Login-Konsolidierung: nach `ManaLoginView` ist auch der Konto-Tab überall
/// identisch, statt pro App handgebaut.
///
/// App-spezifische Inhalte (Profil-Cards, Beitrags-Historie, Settings-Link,
/// Health-Status) kommen als `Section`s in den `extra`-Slot — sie erscheinen
/// **in beiden Auth-Zuständen** (auch ausgeloggt), nach dem Standard-Block.
/// Soft-auth-/Local-First-Apps brauchen ihre Settings auch ohne Login.
///
/// > ⚠️ **`extra:` beim Aufruf IMMER mit Label übergeben**, nicht als
/// > unbenanntes Trailing-Closure. Vor `extra` liegen die funktionstypigen
/// > Parameter `onSignOut`/`onDeleted`; ein unbenanntes Trailing-Closure
/// > bindet per Swift-Forward-Scan (SE-0286) an den ersten davon statt an
/// > `extra` — `extra` fällt dann still auf `{ EmptyView() }` zurück und die
/// > App-Sektionen verschwinden. Siehe Beispiel unten (`extra: { … }`).
///
/// Theming über `\.manaBrand` (App setzt es auf den View; vererbt sich auf die
/// Verwaltungs-Sheets). Login-Auslösung bleibt App-Sache (`onSignIn` → typisch
/// `ManaAuthGate.require`), damit die App ihren Gate-/SSO-Flow behält.
///
/// ```swift
/// NavigationStack {
/// ManaAccountView(
/// auth: auth,
/// onSignIn: { authGate.require(reason: "konto") {} },
/// extra: { // ← Label Pflicht (siehe Warnung oben)
/// Section("Profil") { … app-spezifisch … }
/// }
/// )
/// .navigationTitle("Konto")
/// }
/// ```
public struct ManaAccountView<Extra: View>: View {
/// Welche Verwaltungs-Aktionen der Standard-Block zeigt. Apps ohne
/// Passwort-Auth (reine SSO-/Read-Apps) können E-Mail/Passwort/2FA
/// abschalten — Konto-Löschung + Abmelden bleiben immer.
public struct Config: Sendable {
public var showChangeEmail: Bool
public var showChangePassword: Bool
public var showTwoFactor: Bool
/// Zeigt im eingeloggten Zustand die ökosystemweite Gesamt-Mana-
/// Anzeige (`ManaBalanceLink` → `ManaView`). Default aus — Apps mit
/// Konto + Mana-Bezug schalten es ein.
public var showManaBalance: Bool
/// Universal-Link, den die E-Mail-Änderungs-Bestätigung anspringt.
public var changeEmailCallbackLink: URL?
public var signInLabel: String
/// Fette Headline über dem Anmelde-CTA (z.B. „Noch nicht
/// angemeldet"). `nil` blendet sie aus.
public var signInHeadline: String?
public var signedOutMessage: String?
public init(
// Bewusst ZUERST: Apps setzen oft `config: .init(showManaBalance: true, …)`
// vorne; Swift verlangt Argument-Reihenfolge = Deklarations-Reihenfolge.
showManaBalance: Bool = false,
showChangeEmail: Bool = true,
showChangePassword: Bool = true,
showTwoFactor: Bool = true,
changeEmailCallbackLink: URL? = nil,
signInLabel: String = "Mit mana-Konto anmelden",
signInHeadline: String? = "Noch nicht angemeldet",
signedOutMessage: String? = nil
) {
self.showChangeEmail = showChangeEmail
self.showChangePassword = showChangePassword
self.showTwoFactor = showTwoFactor
self.showManaBalance = showManaBalance
self.changeEmailCallbackLink = changeEmailCallbackLink
self.signInLabel = signInLabel
self.signInHeadline = signInHeadline
self.signedOutMessage = signedOutMessage
}
}
private let auth: AuthClient
private let title: String?
private let config: Config
private let onSignIn: () -> Void
private let onSignOut: () async -> Void
private let onDeleted: (() async -> Void)?
private let feedbackClient: FeedbackClient?
private let aboutConfig: ManaAboutSection.Config?
private let extra: () -> Extra
@State private var showChangeEmail = false
@State private var showChangePassword = false
@State private var showDeleteAccount = false
@State private var showChangeName = false
@State private var nameDraft = ""
@State private var nameError: String?
/// Ökosystem-Profilbild (mana-me primary `avatar`), für den Header oben im
/// Sheet — dieselbe Quelle wie der Toolbar-Avatar (``ManaAvatarImageStore``).
@State private var avatarURL: URL?
/// Anzeigename des Users (aus ``AuthClient/getProfile()``), für den Header.
@State private var profileName: String?
/// Aktive Theme-Variant — treibt das Form-Theming (Hintergrund + Row-
/// Surface), damit **alle** Sektionen dem Theme folgen, nicht nur die
/// selbstgetönten Karten (Anmelden/Feedback). Reaktiv: Variant-Wechsel
/// re-rendert die ganze Form.
@Environment(\.manaTheme) private var theme
/// Effektives Hell/Dunkel — entscheidet den Page-Hintergrund-Token (siehe
/// `body`): in Light ein recessed `muted`, in Dark das dunklere `background`.
@Environment(\.colorScheme) private var colorScheme
/// Öffnet das Profilbild-Studio (hub.mana.how/profil) im Browser — der
/// **eine** Ort fürs Profilbild-Setzen (Foto→3D + Pixel-Baukasten), Quer-
/// Belang des Vereins-Hubs statt pro App dupliziert (Weg B der
/// Vereinheitlichung). Siehe ``profilbildStudioURL``.
@Environment(\.openURL) private var openURL
/// Kanonischer Ort fürs Profilbild-Setzen. Das Studio lebt single-source im
/// Vereins-Hub; alle Apps verlinken hierher statt es zu duplizieren.
/// (Computed statt `static let` — generischer Typ erlaubt keine statischen
/// Stored Properties.)
private var profilbildStudioURL: URL { URL(string: "https://hub.mana.how/profil")! }
/// - Parameters:
/// - auth: der App-`AuthClient`.
/// - config: welche Verwaltungs-Aktionen erscheinen (Default: alle).
/// - onSignIn: Login auslösen (typisch `ManaAuthGate.require`).
/// - onSignOut: Abmelden + App-Cleanup. Default: `auth.signOut()`.
/// - onDeleted: optionaler Cleanup nach Konto-Löschung.
/// - feedbackClient: wenn gesetzt, rendert der Standard-Block an
/// **fester Position** (direkt nach Konto/Anmelden, vor `extra`)
/// die prominente ``ManaFeedbackCard`` — vereinheitlicht Feedback
/// über alle Apps statt pro App handgebaut im `extra`-Slot.
/// - about: wenn gesetzt, rendert der Standard-Block **ganz unten**
/// (nach den App-Sektionen) die ``ManaAboutSection`` — App-Name,
/// Version/Build + Links, in **beiden** Auth-Zuständen. Vereinheitlicht
/// die „Über"-Sektion über alle Apps statt pro App handgebaut.
/// - title: wenn gesetzt, trägt die Konto-Seite ihren Titel über den
/// **globalen** ``ManaScreenHeaderRow`` (monochrom, als erste mit-
/// scrollende Zeile) statt über den System-`navigationTitle` — die App
/// setzt dann **keinen** eigenen `navigationTitle` mehr und ruft
/// `manaScreenHeaderTitleHidden()` auf. `nil` (Default) → unverändertes
/// Verhalten (App betitelt selbst per `navigationTitle`).
/// - extra: App-spezifische `Section`s, in **beiden** Auth-Zuständen
/// sichtbar. ⚠️ Immer mit Label `extra:` übergeben (siehe Typ-Doku).
public init(
auth: AuthClient,
title: String? = nil,
config: Config = .init(),
onSignIn: @escaping () -> Void,
onSignOut: (() async -> Void)? = nil,
onDeleted: (() async -> Void)? = nil,
feedbackClient: FeedbackClient? = nil,
about: ManaAboutSection.Config? = nil,
@ViewBuilder extra: @escaping () -> Extra
) {
self.auth = auth
self.title = title
self.config = config
self.onSignIn = onSignIn
self.onSignOut = onSignOut ?? { await auth.signOut() }
self.onDeleted = onDeleted
self.feedbackClient = feedbackClient
aboutConfig = about
self.extra = extra
}
/// Variante ohne `extra`-Slot. Eigene Überladung (statt Default-Expression
/// auf dem generischen Closure), weil Swift die Typ-Inferenz von `Extra`
/// aus einem Default-Ausdruck künftig als Fehler wertet. `Extra` ist hier
/// fest `EmptyView`.
public init(
auth: AuthClient,
title: String? = nil,
config: Config = .init(),
onSignIn: @escaping () -> Void,
onSignOut: (() async -> Void)? = nil,
onDeleted: (() async -> Void)? = nil,
feedbackClient: FeedbackClient? = nil,
about: ManaAboutSection.Config? = nil
) where Extra == EmptyView {
self.init(
auth: auth,
title: title,
config: config,
onSignIn: onSignIn,
onSignOut: onSignOut,
onDeleted: onDeleted,
feedbackClient: feedbackClient,
about: about,
extra: { EmptyView() }
)
}
private var isSignedIn: Bool {
if case .signedIn = auth.status { return true }
return false
}
private var showsSecuritySection: Bool {
config.showChangeEmail || config.showChangePassword || config.showTwoFactor
}
public var body: some View {
Form {
// Optionaler globaler Seiten-Header als erste mitscrollende Zeile —
// ersetzt den System-navigationTitle, monochrom + unterstrichen wie
// auf allen anderen mana-Seiten (siehe `title`-Doku oben).
if let title {
ManaScreenHeaderRow(title)
}
if isSignedIn {
// Profil-Header: das Ökosystem-Avatarbild groß + E-Mail, oben im
// Sheet (dieselbe Quelle wie der Toolbar-Avatar).
Section {
avatarHeader
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
}
// Standard-Sektionen (Konto/Sicherheit/Abmelden) auf Theme-
// Surface, damit sie dem Variant folgen statt System-Grau.
signedInSections
.listRowBackground(theme.surface)
// Ökosystemweite Gesamt-Mana-Anzeige (opt-in pro App).
if config.showManaBalance {
Section {
ManaBalanceLink(auth: auth)
}
.listRowBackground(theme.surface)
}
} else {
// Karte zeichnet ihre Surface selbst → Row bleibt clear.
signedOutSection
}
// Feedback an FESTER Position — direkt nach dem Konto-/Anmelden-
// Block, vor den App-Sektionen. Vereinheitlicht Platzierung +
// Optik über alle Apps; ersetzt die pro App handgebaute,
// zu tief sitzende `ManaFeedbackButton`-Zeile.
if let feedbackClient {
Section("Feedback") {
ManaFeedbackCard(client: feedbackClient, isLoggedIn: isSignedIn)
.manaCardListRow()
}
}
// App-spezifische Sektionen erscheinen in BEIDEN Zuständen —
// soft-auth-Apps (pageta u.a.) brauchen ihre Settings auch
// ausgeloggt sichtbar. Theme-Surface, damit auch sie (Darstellung,
// Status …) dem Variant folgen.
extra()
.listRowBackground(theme.surface)
// „Über" ganz unten an FESTER Position — konventioneller Platz für
// App-Info/Version/Links. Vereinheitlicht die Sektion über alle Apps.
if let aboutConfig {
ManaAboutSection(aboutConfig)
.listRowBackground(theme.surface)
}
}
// Page-Hintergrund themen statt System-Grau — folgt dem Variant UND gibt
// den (weißen/getönten) `surface`-Cards genug Kontrast. **Nicht**
// `theme.background`: das liegt in Light fast deckungsgleich auf `surface`
// (L≈98 vs 100; die Card-Abgrenzung ist im Theme über den Border gedacht,
// der full-bleed in der grouped Form an den Ecken clippt). Stattdessen in
// Light der klar recessed `muted` (L≈92), in Dark das dunklere
// `background` (deutlich unter `surface`). Cells bleiben `theme.surface`.
.scrollContentBackground(.hidden)
.background(pageBackground.ignoresSafeArea())
// Wenn die App einen `title` gibt, trägt ihn die Header-Zeile oben —
// dann den System-Titel verstecken (Zurück-Pfeil bleibt).
.manaScreenHeaderTitleHidden(title != nil)
.task(id: auth.status) {
guard isSignedIn else { avatarURL = nil; profileName = nil; return }
avatarURL = await ManaAvatarImageStore.shared.thumbURL(auth: auth)
profileName = (try? await auth.getProfile())?.name
}
// Name ändern — leichtgewichtiger Alert mit Textfeld (ein Feld, kein
// eigenes Sheet nötig). PATCH /api/v1/me/profile via AuthClient.
.alert("Name ändern", isPresented: $showChangeName) {
TextField("Name", text: $nameDraft)
Button("Speichern") {
let newName = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !newName.isEmpty else { return }
Task {
do {
try await auth.updateName(newName)
profileName = newName // optimistisch; Server ist Quelle
} catch {
nameError = (error as? LocalizedError)?.errorDescription
?? "Name konnte nicht geändert werden."
}
}
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Dein Anzeigename in allen mana-Apps.")
}
.alert(
"Fehler",
isPresented: Binding(get: { nameError != nil }, set: { if !$0 { nameError = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(nameError ?? "")
}
.sheet(isPresented: $showChangeEmail) {
ManaChangeEmailView(
auth: auth,
callbackUniversalLink: config.changeEmailCallbackLink,
onDone: { showChangeEmail = false }
)
}
.sheet(isPresented: $showChangePassword) {
ManaChangePasswordView(auth: auth, onDone: { showChangePassword = false })
}
.sheet(isPresented: $showDeleteAccount) {
ManaDeleteAccountView(auth: auth, onDone: {
showDeleteAccount = false
if let onDeleted { Task { await onDeleted() } }
})
}
}
/// Page-Hintergrund: in Light recessed `muted`, in Dark dunkleres
/// `background` — beide klar von `surface` (Cards) abgesetzt, beide themed.
private var pageBackground: Color {
colorScheme == .dark ? theme.background : theme.muted
}
/// Profil-Header: großes rundes Ökosystem-Avatarbild + Name + E-Mail.
@ViewBuilder
private var avatarHeader: some View {
VStack(spacing: 8) {
avatarImage
.frame(width: 76, height: 76)
.clipShape(Circle())
VStack(spacing: 2) {
if let name = profileName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
Text(name)
.font(.headline)
.foregroundStyle(theme.foreground)
}
if let email = auth.currentEmail {
Text(email)
.font(.subheadline)
.foregroundStyle(theme.mutedForeground)
}
}
}
.padding(.vertical, 8)
}
@ViewBuilder
private var avatarImage: some View {
if let avatarURL {
AsyncImage(url: avatarURL) { phase in
if case let .success(image) = phase {
image.resizable().scaledToFill()
} else {
avatarFallback
}
}
} else {
avatarFallback
}
}
@ViewBuilder
private var avatarFallback: some View {
Image(systemName: "person.crop.circle.fill")
.resizable()
.scaledToFit()
.symbolRenderingMode(.hierarchical)
.foregroundStyle(theme.primary)
}
@ViewBuilder
private var signedInSections: some View {
Section("Konto") {
if let email = auth.currentEmail {
LabeledContent {
Text(email)
} label: {
Label("E-Mail", systemImage: "envelope")
}
}
settingsRow("Name ändern", systemImage: "person") {
nameDraft = profileName ?? ""
showChangeName = true
}
// Profilbild-Studio (Foto→3D / Pixel-Baukasten) — single-source im
// Vereins-Hub, alle Apps verlinken hierher (Weg B). Öffnet im Browser.
settingsRow("Profilbild ändern", systemImage: "person.crop.square") {
openURL(profilbildStudioURL)
}
}
if showsSecuritySection {
Section("Sicherheit") {
if config.showChangeEmail {
settingsRow("E-Mail ändern", systemImage: "pencil") { showChangeEmail = true }
}
if config.showChangePassword {
settingsRow("Passwort ändern", systemImage: "key") { showChangePassword = true }
}
if config.showTwoFactor {
ManaTwoFactorAccountRow(auth: auth)
}
}
}
Section {
destructiveRow("Abmelden", systemImage: "rectangle.portrait.and.arrow.right") {
Task { await onSignOut() }
}
// Apple-Guideline 5.1.1(v): In-App-Account-Löschung Pflicht.
destructiveRow("Konto löschen…", systemImage: "trash") {
showDeleteAccount = true
}
}
}
/// Settings-Zeile im Konto-Stil: führendes Icon (gedämpft) + Titel
/// (Vordergrund) + Chevron — visuell konsistent mit ``ManaTwoFactorAccountRow``.
private func settingsRow(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: systemImage)
.foregroundStyle(theme.mutedForeground)
.frame(width: 22)
Text(title)
.foregroundStyle(theme.foreground)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(theme.mutedForeground)
}
}
.buttonStyle(.plain)
}
/// Destruktive Zeile (Abmelden/Löschen): Icon + Titel in `error`, ohne
/// Chevron (terminale Aktion).
private func destructiveRow(
_ title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: systemImage)
.frame(width: 22)
Text(title)
Spacer()
}
.foregroundStyle(theme.error)
}
.buttonStyle(.plain)
}
private var signedOutSection: some View {
// Prominente CTA-Card statt blasser Listenzeile (gehoben aus
// viadocus `guestBanner`). Section-Header „Konto" parallel zur
// eingeloggten ``signedInSections``, damit auch dieser Block — wie
// die übrigen — eine Überschrift trägt.
Section("Konto") {
ManaSignInCTA(
headline: config.signInHeadline,
message: config.signedOutMessage,
buttonLabel: config.signInLabel,
action: onSignIn
)
.manaCardListRow()
}
}
}