353 lines
12 KiB
Swift
353 lines
12 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
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.padding(.top, 40)
|
|
} else if let error = error {
|
|
errorView(error)
|
|
} else {
|
|
if let tier = tierStatus {
|
|
tierCard(tier)
|
|
activationCard(tier)
|
|
}
|
|
|
|
if let ledger = ledger {
|
|
earningsCard(ledger)
|
|
}
|
|
|
|
logoutButton
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.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 loadData() }
|
|
.refreshable { await loadData() }
|
|
.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: - 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 {
|
|
// Tier 1 unlocked
|
|
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 {
|
|
// No account yet
|
|
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 {
|
|
// Account exists but incomplete
|
|
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 {
|
|
// Progress bar
|
|
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: - 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 loadData() async {
|
|
isLoading = true
|
|
error = nil
|
|
|
|
// Load tier status (required)
|
|
do {
|
|
tierStatus = try await APIService.shared.getTierStatus()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
// Load ledger (optional — don't block screen if it fails)
|
|
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
|
|
}
|
|
}
|
|
// Refresh on return
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|