09120e9d9d
Android UI Tests / ui-tests (push) Has been cancelled
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>
427 lines
16 KiB
Swift
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()
|
|
}
|
|
}
|