From 1c427a0902b126ddd5b73da18ce4f69847482556 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Tue, 24 Mar 2026 22:15:03 +0000 Subject: [PATCH 1/3] feat: add reusable ErrorView and LoadingView components Extract duplicated inline error/loading patterns from TaskListScreen, MyTasksScreen, BusinessSelectionScreen, AccountScreen, and TaskDetailScreen into shared components under Views/Components/. Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritWorks/Views/AccountScreen.swift | 19 ++--------- .../Views/BusinessSelectionScreen.swift | 17 ++-------- PayfritWorks/Views/Components/ErrorView.swift | 34 +++++++++++++++++++ .../Views/Components/LoadingView.swift | 27 +++++++++++++++ PayfritWorks/Views/MyTasksScreen.swift | 13 ++----- PayfritWorks/Views/TaskDetailScreen.swift | 19 ++--------- PayfritWorks/Views/TaskListScreen.swift | 17 ++-------- 7 files changed, 71 insertions(+), 75 deletions(-) create mode 100644 PayfritWorks/Views/Components/ErrorView.swift create mode 100644 PayfritWorks/Views/Components/LoadingView.swift diff --git a/PayfritWorks/Views/AccountScreen.swift b/PayfritWorks/Views/AccountScreen.swift index 232107f..e876ccc 100644 --- a/PayfritWorks/Views/AccountScreen.swift +++ b/PayfritWorks/Views/AccountScreen.swift @@ -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 { diff --git a/PayfritWorks/Views/BusinessSelectionScreen.swift b/PayfritWorks/Views/BusinessSelectionScreen.swift index 6921937..bb5a7d9 100644 --- a/PayfritWorks/Views/BusinessSelectionScreen.swift +++ b/PayfritWorks/Views/BusinessSelectionScreen.swift @@ -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) { diff --git a/PayfritWorks/Views/Components/ErrorView.swift b/PayfritWorks/Views/Components/ErrorView.swift new file mode 100644 index 0000000..72000e9 --- /dev/null +++ b/PayfritWorks/Views/Components/ErrorView.swift @@ -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.") +} diff --git a/PayfritWorks/Views/Components/LoadingView.swift b/PayfritWorks/Views/Components/LoadingView.swift new file mode 100644 index 0000000..def15c6 --- /dev/null +++ b/PayfritWorks/Views/Components/LoadingView.swift @@ -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() +} diff --git a/PayfritWorks/Views/MyTasksScreen.swift b/PayfritWorks/Views/MyTasksScreen.swift index 661132c..5b33cbe 100644 --- a/PayfritWorks/Views/MyTasksScreen.swift +++ b/PayfritWorks/Views/MyTasksScreen.swift @@ -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 { diff --git a/PayfritWorks/Views/TaskDetailScreen.swift b/PayfritWorks/Views/TaskDetailScreen.swift index f5a56c4..0050cf3 100644 --- a/PayfritWorks/Views/TaskDetailScreen.swift +++ b/PayfritWorks/Views/TaskDetailScreen.swift @@ -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 { diff --git a/PayfritWorks/Views/TaskListScreen.swift b/PayfritWorks/Views/TaskListScreen.swift index 2cfc565..317ef6c 100644 --- a/PayfritWorks/Views/TaskListScreen.swift +++ b/PayfritWorks/Views/TaskListScreen.swift @@ -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) { From b8d648cdfc2b250793b508bb914b6dd539e93254 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Tue, 24 Mar 2026 22:15:58 +0000 Subject: [PATCH 2/3] 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) --- PayfritWorks/Views/ProfileScreen.swift | 245 +++++++++++++++++++++---- 1 file changed, 210 insertions(+), 35 deletions(-) diff --git a/PayfritWorks/Views/ProfileScreen.swift b/PayfritWorks/Views/ProfileScreen.swift index 7e550ac..6d8f1fc 100644 --- a/PayfritWorks/Views/ProfileScreen.swift +++ b/PayfritWorks/Views/ProfileScreen.swift @@ -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) -> 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 { From 543e19a664c0ce607bd2a8fe851fc45dad9ddf08 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Tue, 24 Mar 2026 22:21:39 +0000 Subject: [PATCH 3/3] fix: standardize refresh intervals to 3 seconds across all screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per parity decision #4 — both platforms conform to 3-second refresh intervals. Previously iOS Works was at 2s, Android was at 5s. Now both at 3s. Screens updated: - MyTasksScreen: 2s → 3s - BusinessSelectionScreen: 2s → 3s - TaskListScreen: 2s → 3s Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritWorks/Views/BusinessSelectionScreen.swift | 2 +- PayfritWorks/Views/MyTasksScreen.swift | 2 +- PayfritWorks/Views/TaskListScreen.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PayfritWorks/Views/BusinessSelectionScreen.swift b/PayfritWorks/Views/BusinessSelectionScreen.swift index bb5a7d9..464451e 100644 --- a/PayfritWorks/Views/BusinessSelectionScreen.swift +++ b/PayfritWorks/Views/BusinessSelectionScreen.swift @@ -13,7 +13,7 @@ struct BusinessSelectionScreen: View { @State private var selectedBusiness: Employment? @State private var debugText = "" - private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() + private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() var body: some View { NavigationStack { diff --git a/PayfritWorks/Views/MyTasksScreen.swift b/PayfritWorks/Views/MyTasksScreen.swift index 5b33cbe..d68bae4 100644 --- a/PayfritWorks/Views/MyTasksScreen.swift +++ b/PayfritWorks/Views/MyTasksScreen.swift @@ -20,7 +20,7 @@ struct MyTasksScreen: View { FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"), ] - private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() + private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 0) { diff --git a/PayfritWorks/Views/TaskListScreen.swift b/PayfritWorks/Views/TaskListScreen.swift index 317ef6c..54b6537 100644 --- a/PayfritWorks/Views/TaskListScreen.swift +++ b/PayfritWorks/Views/TaskListScreen.swift @@ -11,7 +11,7 @@ struct TaskListScreen: View { @State private var selectedTask: WorkTask? @State private var showingMyTasks = false - private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() + private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() var body: some View { ZStack(alignment: .bottomTrailing) {