Applies verified fixes from deep audit (concurrency, performance, security, accessibility), standardizes CRUD form buttons to Add/Save pattern, removes .drawingGroup() that broke search bar TextFields, and converts vulnerable .sheet(isPresented:) + if-let patterns to safe presentation to prevent blank white modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
12 KiB
Swift
326 lines
12 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
/// Bottom sheet view that displays all completions for a task
|
|
struct CompletionHistorySheet: View {
|
|
let taskTitle: String
|
|
let taskId: Int32
|
|
@Binding var isPresented: Bool
|
|
@StateObject private var viewModel = TaskViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
WarmGradientBackground()
|
|
.ignoresSafeArea()
|
|
|
|
Group {
|
|
if viewModel.isLoadingCompletions {
|
|
loadingView
|
|
} else if let error = viewModel.completionsError {
|
|
errorView(error)
|
|
} else if viewModel.completions.isEmpty {
|
|
emptyView
|
|
} else {
|
|
completionsList
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(L10n.Tasks.completionHistory)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L10n.Common.done) {
|
|
isPresented = false
|
|
}
|
|
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.loadCompletions(taskId: taskId)
|
|
}
|
|
.onDisappear {
|
|
viewModel.resetCompletionsState()
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 64, height: 64)
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
|
|
.scaleEffect(1.2)
|
|
}
|
|
Text(L10n.Tasks.loadingCompletions)
|
|
.font(.system(size: 15, weight: .medium, design: .rounded))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func errorView(_ error: String) -> some View {
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appError.opacity(0.1))
|
|
.frame(width: 80, height: 80)
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 32, weight: .semibold))
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
|
|
Text(L10n.Tasks.failedToLoad)
|
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text(error)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, OrganicSpacing.spacious)
|
|
|
|
Button(action: {
|
|
viewModel.loadCompletions(taskId: taskId)
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(L10n.Common.retry)
|
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
|
}
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
|
.padding(.vertical, 12)
|
|
.background(Color.appPrimary)
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appTextSecondary.opacity(0.08))
|
|
.frame(width: 80, height: 80)
|
|
Image(systemName: "checkmark.circle")
|
|
.font(.system(size: 36, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
|
}
|
|
|
|
Text(L10n.Tasks.noCompletionsYet)
|
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text(L10n.Tasks.notCompleted)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var completionsList: some View {
|
|
ScrollView {
|
|
VStack(spacing: OrganicSpacing.cozy) {
|
|
// Task title header
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.12))
|
|
.frame(width: 36, height: 36)
|
|
Image(systemName: "doc.text")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
Text(taskTitle)
|
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.lineLimit(2)
|
|
|
|
Spacer()
|
|
|
|
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? L10n.Tasks.completion : L10n.Tasks.completions)")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.appTextSecondary.opacity(0.1))
|
|
)
|
|
}
|
|
.padding(OrganicSpacing.cozy)
|
|
.background(Color.appBackgroundSecondary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
.naturalShadow(.subtle)
|
|
|
|
// Completions list
|
|
ForEach(viewModel.completions, id: \.id) { completion in
|
|
CompletionHistoryCard(completion: completion)
|
|
}
|
|
}
|
|
.padding(OrganicSpacing.cozy)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Card displaying a single completion in the history sheet
|
|
struct CompletionHistoryCard: View {
|
|
let completion: TaskCompletionResponse
|
|
@State private var showPhotoSheet = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
// Header with date and completed by
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
|
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
if let completedBy = completion.completedByName, !completedBy.isEmpty {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "person.fill")
|
|
.font(.system(size: 10, weight: .medium))
|
|
Text("\(L10n.Tasks.completedByName) \(completedBy)")
|
|
.font(.system(size: 12, weight: .medium))
|
|
}
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Rating badge
|
|
if let rating = completion.rating {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "star.fill")
|
|
.font(.system(size: 11, weight: .bold))
|
|
Text("\(rating)")
|
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
}
|
|
.foregroundColor(Color.appAccent)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.appAccent.opacity(0.12))
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(Color.appAccent.opacity(0.2), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
OrganicDivider()
|
|
|
|
// Contractor info
|
|
if let contractor = completion.contractorDetails {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: "wrench.and.screwdriver.fill")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(contractor.name)
|
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
if let company = contractor.company {
|
|
Text(company)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cost
|
|
if let cost = completion.actualCost {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: "dollarsign.circle.fill")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
Text(cost.toCurrency())
|
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
}
|
|
|
|
// Notes
|
|
if !completion.notes.isEmpty {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(L10n.Tasks.notes)
|
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.textCase(.uppercase)
|
|
.tracking(0.5)
|
|
|
|
Text(completion.notes)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// Photos button
|
|
if !completion.images.isEmpty {
|
|
Button(action: {
|
|
showPhotoSheet = true
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "photo.on.rectangle.angled")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))")
|
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(Color.appPrimary.opacity(0.12))
|
|
)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
}
|
|
.padding(OrganicSpacing.cozy)
|
|
.background(Color.appBackgroundSecondary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
.naturalShadow(.medium)
|
|
.sheet(isPresented: $showPhotoSheet) {
|
|
PhotoViewerSheet(images: completion.images)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CompletionHistorySheet(
|
|
taskTitle: "Change HVAC Filter",
|
|
taskId: 1,
|
|
isPresented: .constant(true)
|
|
)
|
|
}
|