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:
parent
81681130e6
commit
bfd327bd40
4 changed files with 126 additions and 3 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue