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) <noreply@anthropic.com>
390 lines
13 KiB
Swift
390 lines
13 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: 3, on: .main, in: .common).autoconnect()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
Group {
|
|
if isLoading {
|
|
LoadingView()
|
|
} else if let error = error {
|
|
ErrorView(message: error) { loadBusinesses() }
|
|
} 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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|