payfrit-works-ios/PayfritWorks/Views/BusinessSelectionScreen.swift
Schwifty db7fe31b8a fix: standardize task polling interval from 2s to 5s (Android parity)
TaskListScreen, MyTasksScreen, and BusinessSelectionScreen all had 2-second
refresh timers. Changed to 5 seconds to match Android and reduce server load.
Chat polling (3s) left unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:10:07 +00:00

403 lines
14 KiB
Swift

import SwiftUI
struct BusinessSelectionScreen: View {
@EnvironmentObject var appState: AppState
@State private var businesses: [Employment] = []
@State private var myTaskBusinessIds: Set<Int> = []
@State private var isLoading = true
@State private var error: String?
@State private var showingTaskList = false
@State private var showingMyTasks = false
@State private var showingAccount = false
@State private var showingDebug = false
@State private var selectedBusiness: Employment?
@State private var debugText = ""
private let refreshTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView()
} else if let error = error {
errorView(error)
} else if businesses.isEmpty {
emptyView
} else {
businessList
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
myTasksFAB
}
.navigationTitle("Select Business")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Label(appState.userName ?? "Worker", systemImage: "person.circle.fill")
Divider()
Button { showingMyTasks = true } label: {
Label("My Tasks", systemImage: "checkmark.circle")
}
Button { showingAccount = true } label: {
Label("Account", systemImage: "person.crop.circle")
}
Button { loadDebugInfo() } label: {
Label("Debug API", systemImage: "ladybug")
}
Button(role: .destructive) { logout() } label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { loadBusinesses() } label: {
Image(systemName: "arrow.clockwise")
}
}
}
.navigationDestination(isPresented: $showingTaskList) {
if let biz = selectedBusiness {
TaskListScreen(businessName: biz.businessName)
.onAppear {
Task { await APIService.shared.setBusinessId(biz.businessId) }
appState.setBusinessId(biz.businessId, roleId: biz.roleId)
}
}
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.navigationDestination(isPresented: $showingAccount) {
AccountScreen()
}
.onChange(of: appState.shouldPopToRoot) { shouldPop in
if shouldPop {
showingTaskList = false
showingMyTasks = false
showingAccount = false
appState.shouldPopToRoot = false
}
}
.onChange(of: appState.needsRefresh) { needsRefresh in
if needsRefresh {
loadBusinesses()
appState.needsRefresh = false
}
}
}
.sheet(isPresented: $showingDebug) {
NavigationStack {
ScrollView {
Text(debugText)
.font(.system(.caption2, design: .monospaced))
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle("API Debug")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { showingDebug = false }
}
}
}
}
.task { loadBusinesses() }
.onReceive(refreshTimer) { _ in loadBusinesses(silent: true) }
}
// MARK: - Business List
private var businessList: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(businesses) { emp in
let hasActivity = emp.pendingTaskCount > 0 || myTaskBusinessIds.contains(emp.businessId)
if hasActivity {
Button { selectBusiness(emp) } label: {
businessCard(emp)
}
.buttonStyle(.plain)
} else {
businessCard(emp)
.opacity(0.5)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 80) // space for FAB
}
.refreshable { loadBusinesses() }
}
private func businessCard(_ emp: Employment) -> some View {
HStack(spacing: 14) {
// Initial letter
Text(String(emp.businessName.prefix(1)).uppercased())
.font(.system(size: 32, weight: .bold))
.foregroundColor(.primary.opacity(0.7))
.frame(width: 50)
// Name + status
VStack(alignment: .leading, spacing: 4) {
Text(emp.businessName)
.font(.body.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
Text(emp.statusName)
.font(.caption.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(statusColor(emp.employeeStatusId))
.clipShape(Capsule())
}
Spacer()
// Task indicators
HStack(spacing: 8) {
// Red indicator if user has active tasks with this business
if myTaskBusinessIds.contains(emp.businessId) {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
.font(.body)
.foregroundColor(.red)
Text("active")
.font(.caption2.weight(.medium))
.foregroundColor(.red)
}
}
// Pending task count or clear checkmark
if emp.pendingTaskCount > 0 {
Text("\(emp.pendingTaskCount)")
.font(.title3.bold())
.foregroundColor(.payfritGreen)
} else if !myTaskBusinessIds.contains(emp.businessId) {
VStack(spacing: 2) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.payfritGreen)
Text("clear")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.payfritGreen.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
}
private var myTasksFAB: some View {
Button { showingMyTasks = true } label: {
HStack(spacing: 8) {
Image(systemName: "checkmark.square.fill")
.font(.title3)
Text("My Tasks")
.font(.body.weight(.semibold))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 6, x: 0, y: 3)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
// MARK: - Empty / Error
private var emptyView: some View {
VStack(spacing: 16) {
Image(systemName: "briefcase")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No businesses found")
.font(.title3)
.foregroundColor(.secondary)
Text("You are not currently employed at any business")
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.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) {
if !silent {
isLoading = true
error = nil
}
Task {
do {
let result = try await APIService.shared.getMyBusinesses()
businesses = result
// Fetch user's active tasks to show indicator
let myTasks = try? await APIService.shared.listMyTasks(filterType: "active")
if let tasks = myTasks {
myTaskBusinessIds = Set(tasks.map { $0.businessId })
}
isLoading = false
} catch {
if !silent {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
private func selectBusiness(_ emp: Employment) {
selectedBusiness = emp
showingTaskList = true
}
private func logout() {
Task {
await APIService.shared.logout()
await AuthStorage.shared.clearAuth()
appState.clearAuth()
}
}
private func loadDebugInfo() {
debugText = "Loading..."
showingDebug = true
Task {
let uid = await APIService.shared.getUserId() ?? 0
let bid = await APIService.shared.getBusinessId()
var text = "=== RAW API RESPONSES ===\n\n"
text += "UserID: \(uid), BusinessID: \(bid)\n\n"
text += "--- /workers/myBusinesses.php ---\n"
let bizRaw = await APIService.shared.debugRawJSON(
"/workers/myBusinesses.php", payload: ["UserID": uid])
text += bizRaw + "\n\n"
if bid > 0 {
text += "--- /tasks/listPending.php ---\n"
let taskRaw = await APIService.shared.debugRawJSON(
"/tasks/listPending.php", payload: ["BusinessID": bid])
text += taskRaw + "\n\n"
}
debugText = text
}
}
private func statusColor(_ id: Int) -> Color {
switch id {
case 0: return .payfritGreen
case 1: return .green
case 2: return .gray
default: return .gray
}
}
}
// MARK: - Business Header Image
struct BusinessHeaderImage: View {
let businessId: Int
@State private var loadedImage: UIImage?
@State private var isLoading = true
private var imageURLs: [URL] {
let domain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
return [
"\(domain)/uploads/headers/\(businessId).png",
"\(domain)/uploads/headers/\(businessId).jpg",
].compactMap { URL(string: $0) }
}
var body: some View {
ZStack {
Color(.systemGray6)
if let image = loadedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
} else if isLoading {
ProgressView()
.tint(.payfritGreen)
.frame(maxWidth: .infinity)
.frame(height: 100)
} else {
// No image fallback
Image(systemName: "building.2")
.font(.system(size: 30))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
.frame(height: 100)
}
}
.task {
await loadImage()
}
}
private func loadImage() async {
for url in imageURLs {
do {
let (data, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let image = UIImage(data: data) {
await MainActor.run {
loadedImage = image
isLoading = false
}
return
}
} catch {
continue
}
}
await MainActor.run {
isLoading = false
}
}
}