ProfileScreen now loads profile data from the API and shows an Edit button. Tapping Edit reveals editable first/last name fields with Save and Cancel buttons. On save, calls updateProfile API and refreshes the displayed data including the appState display name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
11 KiB
Swift
328 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct ProfileScreen: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var appearAnimation = false
|
|
@State private var avatarURLLoaded: URL?
|
|
|
|
// Profile data from API
|
|
@State private var profile: APIService.UserProfile?
|
|
@State private var isLoadingProfile = true
|
|
@State private var profileError: String?
|
|
|
|
// Edit mode
|
|
@State private var isEditing = false
|
|
@State private var editFirstName = ""
|
|
@State private var editLastName = ""
|
|
@State private var isSaving = false
|
|
@State private var saveError: String?
|
|
|
|
private var displayName: String {
|
|
if let p = profile {
|
|
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
|
|
}
|
|
return appState.userName ?? "Worker"
|
|
}
|
|
|
|
private var avatarURL: URL? {
|
|
if let loaded = avatarURLLoaded { return loaded }
|
|
guard let urlString = appState.userPhotoUrl, !urlString.isEmpty else { return nil }
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// Avatar section
|
|
VStack(spacing: 8) {
|
|
if let url = avatarURL {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image
|
|
.resizable()
|
|
.scaledToFill()
|
|
case .failure:
|
|
placeholderAvatar
|
|
default:
|
|
ProgressView()
|
|
.frame(width: 80, height: 80)
|
|
}
|
|
}
|
|
.frame(width: 80, height: 80)
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(Color.payfritGreen.opacity(0.3), lineWidth: 2))
|
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
|
} else {
|
|
placeholderAvatar
|
|
}
|
|
|
|
Text(displayName)
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
|
|
Text(appState.roleName)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 16)
|
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
|
.opacity(appearAnimation ? 1 : 0)
|
|
|
|
// Content
|
|
if isLoadingProfile {
|
|
LoadingView(message: "Loading profile...")
|
|
.frame(height: 120)
|
|
} else if let error = profileError {
|
|
ErrorView(message: error) { loadProfile() }
|
|
} else if isEditing {
|
|
editSection
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
} else {
|
|
infoSection
|
|
.opacity(appearAnimation ? 1 : 0)
|
|
.offset(y: appearAnimation ? 0 : 15)
|
|
}
|
|
|
|
Spacer()
|
|
.frame(height: 20)
|
|
}
|
|
}
|
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
|
.navigationTitle("Profile")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if !isEditing && !isLoadingProfile && profileError == nil {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Edit") { startEditing() }
|
|
.foregroundColor(.payfritGreen)
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await loadAvatarAndProfile()
|
|
}
|
|
.onAppear {
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
|
appearAnimation = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Info Section (Read-Only)
|
|
|
|
private var infoSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Account Information")
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.payfritGreen)
|
|
.padding(.horizontal, 16)
|
|
|
|
VStack(spacing: 0) {
|
|
if let p = profile {
|
|
infoRow(icon: "person.fill", label: "First Name", value: p.firstName.isEmpty ? "-" : p.firstName)
|
|
Divider().padding(.leading, 54)
|
|
infoRow(icon: "person.fill", label: "Last Name", value: p.lastName.isEmpty ? "-" : p.lastName)
|
|
Divider().padding(.leading, 54)
|
|
}
|
|
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName)
|
|
}
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
// MARK: - Edit Section
|
|
|
|
private var editSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Edit Profile")
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.payfritGreen)
|
|
.padding(.horizontal, 16)
|
|
|
|
VStack(spacing: 0) {
|
|
editRow(icon: "person.fill", label: "First Name", text: $editFirstName)
|
|
Divider().padding(.leading, 54)
|
|
editRow(icon: "person.fill", label: "Last Name", text: $editLastName)
|
|
}
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.padding(.horizontal, 16)
|
|
|
|
if let error = saveError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
withAnimation { isEditing = false }
|
|
saveError = nil
|
|
} label: {
|
|
Text("Cancel")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isSaving)
|
|
|
|
Button { saveProfile() } label: {
|
|
if isSaving {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
.frame(maxWidth: .infinity, minHeight: 20)
|
|
} else {
|
|
Text("Save")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.payfritGreen)
|
|
.disabled(isSaving)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
private var placeholderAvatar: some View {
|
|
Image(systemName: "person.circle.fill")
|
|
.resizable()
|
|
.foregroundStyle(.linearGradient(
|
|
colors: [Color.payfritGreen.opacity(0.6), Color.payfritGreen.opacity(0.3)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
))
|
|
.frame(width: 80, height: 80)
|
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
|
}
|
|
|
|
private func infoRow(icon: String, label: String, value: String) -> some View {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: icon)
|
|
.font(.subheadline)
|
|
.foregroundColor(.payfritGreen)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(value)
|
|
.font(.subheadline)
|
|
.foregroundColor(.primary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private func editRow(icon: String, label: String, text: Binding<String>) -> some View {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: icon)
|
|
.font(.subheadline)
|
|
.foregroundColor(.payfritGreen)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
TextField(label, text: text)
|
|
.font(.subheadline)
|
|
.textContentType(label == "First Name" ? .givenName : .familyName)
|
|
.autocorrectionDisabled()
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func loadAvatarAndProfile() async {
|
|
// Load avatar
|
|
do {
|
|
if let urlString = try await APIService.shared.getAvatarUrl(),
|
|
let url = URL(string: urlString) {
|
|
avatarURLLoaded = url
|
|
}
|
|
} catch {
|
|
// Avatar load failed, continue
|
|
}
|
|
|
|
// Load profile
|
|
loadProfile()
|
|
}
|
|
|
|
private func loadProfile() {
|
|
isLoadingProfile = true
|
|
profileError = nil
|
|
Task {
|
|
do {
|
|
let p = try await APIService.shared.getProfile()
|
|
profile = p
|
|
isLoadingProfile = false
|
|
} catch {
|
|
profileError = error.localizedDescription
|
|
isLoadingProfile = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startEditing() {
|
|
editFirstName = profile?.firstName ?? ""
|
|
editLastName = profile?.lastName ?? ""
|
|
saveError = nil
|
|
withAnimation { isEditing = true }
|
|
}
|
|
|
|
private func saveProfile() {
|
|
isSaving = true
|
|
saveError = nil
|
|
Task {
|
|
do {
|
|
try await APIService.shared.updateProfile(
|
|
firstName: editFirstName,
|
|
lastName: editLastName
|
|
)
|
|
// Refresh profile data
|
|
let p = try await APIService.shared.getProfile()
|
|
profile = p
|
|
|
|
// Update appState display name
|
|
let newName = [p.firstName, p.lastName]
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
appState.userName = newName.isEmpty ? nil : newName
|
|
|
|
isSaving = false
|
|
withAnimation { isEditing = false }
|
|
} catch {
|
|
saveError = error.localizedDescription
|
|
isSaving = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
ProfileScreen()
|
|
.environmentObject(AppState())
|
|
}
|
|
}
|