mana-swift-ui/Sources/ManaAuthUI/Account/ManaAccountAvatarButton.swift
Till JS 0221a96dd9 feat(account-toolbar): manaAccountToolbar(actions:) hält Avatar ganz rechts
App-Aktionen werden im selben Toolbar-Block VOR dem Avatar deklariert (Avatar zuletzt -> trailing-most). Neuer ManaToolbar.actionPlacement-Helper (.primaryAction, cross-platform). Behebt, dass der Konto-Avatar links neben App-Icons rutschte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:57:19 +02:00

250 lines
9.5 KiB
Swift

import ManaCore
import SwiftUI
/// Runder Konto-Avatar für die Navigationsleiste oben rechts
/// (`.topBarTrailing`). Tippen ruft `onTap` — die App präsentiert dann
/// `ManaAccountView` als Sheet. Ersetzt flottenweit den früheren
/// Konto-Bottom-Tab (Apple-Muster aus Podcasts / App Store).
///
/// Darstellung nach Auth-Zustand:
/// - signed-in **mit Ökosystem-Profilbild** → das im Avatar-Baukasten
/// gebaute Bild (rund beschnitten), geladen via ``ManaAvatarImageStore``.
/// - signed-in **mit** Name (kein/noch ladendes Bild) → Initialen-Kreis
/// (Brand-Primary).
/// - signed-in **ohne** Name → `person.circle`.
/// - guest / signed-out → dezenter `person.crop.circle`.
///
/// Das Profilbild lädt der Button **selbst** (mana-me primary `kind='avatar'`
/// → öffentliche mana-media-Thumb-URL). Apps müssen dafür nichts tun — der
/// Default zeigt auf die Produktiv-Services. `loadsProfileImage: false`
/// schaltet es ab (z.B. für rein anonyme Apps); `meBaseURL`/`mediaBaseURL`
/// erlauben Dev-/Localhost-Overrides.
///
/// Der Avatar ist bewusst **in jedem** Zustand sichtbar — auch
/// signed-out/guest — damit App-Einstellungen und Sign-In im Sheet
/// erreichbar bleiben (Local-First-Apps laufen ohne Konto).
///
/// `name` kommt aus ``ProfileInfo`` (via `AuthClient.getProfile()`),
/// das die App lädt und als `profile` reicht — `AuthClient.Status`
/// trägt nur die E-Mail, nicht den Namen.
@MainActor
public struct ManaAccountAvatarButton: View {
private let auth: AuthClient
private let profile: ProfileInfo?
private let size: CGFloat
private let onTap: () -> Void
private let loadsProfileImage: Bool
private let meBaseURL: URL
private let mediaBaseURL: URL
@Environment(\.manaBrand) private var brand
@State private var imageURL: URL?
public init(
auth: AuthClient,
profile: ProfileInfo? = nil,
size: CGFloat = 30,
loadsProfileImage: Bool = true,
meBaseURL: URL = ManaAvatarImageStore.defaultMeURL,
mediaBaseURL: URL = ManaAvatarImageStore.defaultMediaURL,
onTap: @escaping () -> Void
) {
self.auth = auth
self.profile = profile
self.size = size
self.loadsProfileImage = loadsProfileImage
self.meBaseURL = meBaseURL
self.mediaBaseURL = mediaBaseURL
self.onTap = onTap
}
public var body: some View {
Button(action: onTap) {
content
.frame(width: size, height: size)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Konto")
// Profilbild laden, sobald (und nur wenn) angemeldet. Schlüssel ist
// der Auth-Zustand: An-/Abmelden triggert ein erneutes Laden bzw.
// Verwerfen. Der Store dedupliziert über alle Buttons hinweg.
.task(id: auth.status) {
guard loadsProfileImage, isSignedIn else {
imageURL = nil
return
}
imageURL = await ManaAvatarImageStore.shared.thumbURL(
auth: auth,
meBaseURL: meBaseURL,
mediaBaseURL: mediaBaseURL
)
}
}
@ViewBuilder
private var content: some View {
if loadsProfileImage, let imageURL {
AsyncImage(url: imageURL) { phase in
if case let .success(image) = phase {
image
.resizable()
.scaledToFill()
} else {
// Während des Ladens / bei Fehler: dezenter Platzhalter
// statt Sprung, fällt sonst auf Initialen/Icon zurück.
placeholder
}
}
.clipShape(Circle())
} else {
placeholder
}
}
@ViewBuilder
private var placeholder: some View {
if let initials {
Circle()
.fill(brand.primary)
.overlay {
Text(initials)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(brand.primaryForeground)
}
} else {
Image(systemName: isSignedIn ? "person.circle" : "person.crop.circle")
.resizable()
.scaledToFit()
.symbolRenderingMode(.hierarchical)
.foregroundStyle(isSignedIn ? brand.primary : brand.mutedForeground)
}
}
private var isSignedIn: Bool {
if case .signedIn = auth.status { return true }
return false
}
/// Initialen nur, wenn signed-in UND ein nicht-leerer Name vorliegt.
/// Bis zu zwei Buchstaben aus den ersten beiden Namens-Teilen,
/// locale-bewusst großgeschrieben.
private var initials: String? {
guard isSignedIn,
let name = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty
else { return nil }
let letters = name
.split(separator: " ")
.prefix(2)
.compactMap(\.first)
.map(String.init)
return letters.joined().uppercased()
}
}
public enum ManaToolbar {
/// Platzierung für App-eigene Aktionen, die **links vom Konto-Avatar**
/// im ``SwiftUICore/View/manaAccountToolbar(auth:profile:loadsProfileImage:meBaseURL:mediaBaseURL:actions:onTap:)``
/// sitzen sollen. Identisch zur Avatar-Platzierung (iOS trailing / macOS
/// primary), damit beide im selben Slot landen und allein die Deklarations-
/// Reihenfolge zählt — der zuletzt deklarierte Avatar bleibt ganz rechts.
///
/// Apps nutzen das statt eines eigenen `#if os(iOS)`-Blocks:
/// `ToolbarItem(placement: ManaToolbar.actionPlacement) { … }`.
///
/// `.primaryAction` rendert auf iOS an der Trailing-Kante (wie
/// `.topBarTrailing`) und ist plattformübergreifend gültig — so liegen
/// App-Aktion und Avatar garantiert in **derselben** Platzierungsgruppe.
public static var actionPlacement: ToolbarItemPlacement { .primaryAction }
}
public extension View {
/// Hängt den ``ManaAccountAvatarButton`` als Toolbar-Item oben rechts
/// an. Muss **innerhalb** des `NavigationStack` eines Tabs angewandt
/// werden, damit er in dessen Navigationsleiste landet. Pro sichtbarem
/// Tab einmal anwenden, damit der Avatar überall erreichbar ist.
func manaAccountToolbar(
auth: AuthClient,
profile: ProfileInfo?,
loadsProfileImage: Bool = true,
meBaseURL: URL = ManaAvatarImageStore.defaultMeURL,
mediaBaseURL: URL = ManaAvatarImageStore.defaultMediaURL,
onTap: @escaping () -> Void
) -> some View {
toolbar {
ManaAccountToolbarItem(
auth: auth,
profile: profile,
loadsProfileImage: loadsProfileImage,
meBaseURL: meBaseURL,
mediaBaseURL: mediaBaseURL,
onTap: onTap
)
}
}
/// Wie ``manaAccountToolbar(auth:profile:loadsProfileImage:meBaseURL:mediaBaseURL:onTap:)``,
/// platziert aber zusätzlich **App-eigene trailing-Aktionen links vom
/// Avatar**. Der Avatar bleibt damit flottenweit das äußerste rechte
/// Element (Apple-Muster), egal wie viele Aktionen die App mitbringt.
///
/// Beide Inhalte landen im **selben** `toolbar`-Block: erst die App-
/// Aktionen (`actions`), dann der Avatar. SwiftUI ordnet Items derselben
/// Platzierung in Deklarations-Reihenfolge von links nach rechts an —
/// der zuletzt deklarierte Avatar sitzt also ganz rechts.
///
/// Apps geben ihre Items als `ToolbarItem(placement: .topBarTrailing)`
/// (iOS) bzw. `.primaryAction` (macOS) in `actions` an und entfernen ihr
/// bisheriges eigenes `.toolbar { … }` für diese Items.
func manaAccountToolbar(
auth: AuthClient,
profile: ProfileInfo?,
loadsProfileImage: Bool = true,
meBaseURL: URL = ManaAvatarImageStore.defaultMeURL,
mediaBaseURL: URL = ManaAvatarImageStore.defaultMediaURL,
@ToolbarContentBuilder actions: () -> some ToolbarContent,
onTap: @escaping () -> Void
) -> some View {
toolbar {
actions()
ManaAccountToolbarItem(
auth: auth,
profile: profile,
loadsProfileImage: loadsProfileImage,
meBaseURL: meBaseURL,
mediaBaseURL: mediaBaseURL,
onTap: onTap
)
}
}
}
/// Das reine Avatar-Toolbar-Item (ohne `toolbar`-Wrapper), damit beide
/// `manaAccountToolbar`-Varianten denselben Avatar in denselben Slot legen.
@MainActor
private struct ManaAccountToolbarItem: ToolbarContent {
let auth: AuthClient
let profile: ProfileInfo?
let loadsProfileImage: Bool
let meBaseURL: URL
let mediaBaseURL: URL
let onTap: () -> Void
var body: some ToolbarContent {
// `.primaryAction` auf beiden Plattformen: rendert auf iOS an der
// Trailing-Kante und teilt sich die Platzierungsgruppe mit App-
// Aktionen (siehe ``ManaToolbar/actionPlacement``), sodass der zuletzt
// deklarierte Avatar verlässlich ganz rechts sitzt.
ToolbarItem(placement: .primaryAction) {
ManaAccountAvatarButton(
auth: auth,
profile: profile,
loadsProfileImage: loadsProfileImage,
meBaseURL: meBaseURL,
mediaBaseURL: mediaBaseURL,
onTap: onTap
)
}
}
}