Compare commits
2 commits
88a3a9d6e0
...
b8d648cdfc
| Author | SHA1 | Date | |
|---|---|---|---|
| b8d648cdfc | |||
| 1c427a0902 |
8 changed files with 281 additions and 110 deletions
|
|
@ -90,10 +90,10 @@ struct AccountScreen: View {
|
|||
|
||||
// Payout content
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
.padding(.top, 20)
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { Task { await loadData() } }
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
if let tier = tierStatus {
|
||||
|
|
@ -386,21 +386,6 @@ struct AccountScreen: View {
|
|||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
private func errorView(_ msg: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(msg)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { Task { await loadData() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadAvatar() async {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ struct BusinessSelectionScreen: View {
|
|||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { loadBusinesses() }
|
||||
} else if businesses.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
|
|
@ -246,19 +246,6 @@ struct BusinessSelectionScreen: View {
|
|||
.padding()
|
||||
}
|
||||
|
||||
private func errorView(_ message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadBusinesses() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadBusinesses(silent: Bool = false) {
|
||||
|
|
|
|||
34
PayfritWorks/Views/Components/ErrorView.swift
Normal file
34
PayfritWorks/Views/Components/ErrorView.swift
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Reusable error state view with icon, message, and optional retry button.
|
||||
/// Replaces inline error patterns across all screens.
|
||||
struct ErrorView: View {
|
||||
let message: String
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.primary)
|
||||
if let onRetry = onRetry {
|
||||
Button("Retry", action: onRetry)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Retry") {
|
||||
ErrorView(message: "Something went wrong. Please try again.") {
|
||||
print("Retry tapped")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("No Retry") {
|
||||
ErrorView(message: "Could not load data.")
|
||||
}
|
||||
27
PayfritWorks/Views/Components/LoadingView.swift
Normal file
27
PayfritWorks/Views/Components/LoadingView.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Reusable loading state view with a spinner and optional message.
|
||||
/// Replaces inline ProgressView() patterns across all screens.
|
||||
struct LoadingView: View {
|
||||
var message: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
if let message = message {
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Message") {
|
||||
LoadingView(message: "Loading tasks...")
|
||||
}
|
||||
|
||||
#Preview("No Message") {
|
||||
LoadingView()
|
||||
}
|
||||
|
|
@ -94,18 +94,9 @@ struct MyTasksScreen: View {
|
|||
let tasks = tasksByFilter[filterType] ?? []
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadTasks(filterType) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
ErrorView(message: error) { loadTasks(filterType) }
|
||||
} else if tasks.isEmpty {
|
||||
emptyView(filterType)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,23 @@ struct ProfileScreen: View {
|
|||
@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 {
|
||||
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? {
|
||||
|
|
@ -57,34 +72,20 @@ struct ProfileScreen: View {
|
|||
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||
.opacity(appearAnimation ? 1 : 0)
|
||||
|
||||
// Info section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Account Information")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.payfritGreen)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
infoRow(icon: "person.fill", label: "Name", value: displayName)
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
.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()
|
||||
.frame(height: 20)
|
||||
|
|
@ -93,16 +94,17 @@ struct ProfileScreen: View {
|
|||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
do {
|
||||
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||
let url = URL(string: urlString) {
|
||||
avatarURLLoaded = url
|
||||
.toolbar {
|
||||
if !isEditing && !isLoadingProfile && profileError == nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Edit") { startEditing() }
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
} catch {
|
||||
// Avatar load failed
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadAvatarAndProfile()
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
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 {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.resizable()
|
||||
|
|
@ -143,6 +228,96 @@ struct ProfileScreen: View {
|
|||
.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 {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ struct TaskDetailScreen: View {
|
|||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { Task { await loadDetails() } }
|
||||
} else if let details = details {
|
||||
contentView(details)
|
||||
}
|
||||
|
|
@ -663,21 +663,6 @@ struct TaskDetailScreen: View {
|
|||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
private func errorView(_ message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { Task { await loadDetails() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadDetails() async {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ struct TaskListScreen: View {
|
|||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { loadTasks() }
|
||||
} else if tasks.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
|
|
@ -195,19 +195,6 @@ struct TaskListScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func errorView(_ message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadTasks() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadTasks(silent: Bool = false) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue