payfrit-works-ios/PayfritWorks/Views/AccountScreen.swift
Schwifty 1c427a0902 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) <noreply@anthropic.com>
2026-03-24 22:15:03 +00:00

511 lines
18 KiB
Swift

import SwiftUI
struct AccountScreen: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var tierStatus: TierStatus?
@State private var ledger: LedgerResponse?
@State private var isLoading = true
@State private var error: String?
@State private var showingMyTasks = false
@State private var showActivationInfo = false
@State private var appearAnimation = false
@State private var avatarURLLoaded: URL?
private var displayName: String {
appState.userName ?? "Worker"
}
private var avatarURL: URL? {
// Prefer loaded URL from API, fall back to appState
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: 16) {
// 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: 70, height: 70)
}
}
.frame(width: 70, height: 70)
.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(.subheadline)
.fontWeight(.bold)
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.scaleEffect(appearAnimation ? 1 : 0.9)
.opacity(appearAnimation ? 1 : 0)
// Navigation cards
VStack(spacing: 12) {
NavigationLink {
ProfileScreen()
.environmentObject(appState)
} label: {
accountCard(
icon: "person",
title: "Edit Profile",
subtitle: "View your account info"
)
}
.staggeredAppear(index: 0, appeared: appearAnimation)
NavigationLink {
AboutScreen()
} label: {
accountCard(
icon: "info.circle",
title: "About Payfrit",
subtitle: "App info and features"
)
}
.staggeredAppear(index: 1, appeared: appearAnimation)
}
.padding(.horizontal, 16)
// Payout content
if isLoading {
LoadingView()
.padding(.top, 20)
} else if let error = error {
ErrorView(message: error) { Task { await loadData() } }
} else {
VStack(spacing: 16) {
if let tier = tierStatus {
tierCard(tier)
.staggeredAppear(index: 2, appeared: appearAnimation)
activationCard(tier)
.staggeredAppear(index: 3, appeared: appearAnimation)
}
if let ledger = ledger {
earningsCard(ledger)
.staggeredAppear(index: 4, appeared: appearAnimation)
}
logoutButton
.staggeredAppear(index: 5, appeared: appearAnimation)
}
.padding(.horizontal, 16)
}
}
.padding(.vertical, 16)
}
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("Account")
.overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task {
await loadAvatar()
await loadData()
}
.refreshable { await loadData() }
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
appearAnimation = true
}
}
.alert("What is Activation?", isPresented: $showActivationInfo) {
Button("Got it", role: .cancel) { }
} message: {
Text("Activation tracks your early earnings to help establish your account. Once you reach the activation cap, your account is fully activated and you can receive regular payouts. You can also complete activation early if you prefer.")
}
}
// MARK: - Avatar
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: 70, height: 70)
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
}
// MARK: - Account Card
private func accountCard(icon: String, title: String, subtitle: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.subheadline)
.foregroundColor(.payfritGreen)
.frame(width: 28, height: 28)
.background(Color.payfritGreen.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.contentShape(Rectangle())
}
// MARK: - Tier Card
@ViewBuilder
private func tierCard(_ tier: TierStatus) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: tier.tier >= 1 ? "checkmark.shield.fill" : "shield")
.foregroundColor(tier.tier >= 1 ? .green : .secondary)
.font(.title2)
Text("Payout Status")
.font(.headline)
Spacer()
}
if tier.tier >= 1 {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Tier 1 unlocked — Payouts enabled")
.foregroundColor(.green)
.font(.subheadline.weight(.medium))
}
} else if !tier.stripe.hasAccount {
Text("Tier 1 is locked")
.font(.subheadline)
.foregroundColor(.secondary)
Button { startStripeOnboarding() } label: {
Label("Complete payout setup", systemImage: "arrow.right.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
} else if tier.stripe.setupIncomplete {
Text("Stripe needs more info to enable payouts.")
.font(.subheadline)
.foregroundColor(.secondary)
Button { continueStripeOnboarding() } label: {
Label("Continue payout setup", systemImage: "arrow.right.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Activation Card
@ViewBuilder
private func activationCard(_ tier: TierStatus) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: tier.activation.isComplete ? "checkmark.circle.fill" : "star.circle")
.foregroundColor(tier.activation.isComplete ? .green : .payfritGreen)
.font(.title2)
Text("Activation")
.font(.headline)
Spacer()
}
if tier.activation.isComplete {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Activation complete")
.foregroundColor(.green)
.font(.subheadline.weight(.medium))
}
} else {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: tier.activation.progress)
.tint(.payfritGreen)
Text("\(tier.activation.balanceDollars) of \(tier.activation.capDollars) completed")
.font(.subheadline)
.foregroundColor(.secondary)
}
Button { earlyUnlock() } label: {
Label("Complete activation now (optional)", systemImage: "bolt.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button { showActivationInfo = true } label: {
Text("What is activation?")
.font(.caption)
.foregroundColor(.payfritGreen)
}
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Earnings Card
@ViewBuilder
private func earningsCard(_ ledger: LedgerResponse) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "dollarsign.circle")
.foregroundColor(.green)
.font(.title2)
Text("Earnings")
.font(.headline)
Spacer()
VStack(alignment: .trailing) {
Text(ledger.totals.totalNetDollars)
.font(.title3.bold())
.foregroundColor(.green)
Text("total earned")
.font(.caption)
.foregroundColor(.secondary)
}
}
if !ledger.entries.isEmpty {
Divider()
ForEach(ledger.entries.prefix(20)) { entry in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Task #\(entry.taskId)")
.font(.subheadline.weight(.medium))
Text(entry.createdAt)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(entry.netDollars)
.font(.subheadline.weight(.medium))
.foregroundColor(.green)
Text(entry.statusDisplay)
.font(.caption2)
.foregroundColor(entry.status == "transferred" ? .green : .secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
entry.status == "transferred"
? Color.green.opacity(0.1)
: Color(.systemGray5)
)
.cornerRadius(4)
}
}
.padding(.vertical, 2)
}
} else {
Text("No earnings yet. Complete tasks to start earning!")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Logout
private var logoutButton: some View {
Button(role: .destructive) {
Task {
await APIService.shared.logout()
await AuthStorage.shared.clearAuth()
appState.clearAuth()
}
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
.padding(.top, 8)
}
// MARK: - Actions
private func loadAvatar() async {
do {
if let urlString = try await APIService.shared.getAvatarUrl(),
let url = URL(string: urlString) {
avatarURLLoaded = url
}
} catch {
// Avatar load failed, will use placeholder
}
}
private func loadData() async {
isLoading = true
error = nil
do {
tierStatus = try await APIService.shared.getTierStatus()
} catch {
self.error = error.localizedDescription
isLoading = false
return
}
do {
ledger = try await APIService.shared.getLedger()
} catch {
ledger = LedgerResponse()
}
isLoading = false
}
private func startStripeOnboarding() {
Task {
do {
_ = try await APIService.shared.createStripeAccount()
let urlStr = try await APIService.shared.getOnboardingLink()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
private func continueStripeOnboarding() {
Task {
do {
let urlStr = try await APIService.shared.getOnboardingLink()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
private func earlyUnlock() {
Task {
do {
let urlStr = try await APIService.shared.getEarlyUnlockUrl()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
}
// MARK: - Staggered Appear Modifier
private struct StaggeredAppearModifier: ViewModifier {
let index: Int
let appeared: Bool
func body(content: Content) -> some View {
content
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.animation(
.spring(response: 0.4, dampingFraction: 0.8)
.delay(Double(index) * 0.06),
value: appeared
)
}
}
private extension View {
func staggeredAppear(index: Int, appeared: Bool) -> some View {
modifier(StaggeredAppearModifier(index: index, appeared: appeared))
}
}
#Preview {
NavigationStack {
AccountScreen()
.environmentObject(AppState())
}
}