iOS: unify empty states — one centered, leaf-decorated component
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>
This commit is contained in:
Trey T
2026-06-04 22:49:34 -05:00
parent db65db6232
commit 09120e9d9d
8 changed files with 175 additions and 387 deletions
@@ -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()
+6 -57
View File
@@ -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<T, Content: View, EmptyContent: View>: 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",
@@ -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(
@@ -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)
}
}
@@ -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(
@@ -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()
@@ -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
)
}
}
}
+25 -135
View File
@@ -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 {