From 09120e9d9d171651ca7a11a8c0205721c71ba359 Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 4 Jun 2026 22:49:34 -0500 Subject: [PATCH] =?UTF-8?q?iOS:=20unify=20empty=20states=20=E2=80=94=20one?= =?UTF-8?q?=20centered,=20leaf-decorated=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Contractor/ContractorsListView.swift | 77 +-------- iosApp/iosApp/Core/AsyncContentView.swift | 63 +------ .../Components/DocumentsTabContent.swift | 4 +- .../Documents/Components/EmptyStateView.swift | 31 ---- .../Components/WarrantiesTabContent.swift | 4 +- .../iosApp/Residence/ResidencesListView.swift | 97 +---------- .../Components/SharedEmptyStateView.swift | 126 ++++++++++++++ iosApp/iosApp/Task/AllTasksView.swift | 160 +++--------------- 8 files changed, 175 insertions(+), 387 deletions(-) delete mode 100644 iosApp/iosApp/Documents/Components/EmptyStateView.swift diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 7c09db2..f516800 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -48,8 +48,10 @@ struct ContractorsListView: View { .padding(.horizontal, 16) .padding(.top, 8) - // Active Filters - if showFavoritesOnly || selectedSpecialty != nil { + // 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 { @@ -85,8 +87,11 @@ struct ContractorsListView: View { }, emptyContent: { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { - OrganicEmptyContractorsView( - hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty + 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( @@ -414,70 +419,6 @@ private struct OrganicToolbarButton: View { } } -// MARK: - Organic Empty Contractors View - -private struct OrganicEmptyContractorsView: View { - let hasFilters: Bool - @State private var isAnimating = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - VStack(spacing: OrganicSpacing.comfortable) { - Spacer() - - ZStack { - Circle() - .fill( - RadialGradient( - colors: [ - Color.appPrimary.opacity(0.15), - Color.appPrimary.opacity(0.05), - Color.clear - ], - center: .center, - startRadius: 0, - endRadius: 60 - ) - ) - .frame(width: 120, height: 120) - .scaleEffect(isAnimating ? 1.1 : 1.0) - .animation( - isAnimating && !reduceMotion - ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) - - Image(systemName: "person.badge.plus") - .font(.system(size: 44, weight: .medium)) - .foregroundColor(Color.appPrimary) - } - - VStack(spacing: 8) { - Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle) - .font(.system(size: 20, weight: .bold, design: .rounded)) - .foregroundColor(Color.appTextPrimary) - - if !hasFilters { - Text(L10n.Contractors.emptyNoFilters) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - } - - Spacer() - } - .padding(24) - .onAppear { - isAnimating = true - } - .onDisappear { - isAnimating = false - } - } -} - #Preview { NavigationView { ContractorsListView() diff --git a/iosApp/iosApp/Core/AsyncContentView.swift b/iosApp/iosApp/Core/AsyncContentView.swift index 49686bb..777e28a 100644 --- a/iosApp/iosApp/Core/AsyncContentView.swift +++ b/iosApp/iosApp/Core/AsyncContentView.swift @@ -137,59 +137,6 @@ struct DefaultErrorView: View { } } -// MARK: - Async Empty State View - -struct AsyncEmptyStateView: View { - let icon: String - let title: String - let subtitle: String? - let actionLabel: String? - let action: (() -> Void)? - - init( - icon: String, - title: String, - subtitle: String? = nil, - actionLabel: String? = nil, - action: (() -> Void)? = nil - ) { - self.icon = icon - self.title = title - self.subtitle = subtitle - self.actionLabel = actionLabel - self.action = action - } - - var body: some View { - VStack(spacing: 16) { - Image(systemName: icon) - .font(.system(size: 60)) - .foregroundColor(Color.appTextSecondary.opacity(0.5)) - - Text(title) - .font(.headline) - .foregroundColor(Color.appTextPrimary) - - if let subtitle = subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - - if let actionLabel = actionLabel, let action = action { - Button(action: action) { - Text(actionLabel) - } - .buttonStyle(.borderedProminent) - .tint(Color.appPrimary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } -} - // MARK: - List Async Content View /// Specialized async content view for lists with pull-to-refresh support @@ -227,15 +174,17 @@ struct ListAsyncContentView: View { GeometryReader { geometry in ScrollView { DefaultErrorView(message: errorMessage, onRetry: onRetry) - .frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } } else if items.isEmpty && !isLoading { - // Wrap in ScrollView for pull-to-refresh support + // Wrap in ScrollView for pull-to-refresh support. Use the FULL + // height so the empty content centers in the dead middle of the + // screen (not the top 60%). GeometryReader { geometry in ScrollView { emptyContent() - .frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } } else { @@ -280,7 +229,7 @@ struct AsyncContentView_Previews: PreviewProvider { ) .previewDisplayName("Error") - AsyncEmptyStateView( + OrganicEmptyScreen( icon: "tray", title: "No Items", subtitle: "Add your first item to get started", diff --git a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift index 7b9a2ab..3599050 100644 --- a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift +++ b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift @@ -26,10 +26,10 @@ struct DocumentsTabContent: View { }, emptyContent: { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") { - EmptyStateView( + OrganicEmptyScreen( icon: "doc", title: L10n.Documents.noDocumentsFound, - message: L10n.Documents.noDocumentsMessage + subtitle: L10n.Documents.noDocumentsMessage ) } else { UpgradeFeatureView( diff --git a/iosApp/iosApp/Documents/Components/EmptyStateView.swift b/iosApp/iosApp/Documents/Components/EmptyStateView.swift deleted file mode 100644 index a6a1614..0000000 --- a/iosApp/iosApp/Documents/Components/EmptyStateView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import SwiftUI - -struct EmptyStateView: View { - let icon: String - let title: String - let message: String - - var body: some View { - VStack(spacing: OrganicSpacing.cozy) { - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.08)) - .frame(width: 100, height: 100) - - Image(systemName: icon) - .font(.system(size: 44, weight: .medium)) - .foregroundColor(Color.appPrimary.opacity(0.6)) - } - - Text(title) - .font(.system(size: 18, weight: .semibold, design: .rounded)) - .foregroundColor(Color.appTextPrimary) - - Text(message) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - .padding(OrganicSpacing.comfortable) - } -} diff --git a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift index ccfae67..da54f30 100644 --- a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift +++ b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift @@ -28,10 +28,10 @@ struct WarrantiesTabContent: View { }, emptyContent: { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") { - EmptyStateView( + OrganicEmptyScreen( icon: "doc.text.viewfinder", title: L10n.Documents.noWarrantiesFound, - message: L10n.Documents.noWarrantiesMessage + subtitle: L10n.Documents.noWarrantiesMessage ) } else { UpgradeFeatureView( diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 13a8019..58782c4 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -26,7 +26,11 @@ struct ResidencesListView: View { ResidencesContent(residences: residences) }, emptyContent: { - OrganicEmptyResidencesView() + OrganicEmptyScreen( + imageName: "outline", + title: "Welcome to Your Space", + subtitle: "Tap the + icon in the top right\nto add your first property" + ) }, onRefresh: { viewModel.loadMyResidences(forceRefresh: true) @@ -248,97 +252,6 @@ private struct ResidencesContent: View { } } -// MARK: - Organic Empty Residences View - -private struct OrganicEmptyResidencesView: View { - @State private var isAnimating = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - VStack(spacing: OrganicSpacing.comfortable) { - Spacer() - - // Animated house illustration - ZStack { - // Background glow - Circle() - .fill( - RadialGradient( - colors: [ - Color.appPrimary.opacity(0.15), - Color.appPrimary.opacity(0.05), - Color.clear - ], - center: .center, - startRadius: 0, - endRadius: 80 - ) - ) - .frame(width: 160, height: 160) - .scaleEffect(isAnimating ? 1.1 : 1.0) - .animation( - isAnimating && !reduceMotion - ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) - - // House icon - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 100, height: 100) - - Image("outline") - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 48, height: 48) - .foregroundColor(Color.appPrimary) - .offset(y: isAnimating ? -2 : 2) - .animation( - isAnimating && !reduceMotion - ? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) - } - } - - VStack(spacing: 12) { - Text("Welcome to Your Space") - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(Color.appTextPrimary) - - Text("Tap the + icon in the top right\nto add your first property") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - .padding(.top, 8) - - Spacer() - - // Decorative footer elements - HStack(spacing: 40) { - FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary) - FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent) - FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary) - } - .opacity(0.6) - .padding(.bottom, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - isAnimating = true - } - .onDisappear { - isAnimating = false - } - } -} - #Preview { NavigationStack { ResidencesListView() diff --git a/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift b/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift index 8982fb0..54d4daf 100644 --- a/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift +++ b/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift @@ -170,3 +170,129 @@ struct ListEmptyState: View { .accessibilityElement(children: .combine) } } + +// MARK: - Canonical Full-Screen Empty State +// +// The single empty-state used by every screen. It fills its container and +// places the icon + title + subtitle + optional action in the DEAD CENTER +// (both axes), with the three animated FloatingLeaf as a bottom footer. +// Center it on the screen by giving it the full screen area (e.g. as the +// sole child of a screen-filling ZStack, or via a maxHeight: .infinity +// container) so it is unaffected by any header/toolbar above it. +struct OrganicEmptyScreen: View { + /// SF Symbol name (used when `imageName` is nil). + var icon: String? = nil + /// Custom asset-catalog image (template-rendered); takes precedence over `icon`. + var imageName: String? = nil + let title: String + let subtitle: String + var actionLabel: String? = nil + var action: (() -> Void)? = nil + var accentColor: Color = Color.appPrimary + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isAnimating = false + + var body: some View { + ZStack { + // Dead-centered placeholder content + VStack(spacing: OrganicSpacing.comfortable) { + illustration + .accessibilityHidden(true) + + VStack(spacing: 12) { + Text(title) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text(subtitle) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + if let actionLabel = actionLabel, let action = action { + Button(action: action) { + Text(actionLabel) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextOnPrimary) + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(Capsule().fill(accentColor)) + } + .padding(.top, 4) + } + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + + // Decorative three-leaf footer (consistent across every empty screen) + VStack { + Spacer() + HStack(spacing: 40) { + FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary) + FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent) + FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary) + } + .opacity(0.6) + .padding(.bottom, 40) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { isAnimating = true } + .onDisappear { isAnimating = false } + } + + @ViewBuilder + private var illustration: some View { + ZStack { + // Background glow + Circle() + .fill( + RadialGradient( + colors: [accentColor.opacity(0.15), accentColor.opacity(0.05), Color.clear], + center: .center, startRadius: 0, endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .scaleEffect(isAnimating && !reduceMotion ? 1.08 : 1.0) + .animation( + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) + : .default, + value: isAnimating + ) + + // Icon disc + ZStack { + Circle() + .fill(accentColor.opacity(0.1)) + .frame(width: 100, height: 100) + + if let imageName = imageName { + Image(imageName) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + .foregroundColor(accentColor) + } else { + Image(systemName: icon ?? "tray") + .font(.system(size: 40, weight: .medium)) + .foregroundColor(accentColor) + } + } + .offset(y: isAnimating && !reduceMotion ? -2 : 2) + .animation( + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true) + : .default, + value: isAnimating + ) + } + } +} diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 283ded4..42ea574 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -171,13 +171,31 @@ struct AllTasksView: View { } } else if let tasksResponse = tasksResponse { if hasNoTasks { - OrganicEmptyTasksView( - totalTaskCount: totalTaskCount, - hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true), - subscriptionCache: subscriptionCache, - showingUpgradePrompt: $showingUpgradePrompt, - showAddTask: $showAddTask - ) + let hasResidences = !(residenceViewModel.myResidences?.residences.isEmpty ?? true) + if hasResidences { + OrganicEmptyScreen( + icon: "checklist", + title: L10n.Tasks.noTasksYet, + subtitle: L10n.Tasks.createFirst, + actionLabel: L10n.Tasks.addButton, + action: { + if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { + showingUpgradePrompt = true + } else { + showAddTask = true + } + } + ) + } else { + // No residences: the original action button was disabled and + // showed "Add a property first" guidance, so surface that copy + // as the subtitle with no actionable button. + OrganicEmptyScreen( + icon: "checklist", + title: L10n.Tasks.noTasksYet, + subtitle: L10n.Tasks.addPropertyFirst + ) + } } else { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { @@ -353,134 +371,6 @@ struct AllTasksView: View { } } -// MARK: - Organic Empty Tasks View - -private struct OrganicEmptyTasksView: View { - let totalTaskCount: Int - let hasResidences: Bool - let subscriptionCache: SubscriptionCacheWrapper - @Binding var showingUpgradePrompt: Bool - @Binding var showAddTask: Bool - @State private var isAnimating = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - VStack(spacing: OrganicSpacing.comfortable) { - Spacer() - - ZStack { - Circle() - .fill( - RadialGradient( - colors: [ - Color.appPrimary.opacity(0.15), - Color.appPrimary.opacity(0.05), - Color.clear - ], - center: .center, - startRadius: 0, - endRadius: 80 - ) - ) - .frame(width: 160, height: 160) - .scaleEffect(isAnimating ? 1.1 : 1.0) - .animation( - isAnimating && !reduceMotion - ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) - - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 100, height: 100) - - Image(systemName: "checklist") - .font(.system(size: 44, weight: .medium)) - .foregroundColor(Color.appPrimary) - .offset(y: isAnimating ? -2 : 2) - .animation( - isAnimating && !reduceMotion - ? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true) - : .default, - value: isAnimating - ) - } - } - - VStack(spacing: 12) { - Text(L10n.Tasks.noTasksYet) - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(Color.appTextPrimary) - - Text(L10n.Tasks.createFirst) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - .padding(.top, 8) - - Button(action: { - if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { - showingUpgradePrompt = true - } else { - showAddTask = true - } - }) { - HStack(spacing: 10) { - Image(systemName: "plus") - .font(.system(size: 16, weight: .bold)) - Text(L10n.Tasks.addButton) - .font(.system(size: 17, weight: .semibold)) - } - .foregroundColor(hasResidences ? Color.appTextOnPrimary : Color.appTextSecondary) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background( - hasResidences - ? AnyShapeStyle(LinearGradient( - colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], - startPoint: .topLeading, - endPoint: .bottomTrailing - )) - : AnyShapeStyle(Color.appTextSecondary.opacity(0.3)) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(color: hasResidences ? Color.appPrimary.opacity(0.3) : Color.clear, radius: 12, y: 6) - } - .disabled(!hasResidences) - .padding(.horizontal, 48) - .padding(.top, 16) - .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) - - if !hasResidences { - Text(L10n.Tasks.addPropertyFirst) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Color.appError) - } - - Spacer() - - HStack(spacing: 40) { - FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary) - FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent) - FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary) - } - .opacity(0.6) - .padding(.bottom, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - isAnimating = true - } - .onDisappear { - isAnimating = false - } - } -} - // MARK: - Organic Toolbar Add Button private struct OrganicToolbarAddButton: View {