Compare commits
4 commits
schwifty/p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de67323c7 | |||
| 543e19a664 | |||
| b8d648cdfc | |||
| 1c427a0902 |
11 changed files with 304 additions and 142 deletions
|
|
@ -691,14 +691,14 @@ actor APIService {
|
||||||
|
|
||||||
func getAvatarUrl() async throws -> String? {
|
func getAvatarUrl() async throws -> String? {
|
||||||
let json = try await getJSON("/auth/avatar.php")
|
let json = try await getJSON("/auth/avatar.php")
|
||||||
if IS_DEV { print("[Avatar] Response: \(json)") }
|
print("[Avatar] Response: \(json)")
|
||||||
guard ok(json) else {
|
guard ok(json) else {
|
||||||
if IS_DEV { print("[Avatar] Response not OK") }
|
print("[Avatar] Response not OK")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = json["DATA"] as? [String: Any] ?? json
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
if IS_DEV { print("[Avatar] Data: \(data)") }
|
print("[Avatar] Data: \(data)")
|
||||||
|
|
||||||
// Try all possible key variations for avatar URL
|
// Try all possible key variations for avatar URL
|
||||||
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
||||||
|
|
@ -707,16 +707,16 @@ actor APIService {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let url = data[key] as? String, !url.isEmpty {
|
if let url = data[key] as? String, !url.isEmpty {
|
||||||
let resolved = Self.resolvePhotoUrl(url)
|
let resolved = Self.resolvePhotoUrl(url)
|
||||||
if IS_DEV { print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)") }
|
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)")
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
if let url = json[key] as? String, !url.isEmpty {
|
if let url = json[key] as? String, !url.isEmpty {
|
||||||
let resolved = Self.resolvePhotoUrl(url)
|
let resolved = Self.resolvePhotoUrl(url)
|
||||||
if IS_DEV { print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)") }
|
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)")
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if IS_DEV { print("[Avatar] No avatar URL found in response") }
|
print("[Avatar] No avatar URL found in response")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -724,7 +724,7 @@ actor APIService {
|
||||||
func getUserAvatarUrl(userId: Int) async throws -> String? {
|
func getUserAvatarUrl(userId: Int) async throws -> String? {
|
||||||
// Try the avatar endpoint with UserID
|
// Try the avatar endpoint with UserID
|
||||||
let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId])
|
let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId])
|
||||||
if IS_DEV { print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)") }
|
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)")
|
||||||
|
|
||||||
let data = json["DATA"] as? [String: Any] ?? json
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
|
||||||
|
|
@ -733,15 +733,15 @@ actor APIService {
|
||||||
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let url = data[key] as? String, !url.isEmpty {
|
if let url = data[key] as? String, !url.isEmpty {
|
||||||
if IS_DEV { print("[Avatar] Found avatar for userId \(userId): \(url)") }
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
||||||
return Self.resolvePhotoUrl(url)
|
return Self.resolvePhotoUrl(url)
|
||||||
}
|
}
|
||||||
if let url = json[key] as? String, !url.isEmpty {
|
if let url = json[key] as? String, !url.isEmpty {
|
||||||
if IS_DEV { print("[Avatar] Found avatar for userId \(userId): \(url)") }
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
||||||
return Self.resolvePhotoUrl(url)
|
return Self.resolvePhotoUrl(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if IS_DEV { print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())") }
|
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if IS_DEV { print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)") }
|
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopScanning() {
|
func stopScanning() {
|
||||||
|
|
@ -137,12 +137,12 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.resolvedBeacons[key] = servicePointId
|
self.resolvedBeacons[key] = servicePointId
|
||||||
self.pendingResolutions.remove(key)
|
self.pendingResolutions.remove(key)
|
||||||
if IS_DEV { print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)") }
|
print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.pendingResolutions.remove(key)
|
self.pendingResolutions.remove(key)
|
||||||
if IS_DEV { print("[BeaconScanner] Failed to resolve \(key): \(error)") }
|
print("[BeaconScanner] Failed to resolve \(key): \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +181,7 @@ extension BeaconScanner: CLLocationManagerDelegate {
|
||||||
|
|
||||||
if rssiSamples.count >= minSamplesToConfirm {
|
if rssiSamples.count >= minSamplesToConfirm {
|
||||||
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
||||||
if IS_DEV { print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)") }
|
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)")
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.onBeaconDetected(avg)
|
self?.onBeaconDetected(avg)
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +210,6 @@ extension BeaconScanner: CLLocationManagerDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
||||||
if IS_DEV { print("[BeaconScanner] Ranging failed: \(error)") }
|
print("[BeaconScanner] Ranging failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ struct AboutScreen: View {
|
||||||
aboutInfo = try await APIService.shared.getAboutInfo()
|
aboutInfo = try await APIService.shared.getAboutInfo()
|
||||||
} catch {
|
} catch {
|
||||||
// Use fallback on error
|
// Use fallback on error
|
||||||
if IS_DEV { print("Failed to load about info: \(error)") }
|
print("Failed to load about info: \(error)")
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,10 @@ struct AccountScreen: View {
|
||||||
|
|
||||||
// Payout content
|
// Payout content
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
LoadingView()
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
errorView(error)
|
ErrorView(message: error) { Task { await loadData() } }
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if let tier = tierStatus {
|
if let tier = tierStatus {
|
||||||
|
|
@ -386,21 +386,6 @@ struct AccountScreen: View {
|
||||||
.padding(.top, 8)
|
.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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadAvatar() async {
|
private func loadAvatar() async {
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,16 @@ struct BusinessSelectionScreen: View {
|
||||||
@State private var selectedBusiness: Employment?
|
@State private var selectedBusiness: Employment?
|
||||||
@State private var debugText = ""
|
@State private var debugText = ""
|
||||||
|
|
||||||
private let refreshTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
|
private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
LoadingView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
errorView(error)
|
ErrorView(message: error) { loadBusinesses() }
|
||||||
} else if businesses.isEmpty {
|
} else if businesses.isEmpty {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -246,19 +246,6 @@ struct BusinessSelectionScreen: View {
|
||||||
.padding()
|
.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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadBusinesses(silent: Bool = false) {
|
private func loadBusinesses(silent: Bool = false) {
|
||||||
|
|
|
||||||
34
PayfritWorks/Views/Components/ErrorView.swift
Normal file
34
PayfritWorks/Views/Components/ErrorView.swift
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable error state view with icon, message, and optional retry button.
|
||||||
|
/// Replaces inline error patterns across all screens.
|
||||||
|
struct ErrorView: View {
|
||||||
|
let message: String
|
||||||
|
var onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.circle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(message)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
if let onRetry = onRetry {
|
||||||
|
Button("Retry", action: onRetry)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("With Retry") {
|
||||||
|
ErrorView(message: "Something went wrong. Please try again.") {
|
||||||
|
print("Retry tapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("No Retry") {
|
||||||
|
ErrorView(message: "Could not load data.")
|
||||||
|
}
|
||||||
27
PayfritWorks/Views/Components/LoadingView.swift
Normal file
27
PayfritWorks/Views/Components/LoadingView.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable loading state view with a spinner and optional message.
|
||||||
|
/// Replaces inline ProgressView() patterns across all screens.
|
||||||
|
struct LoadingView: View {
|
||||||
|
var message: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
if let message = message {
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("With Message") {
|
||||||
|
LoadingView(message: "Loading tasks...")
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("No Message") {
|
||||||
|
LoadingView()
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ struct MyTasksScreen: View {
|
||||||
FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"),
|
FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"),
|
||||||
]
|
]
|
||||||
|
|
||||||
private let refreshTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
|
private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -94,18 +94,9 @@ struct MyTasksScreen: View {
|
||||||
let tasks = tasksByFilter[filterType] ?? []
|
let tasks = tasksByFilter[filterType] ?? []
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
LoadingView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
VStack(spacing: 16) {
|
ErrorView(message: error) { loadTasks(filterType) }
|
||||||
Image(systemName: "exclamationmark.circle")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(error)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") { loadTasks(filterType) }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
} else if tasks.isEmpty {
|
} else if tasks.isEmpty {
|
||||||
emptyView(filterType)
|
emptyView(filterType)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,23 @@ struct ProfileScreen: View {
|
||||||
@State private var appearAnimation = false
|
@State private var appearAnimation = false
|
||||||
@State private var avatarURLLoaded: URL?
|
@State private var avatarURLLoaded: URL?
|
||||||
|
|
||||||
|
// Profile data from API
|
||||||
|
@State private var profile: APIService.UserProfile?
|
||||||
|
@State private var isLoadingProfile = true
|
||||||
|
@State private var profileError: String?
|
||||||
|
|
||||||
|
// Edit mode
|
||||||
|
@State private var isEditing = false
|
||||||
|
@State private var editFirstName = ""
|
||||||
|
@State private var editLastName = ""
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var saveError: String?
|
||||||
|
|
||||||
private var displayName: String {
|
private var displayName: String {
|
||||||
appState.userName ?? "Worker"
|
if let p = profile {
|
||||||
|
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
return appState.userName ?? "Worker"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var avatarURL: URL? {
|
private var avatarURL: URL? {
|
||||||
|
|
@ -57,34 +72,20 @@ struct ProfileScreen: View {
|
||||||
.scaleEffect(appearAnimation ? 1 : 0.9)
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||||
.opacity(appearAnimation ? 1 : 0)
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
// Info section
|
// Content
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
if isLoadingProfile {
|
||||||
Text("Account Information")
|
LoadingView(message: "Loading profile...")
|
||||||
.font(.subheadline)
|
.frame(height: 120)
|
||||||
.fontWeight(.semibold)
|
} else if let error = profileError {
|
||||||
.foregroundColor(.payfritGreen)
|
ErrorView(message: error) { loadProfile() }
|
||||||
.padding(.horizontal, 16)
|
} else if isEditing {
|
||||||
|
editSection
|
||||||
VStack(spacing: 0) {
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
infoRow(icon: "person.fill", label: "Name", value: displayName)
|
} else {
|
||||||
Divider().padding(.leading, 54)
|
infoSection
|
||||||
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName)
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
}
|
.offset(y: appearAnimation ? 0 : 15)
|
||||||
.background(Color(.secondarySystemGroupedBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
}
|
||||||
.opacity(appearAnimation ? 1 : 0)
|
|
||||||
.offset(y: appearAnimation ? 0 : 15)
|
|
||||||
|
|
||||||
// Note
|
|
||||||
Text("Profile information is managed through your employer. Contact your manager if you need to update your details.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.opacity(appearAnimation ? 1 : 0)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
|
|
@ -93,16 +94,17 @@ struct ProfileScreen: View {
|
||||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("Profile")
|
.navigationTitle("Profile")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.toolbar {
|
||||||
do {
|
if !isEditing && !isLoadingProfile && profileError == nil {
|
||||||
if let urlString = try await APIService.shared.getAvatarUrl(),
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
let url = URL(string: urlString) {
|
Button("Edit") { startEditing() }
|
||||||
avatarURLLoaded = url
|
.foregroundColor(.payfritGreen)
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Avatar load failed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
await loadAvatarAndProfile()
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
appearAnimation = true
|
appearAnimation = true
|
||||||
|
|
@ -110,6 +112,89 @@ struct ProfileScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Info Section (Read-Only)
|
||||||
|
|
||||||
|
private var infoSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Account Information")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let p = profile {
|
||||||
|
infoRow(icon: "person.fill", label: "First Name", value: p.firstName.isEmpty ? "-" : p.firstName)
|
||||||
|
Divider().padding(.leading, 54)
|
||||||
|
infoRow(icon: "person.fill", label: "Last Name", value: p.lastName.isEmpty ? "-" : p.lastName)
|
||||||
|
Divider().padding(.leading, 54)
|
||||||
|
}
|
||||||
|
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName)
|
||||||
|
}
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edit Section
|
||||||
|
|
||||||
|
private var editSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Edit Profile")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
editRow(icon: "person.fill", label: "First Name", text: $editFirstName)
|
||||||
|
Divider().padding(.leading, 54)
|
||||||
|
editRow(icon: "person.fill", label: "Last Name", text: $editLastName)
|
||||||
|
}
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
if let error = saveError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
withAnimation { isEditing = false }
|
||||||
|
saveError = nil
|
||||||
|
} label: {
|
||||||
|
Text("Cancel")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isSaving)
|
||||||
|
|
||||||
|
Button { saveProfile() } label: {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 20)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.payfritGreen)
|
||||||
|
.disabled(isSaving)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
private var placeholderAvatar: some View {
|
private var placeholderAvatar: some View {
|
||||||
Image(systemName: "person.circle.fill")
|
Image(systemName: "person.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|
@ -143,6 +228,96 @@ struct ProfileScreen: View {
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func editRow(icon: String, label: String, text: Binding<String>) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
TextField(label, text: text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.textContentType(label == "First Name" ? .givenName : .familyName)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadAvatarAndProfile() async {
|
||||||
|
// Load avatar
|
||||||
|
do {
|
||||||
|
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
avatarURLLoaded = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avatar load failed, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profile
|
||||||
|
loadProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProfile() {
|
||||||
|
isLoadingProfile = true
|
||||||
|
profileError = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let p = try await APIService.shared.getProfile()
|
||||||
|
profile = p
|
||||||
|
isLoadingProfile = false
|
||||||
|
} catch {
|
||||||
|
profileError = error.localizedDescription
|
||||||
|
isLoadingProfile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startEditing() {
|
||||||
|
editFirstName = profile?.firstName ?? ""
|
||||||
|
editLastName = profile?.lastName ?? ""
|
||||||
|
saveError = nil
|
||||||
|
withAnimation { isEditing = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() {
|
||||||
|
isSaving = true
|
||||||
|
saveError = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await APIService.shared.updateProfile(
|
||||||
|
firstName: editFirstName,
|
||||||
|
lastName: editLastName
|
||||||
|
)
|
||||||
|
// Refresh profile data
|
||||||
|
let p = try await APIService.shared.getProfile()
|
||||||
|
profile = p
|
||||||
|
|
||||||
|
// Update appState display name
|
||||||
|
let newName = [p.firstName, p.lastName]
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: " ")
|
||||||
|
appState.userName = newName.isEmpty ? nil : newName
|
||||||
|
|
||||||
|
isSaving = false
|
||||||
|
withAnimation { isEditing = false }
|
||||||
|
} catch {
|
||||||
|
saveError = error.localizedDescription
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ struct TaskDetailScreen: View {
|
||||||
@State private var showCancelOrderAlert = false
|
@State private var showCancelOrderAlert = false
|
||||||
@State private var isCancelingOrder = false
|
@State private var isCancelingOrder = false
|
||||||
@State private var taskAccepted = false // Track if task was just accepted
|
@State private var taskAccepted = false // Track if task was just accepted
|
||||||
@State private var hasCompleted = false // Guard against double-completion
|
|
||||||
@State private var customerAvatarUrl: String? // Fetched separately if not in task details
|
@State private var customerAvatarUrl: String? // Fetched separately if not in task details
|
||||||
|
|
||||||
// Rating dialog
|
// Rating dialog
|
||||||
|
|
@ -43,9 +42,9 @@ struct TaskDetailScreen: View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
LoadingView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
errorView(error)
|
ErrorView(message: error) { Task { await loadDetails() } }
|
||||||
} else if let details = details {
|
} else if let details = details {
|
||||||
contentView(details)
|
contentView(details)
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +100,6 @@ struct TaskDetailScreen: View {
|
||||||
} else if result == "cancelled" || result == "error" {
|
} else if result == "cancelled" || result == "error" {
|
||||||
autoCompleting = false
|
autoCompleting = false
|
||||||
beaconDetected = false
|
beaconDetected = false
|
||||||
hasCompleted = false
|
|
||||||
beaconScanner?.resetSamples()
|
beaconScanner?.resetSamples()
|
||||||
beaconScanner?.startScanning()
|
beaconScanner?.startScanning()
|
||||||
}
|
}
|
||||||
|
|
@ -631,7 +629,6 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
.disabled(hasCompleted)
|
|
||||||
} else {
|
} else {
|
||||||
Button { showCompleteAlert = true } label: {
|
Button { showCompleteAlert = true } label: {
|
||||||
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
||||||
|
|
@ -640,7 +637,6 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
.disabled(hasCompleted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -667,21 +663,6 @@ struct TaskDetailScreen: View {
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error
|
|
||||||
|
|
||||||
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") { Task { await loadDetails() } }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadDetails() async {
|
private func loadDetails() async {
|
||||||
|
|
@ -710,12 +691,12 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if IS_DEV { print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") }
|
print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
|
||||||
if showCompleteButton && d.servicePointId > 0 {
|
if showCompleteButton && d.servicePointId > 0 {
|
||||||
if IS_DEV { print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)") }
|
print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)")
|
||||||
startBeaconScanning(d.servicePointId)
|
startBeaconScanning(d.servicePointId)
|
||||||
} else {
|
} else {
|
||||||
if IS_DEV { print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") }
|
print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|
@ -727,10 +708,9 @@ struct TaskDetailScreen: View {
|
||||||
let scanner = BeaconScanner(
|
let scanner = BeaconScanner(
|
||||||
targetServicePointId: servicePointId,
|
targetServicePointId: servicePointId,
|
||||||
onBeaconDetected: { [self] _ in
|
onBeaconDetected: { [self] _ in
|
||||||
if !beaconDetected && !autoCompleting && !hasCompleted {
|
if !beaconDetected && !autoCompleting {
|
||||||
beaconDetected = true
|
beaconDetected = true
|
||||||
autoCompleting = true
|
autoCompleting = true
|
||||||
hasCompleted = true
|
|
||||||
beaconScanner?.stopScanning()
|
beaconScanner?.stopScanning()
|
||||||
showAutoCompleteDialog = true
|
showAutoCompleteDialog = true
|
||||||
}
|
}
|
||||||
|
|
@ -765,8 +745,6 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeTask() {
|
private func completeTask() {
|
||||||
guard !hasCompleted else { return }
|
|
||||||
hasCompleted = true
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await APIService.shared.completeTask(taskId: task.taskId)
|
try await APIService.shared.completeTask(taskId: task.taskId)
|
||||||
|
|
@ -775,11 +753,9 @@ struct TaskDetailScreen: View {
|
||||||
if case .ratingRequired = apiError {
|
if case .ratingRequired = apiError {
|
||||||
showRatingDialog = true
|
showRatingDialog = true
|
||||||
} else {
|
} else {
|
||||||
hasCompleted = false
|
|
||||||
self.error = apiError.localizedDescription
|
self.error = apiError.localizedDescription
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
hasCompleted = false
|
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ struct TaskListScreen: View {
|
||||||
@State private var selectedTask: WorkTask?
|
@State private var selectedTask: WorkTask?
|
||||||
@State private var showingMyTasks = false
|
@State private var showingMyTasks = false
|
||||||
|
|
||||||
private let refreshTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
|
private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
LoadingView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
errorView(error)
|
ErrorView(message: error) { loadTasks() }
|
||||||
} else if tasks.isEmpty {
|
} else if tasks.isEmpty {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -195,19 +195,6 @@ struct TaskListScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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") { loadTasks() }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadTasks(silent: Bool = false) {
|
private func loadTasks(silent: Bool = false) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue