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>
511 lines
18 KiB
Swift
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())
|
|
}
|
|
}
|