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>
250 lines
9.5 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|