feat: add profile editing UI with first/last name fields

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>
This commit is contained in:
Schwifty 2026-03-24 22:15:58 +00:00
parent 1c427a0902
commit b8d648cdfc

View file

@ -6,8 +6,23 @@ struct ProfileScreen: View {
@State private var appearAnimation = false @State private var appearAnimation = false
@State private var avatarURLLoaded: URL? @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 { private var displayName: String {
appState.userName ?? "Worker" if let p = profile {
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
}
return appState.userName ?? "Worker"
} }
private var avatarURL: URL? { private var avatarURL: URL? {
@ -57,34 +72,20 @@ struct ProfileScreen: View {
.scaleEffect(appearAnimation ? 1 : 0.9) .scaleEffect(appearAnimation ? 1 : 0.9)
.opacity(appearAnimation ? 1 : 0) .opacity(appearAnimation ? 1 : 0)
// Info section // Content
VStack(alignment: .leading, spacing: 12) { if isLoadingProfile {
Text("Account Information") LoadingView(message: "Loading profile...")
.font(.subheadline) .frame(height: 120)
.fontWeight(.semibold) } else if let error = profileError {
.foregroundColor(.payfritGreen) ErrorView(message: error) { loadProfile() }
.padding(.horizontal, 16) } else if isEditing {
editSection
VStack(spacing: 0) { .transition(.opacity.combined(with: .move(edge: .bottom)))
infoRow(icon: "person.fill", label: "Name", value: displayName) } else {
Divider().padding(.leading, 54) infoSection
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName) .opacity(appearAnimation ? 1 : 0)
} .offset(y: appearAnimation ? 0 : 15)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, 16)
} }
.opacity(appearAnimation ? 1 : 0)
.offset(y: appearAnimation ? 0 : 15)
// Note
Text("Profile information is managed through your employer. Contact your manager if you need to update your details.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.top, 8)
.opacity(appearAnimation ? 1 : 0)
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
@ -93,16 +94,17 @@ struct ProfileScreen: View {
.background(Color(.systemGroupedBackground).ignoresSafeArea()) .background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("Profile") .navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .toolbar {
do { if !isEditing && !isLoadingProfile && profileError == nil {
if let urlString = try await APIService.shared.getAvatarUrl(), ToolbarItem(placement: .navigationBarTrailing) {
let url = URL(string: urlString) { Button("Edit") { startEditing() }
avatarURLLoaded = url .foregroundColor(.payfritGreen)
} }
} catch {
// Avatar load failed
} }
} }
.task {
await loadAvatarAndProfile()
}
.onAppear { .onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
appearAnimation = true appearAnimation = true
@ -110,6 +112,89 @@ struct ProfileScreen: View {
} }
} }
// 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 { private var placeholderAvatar: some View {
Image(systemName: "person.circle.fill") Image(systemName: "person.circle.fill")
.resizable() .resizable()
@ -143,6 +228,96 @@ struct ProfileScreen: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .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 { #Preview {