Files
honeyDueKMP/iosApp/iosApp/Contractor/ContractorsListView.swift
T
Trey T 09120e9d9d
Android UI Tests / ui-tests (push) Has been cancelled
iOS: unify empty states — one centered, leaf-decorated component
All screen-level empty states now use a single OrganicEmptyScreen that
fills the screen and centers its icon/title/subtitle/action in the dead
middle (both axes), with the three animated FloatingLeaf footer on every
empty screen.

- Add canonical OrganicEmptyScreen (Shared/Components/SharedEmptyStateView)
- Fix ListAsyncContentView: empty/error content used minHeight 60% of the
  screen (placeholder sat in the top portion) → use full height so it
  centers dead-center regardless of headers
- Hide Contractors' filter bar when the list is empty so the placeholder
  stays screen-centered
- Route Properties / Tasks / Contractors / Documents / Warranties empties
  through OrganicEmptyScreen; preserve the Tasks empty's branching
  (no-residences vs add-task vs upgrade-prompt)
- Remove the duplicate/dead empty components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:49:34 -05:00

427 lines
16 KiB
Swift

import SwiftUI
import ComposeApp
struct ContractorsListView: View {
@StateObject private var viewModel = ContractorViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@ObservedObject private var dataManager = DataManagerObservable.shared
@State private var searchText = ""
@State private var showingAddSheet = false
@State private var selectedSpecialty: String? = nil
@State private var showFavoritesOnly = false
@State private var showSpecialtyFilter = false
@State private var showingUpgradePrompt = false
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
var specialties: [String] {
contractorSpecialties.map { $0.name }
}
var contractors: [ContractorSummary] {
viewModel.contractors
}
var filteredContractors: [ContractorSummary] {
contractors.filter { contractor in
let matchesSearch = searchText.isEmpty ||
contractor.name.localizedCaseInsensitiveContains(searchText) ||
(contractor.company?.localizedCaseInsensitiveContains(searchText) ?? false)
let matchesSpecialty = selectedSpecialty == nil ||
contractor.specialties.contains { $0.name == selectedSpecialty }
let matchesFavorite = !showFavoritesOnly || contractor.isFavorite
return matchesSearch && matchesSpecialty && matchesFavorite
}
}
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
}
var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) {
// Search Bar
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
.padding(.horizontal, 16)
.padding(.top, 8)
// Active Filters hidden when the list is empty so the empty
// placeholder centers in the full screen rather than being
// offset by this header.
if (showFavoritesOnly || selectedSpecialty != nil) && !filteredContractors.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if showFavoritesOnly {
OrganicFilterChip(
title: L10n.Contractors.favorites,
icon: "star.fill",
onRemove: { showFavoritesOnly = false }
)
}
if let specialty = selectedSpecialty {
OrganicFilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
}
.padding(.horizontal, 16)
}
.padding(.vertical, 8)
}
// Content
ListAsyncContentView(
items: filteredContractors,
isLoading: viewModel.isLoading,
errorMessage: viewModel.errorMessage,
content: { contractorList in
OrganicContractorsContent(
contractors: contractorList,
onToggleFavorite: toggleFavorite
)
},
emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
OrganicEmptyScreen(
icon: "person.2.fill",
title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle,
subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters
)
} else {
UpgradeFeatureView(
triggerKey: "view_contractors",
icon: "person.2.fill"
)
}
},
onRefresh: {
viewModel.loadContractors(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
loadContractors()
}
)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
// Favorites Filter
Button(action: {
showFavoritesOnly.toggle()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
.accessibilityLabel(showFavoritesOnly ? String(localized: "Show all contractors") : String(localized: "Show favorites only"))
// Specialty Filter
Menu {
Button(action: {
selectedSpecialty = nil
}) {
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
.accessibilityLabel("Filter by specialty")
// Add Button
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
AnalyticsManager.shared.track(.contractorPaywallShown(currentCount: currentCount))
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
.accessibilityLabel("Add contractor")
}
}
}
.sheet(isPresented: $showingAddSheet, onDismiss: {
viewModel.loadContractors(forceRefresh: true)
}) {
ContractorFormSheet(
contractor: nil,
onSave: {
loadContractors()
}
)
.presentationDetents([.large])
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear {
AnalyticsManager.shared.trackScreen(.contractors)
loadContractors()
}
}
private func loadContractors(forceRefresh: Bool = false) {
viewModel.loadContractors(forceRefresh: forceRefresh)
}
private func toggleFavorite(_ id: Int32) {
viewModel.toggleFavorite(id: id) { success in
if success {
loadContractors()
}
}
}
}
// MARK: - Organic Search Bar
private struct OrganicSearchBar: View {
@Binding var text: String
var placeholder: String
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
if !text.isEmpty {
Button(action: { text = "" }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(14)
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.subtle)
}
}
// MARK: - Organic Filter Chip
private struct OrganicFilterChip: View {
let title: String
var icon: String? = nil
let onRemove: () -> Void
var body: some View {
HStack(spacing: 6) {
if let icon = icon {
Image(systemName: icon)
.font(.system(size: 12, weight: .semibold))
}
Text(title)
.font(.system(size: 13, weight: .semibold))
Button(action: onRemove) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.15))
.foregroundColor(Color.appPrimary)
.clipShape(Capsule())
}
}
// MARK: - Organic Contractors Content
private struct OrganicContractorsContent: View {
let contractors: [ContractorSummary]
let onToggleFavorite: (Int32) -> Void
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
OrganicContractorCard(
contractor: contractor,
onToggleFavorite: {
onToggleFavorite(contractor.id)
}
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(16)
.padding(.bottom, 40)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0)
}
}
}
// MARK: - Organic Contractor Card
private struct OrganicContractorCard: View {
let contractor: ContractorSummary
let onToggleFavorite: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View {
HStack(spacing: 14) {
// Avatar
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 50, height: 50)
Text(String(contractor.name.prefix(1)).uppercased())
.font(.system(size: 20, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
}
VStack(alignment: .leading, spacing: 4) {
Text(contractor.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
.lineLimit(1)
if let company = contractor.company, !company.isEmpty {
Text(company)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
}
if !contractor.specialties.isEmpty {
HStack(spacing: 4) {
ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in
Text(specialty.displayName)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
}
if contractor.specialties.count > 2 {
Text("+\(contractor.specialties.count - 2)")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
}
}
}
Spacer()
// Favorite Button
Button(action: onToggleFavorite) {
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
.font(.system(size: 18, weight: .medium))
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(contractor.isFavorite ? String(format: String(localized: "Remove %@ from favorites"), contractor.name) : String(format: String(localized: "Add %@ to favorites"), contractor.name))
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
.accessibilityHidden(true)
}
.padding(16)
.background(
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02))
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
.offset(x: geo.size.width * 0.6, y: 0)
.blur(radius: 10)
}
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.medium)
}
}
// MARK: - Organic Toolbar Button
private struct OrganicToolbarButton: View {
let systemName: String
var isPrimary: Bool = false
var body: some View {
ZStack {
if isPrimary {
Circle()
.fill(Color.appPrimary)
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
} else {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
}
}
#Preview {
NavigationView {
ContractorsListView()
}
}