- 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>
483 lines
20 KiB
Swift
483 lines
20 KiB
Swift
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()
|
||
}
|
||
}
|
||
}
|