Settings: echtes Profil via /api/auth/me (Rolle, Founder, Abo, Avatar)

Login liefert nur {token, name, is_premium}. Für Admin-/Founder-/Tier-Info
holen wir nach Login (und beim Erscheinen von MainTabView) /api/auth/me und
zeigen ein echtes Profil mit Avatar, Email, Rolle und nur dann Premium-Status,
wenn das relevant ist.
This commit is contained in:
rene 2026-05-30 09:37:12 +02:00
parent 81681130e6
commit bfd327bd40
4 changed files with 126 additions and 3 deletions

View file

@ -13,6 +13,22 @@ struct LoginResponse: Decodable {
let isPremium: Bool let isPremium: Bool
} }
struct UserProfile: Decodable {
let id: Int
let name: String
let email: String
let realName: String?
let rolle: String?
let isPremium: Bool?
let isFounder: Bool?
let isPartner: Bool?
let founderNumber: Int?
let subscriptionTier: String?
let avatarUrl: String?
let wohnort: String?
let bio: String?
}
// MARK: - Dogs // MARK: - Dogs
struct Dog: Decodable, Identifiable { struct Dog: Decodable, Identifiable {

View file

@ -7,6 +7,7 @@ final class AuthSession {
var token: String? var token: String?
var userName: String? var userName: String?
var isPremium: Bool = false var isPremium: Bool = false
var profile: UserProfile?
var isLoggingIn: Bool = false var isLoggingIn: Bool = false
var errorMessage: String? var errorMessage: String?
@ -46,5 +47,21 @@ final class AuthSession {
token = nil token = nil
userName = nil userName = nil
isPremium = false isPremium = false
profile = nil
}
/// Fetches the full user profile from /api/auth/me. Called after login and
/// when MainTabView appears, so admin/founder/role info shows up.
func loadProfile() async {
guard token != nil else { return }
do {
let me: UserProfile = try await APIClient.shared.get("/api/auth/me")
self.profile = me
if let premium = me.isPremium {
self.isPremium = premium
}
} catch {
// Profil-Refresh ist non-critical; Settings zeigt sonst nur die Basics.
}
} }
} }

View file

@ -1,6 +1,8 @@
import SwiftUI import SwiftUI
struct MainTabView: View { struct MainTabView: View {
@Environment(AuthSession.self) private var auth
var body: some View { var body: some View {
TabView { TabView {
RoutesListView() RoutesListView()
@ -12,5 +14,6 @@ struct MainTabView: View {
SettingsView() SettingsView()
.tabItem { Label("Mehr", systemImage: "person.crop.circle") } .tabItem { Label("Mehr", systemImage: "person.crop.circle") }
} }
.task { await auth.loadProfile() }
} }
} }

View file

@ -6,15 +6,50 @@ struct SettingsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("Account") { Section {
LabeledContent("Name", value: auth.userName ?? "") HStack(spacing: 14) {
LabeledContent("Premium", value: auth.isPremium ? "Ja" : "Nein") avatarView
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(displayName)
.font(.headline)
if let email = auth.profile?.email {
Text(email)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
} }
Section("Account") {
LabeledContent("Rolle", value: rolleLabel)
if auth.profile?.isFounder == true {
LabeledContent("Founder", value: founderLabel)
}
if auth.profile?.isPartner == true {
LabeledContent("Partner", value: "Ja")
}
if let tier = auth.profile?.subscriptionTier, !tier.isEmpty {
LabeledContent("Abo", value: tier.capitalized)
}
LabeledContent("Premium", value: premiumValue ? "Ja" : "Nein")
}
if let wohnort = auth.profile?.wohnort, !wohnort.isEmpty {
Section("Ort") {
Text(wohnort)
}
}
Section { Section {
Button("Abmelden", role: .destructive) { Button("Abmelden", role: .destructive) {
auth.logout() auth.logout()
} }
} }
Section("Über") { Section("Über") {
Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.") Text("Ban Yaro Go ist die native iOS-Ergänzung zur banyaro.app PWA. Phase 1: deine Touren ansehen.")
.font(.footnote) .font(.footnote)
@ -22,6 +57,58 @@ struct SettingsView: View {
} }
} }
.navigationTitle("Mehr") .navigationTitle("Mehr")
.refreshable { await auth.loadProfile() }
} }
} }
private var displayName: String {
auth.profile?.name ?? auth.userName ?? ""
}
private var premiumValue: Bool {
auth.profile?.isPremium ?? auth.isPremium
}
private var rolleLabel: String {
switch auth.profile?.rolle?.lowercased() {
case "admin": return "Admin"
case "moderator": return "Moderator"
case nil: return ""
default: return "Mitglied"
}
}
private var founderLabel: String {
if let n = auth.profile?.founderNumber { return "#\(n)" }
return "Ja"
}
@ViewBuilder
private var avatarView: some View {
if let path = auth.profile?.avatarUrl, !path.isEmpty,
let url = avatarURL(path) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFill()
default: avatarPlaceholder
}
}
} else {
avatarPlaceholder
}
}
private var avatarPlaceholder: some View {
ZStack {
Color.accentColor.opacity(0.2)
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 36))
.foregroundStyle(Color.accentColor)
}
}
private func avatarURL(_ path: String) -> URL? {
if path.hasPrefix("http") { return URL(string: path) }
return URL(string: "https://banyaro.app\(path)")
}
} }