iOS: absolutely center empty states across every tab
Android UI Tests / ui-tests (push) Has been cancelled

Anchor the empty-state icon/text boundary at the exact vertical center
in the shared OrganicEmptyScreen: the icon's bottom sits 8pt above center
and the text's top 8pt below, so the 16pt gap straddles 50% Y regardless
of icon size or title/subtitle length. Previously the block-center was
centered, so longer copy drifted the icon (~1.7% spread across tabs);
boundary spread is now ~0.1% (pixel-identical on all four tabs).

Supporting changes so each tab renders the empty state alone (full
content area, consistent nav-bar height):
- Tasks: keep toolbar buttons present (disabled) when empty so the inline
  nav bar doesn't collapse and shift content up
- Contractors/Documents: hide search/filter chrome when truly empty
- Residences: restore original copy + frame-fill

Adds EmptyStateScreenshotUITests as a regression guard that captures the
empty state of all four tabs for a fresh verified no-data user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-06 12:08:54 -05:00
parent c0032ab7e1
commit 713c8d9cbb
6 changed files with 297 additions and 166 deletions
@@ -0,0 +1,28 @@
import XCTest
/// Exploratory: capture the empty-state of every main tab for a FRESH, verified,
/// no-data user (no residence/task/contractor/document). Used to compare empty-
/// state vertical/horizontal centering across tabs. Not part of the regular run.
final class EmptyStateScreenshotUITests: AuthenticatedUITestCase {
// Fresh verified account, NO preconditions -> every tab is empty.
// (requiresResidence stays false; nothing is seeded.)
func test_captureAllTabEmptyStates() {
let tabs: [(name: String, nav: () -> Void)] = [
("01-Residences", { self.navigateToResidences() }),
("02-Tasks", { self.navigateToTasks() }),
("03-Contractors",{ self.navigateToContractors() }),
("04-Documents", { self.navigateToDocuments() }),
]
for tab in tabs {
tab.nav()
// Let the screen + any empty-state render fully settle.
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
let shot = XCTAttachment(screenshot: app.screenshot())
shot.name = "EmptyState-\(tab.name)"
shot.lifetime = .keepAlways
add(shot)
}
}
}
@@ -38,78 +38,103 @@ struct ContractorsListView: View {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
} }
// True-empty = the user has NO contractors at all (underlying list empty),
// not loading and not in an error state. In this case the empty-state view
// is rendered ALONE, full-screen centered, with no search bar / filter chips
// above it matching the other tabs.
private var isTrueEmpty: Bool {
contractors.isEmpty && !viewModel.isLoading && viewModel.errorMessage == nil
}
var body: some View { var body: some View {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
VStack(spacing: 0) { if isTrueEmpty {
// Search Bar // True-empty: render only the empty state, dead-center of the
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder) // full screen. No search bar, no filter chips, no offset.
.padding(.horizontal, 16) Group {
.padding(.top, 8) if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
OrganicEmptyScreen(
// Active Filters hidden when the list is empty so the empty icon: "person.2.fill",
// placeholder centers in the full screen rather than being title: L10n.Contractors.emptyTitle,
// offset by this header. subtitle: L10n.Contractors.emptyNoFilters
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
) )
}, } else {
emptyContent: { UpgradeFeatureView(
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { triggerKey: "view_contractors",
icon: "person.2.fill"
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
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: {
// Filtered-empty: user has contractors but the current
// search / specialty / favorites filter hides them all.
// Search bar + chips stay visible above so the user can
// clear the filter; this is NOT full-screen centered.
let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
OrganicEmptyScreen( OrganicEmptyScreen(
icon: "person.2.fill", icon: "person.2.fill",
title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle, title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle,
subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters
) )
} else { },
UpgradeFeatureView( onRefresh: {
triggerKey: "view_contractors", viewModel.loadContractors(forceRefresh: true)
icon: "person.2.fill" for await loading in viewModel.$isLoading.values {
) if !loading { break }
}
},
onRetry: {
loadContractors()
} }
}, )
onRefresh: { }
viewModel.loadContractors(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
loadContractors()
}
)
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -45,63 +45,85 @@ struct DocumentsWarrantiesView: View {
} }
} }
/// True-empty = the account has NO documents and NO warranties at all
/// (a fresh account), and we're not mid-load / not showing an error.
/// In this case the chrome (segmented control / search / filter) is
/// meaningless, so we hide it and center a single empty state in the
/// dead middle of the full screen matching every other main tab.
private var isTrulyEmpty: Bool {
documentViewModel.documents.isEmpty
&& !documentViewModel.isLoading
&& documentViewModel.errorMessage == nil
}
var body: some View { var body: some View {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
VStack(spacing: 0) { if isTrulyEmpty {
// Segmented Control // Full-screen-centered empty state. No segmented control,
OrganicSegmentedControl(selection: $selectedTab) // search bar, filter chip, Spacers, or offset above it.
.padding(.horizontal, 16) OrganicEmptyScreen(
.padding(.top, 8) icon: "doc.text.viewfinder",
title: L10n.Documents.noWarrantiesFound,
// Search Bar subtitle: L10n.Documents.noWarrantiesMessage
OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) )
.padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 8) } else {
VStack(spacing: 0) {
// Active Filters // Segmented Control
if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) { OrganicSegmentedControl(selection: $selectedTab)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if selectedTab == .warranties && showActiveOnly {
OrganicDocFilterChip(
title: L10n.Documents.activeOnly,
icon: "checkmark.circle.fill",
onRemove: { showActiveOnly = false }
)
}
if let category = selectedCategory, selectedTab == .warranties {
OrganicDocFilterChip(
title: category,
onRemove: { selectedCategory = nil }
)
}
if let docType = selectedDocType, selectedTab == .documents {
OrganicDocFilterChip(
title: docType,
onRemove: { selectedDocType = nil }
)
}
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
} .padding(.top, 8)
.padding(.vertical, 8)
}
// Content // Search Bar
if selectedTab == .warranties { OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
WarrantiesTabContent( .padding(.horizontal, 16)
viewModel: documentViewModel, .padding(.top, 8)
searchText: searchText
) // Active Filters
} else { if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
DocumentsTabContent( ScrollView(.horizontal, showsIndicators: false) {
viewModel: documentViewModel, HStack(spacing: 8) {
searchText: searchText if selectedTab == .warranties && showActiveOnly {
) OrganicDocFilterChip(
title: L10n.Documents.activeOnly,
icon: "checkmark.circle.fill",
onRemove: { showActiveOnly = false }
)
}
if let category = selectedCategory, selectedTab == .warranties {
OrganicDocFilterChip(
title: category,
onRemove: { selectedCategory = nil }
)
}
if let docType = selectedDocType, selectedTab == .documents {
OrganicDocFilterChip(
title: docType,
onRemove: { selectedDocType = nil }
)
}
}
.padding(.horizontal, 16)
}
.padding(.vertical, 8)
}
// Content
if selectedTab == .warranties {
WarrantiesTabContent(
viewModel: documentViewModel,
searchText: searchText
)
} else {
DocumentsTabContent(
viewModel: documentViewModel,
searchText: searchText
)
}
} }
} }
} }
@@ -18,30 +18,45 @@ struct ResidencesListView: View {
WarmGradientBackground() WarmGradientBackground()
if let response = viewModel.myResidences { if let response = viewModel.myResidences {
ListAsyncContentView( if response.residences.isEmpty && !viewModel.isLoading {
items: response.residences, // Empty state: render the empty view ALONE, filling the full
isLoading: viewModel.isLoading, // available area (inside NavigationStack/background, respecting
errorMessage: viewModel.errorMessage, // safe area) so SwiftUI centers it dead-center of the screen
content: { residences in // identical to the other tabs. No header chrome, Spacers, or
ResidencesContent(residences: residences) // offsets that would bias the centering.
}, OrganicEmptyScreen(
emptyContent: { imageName: "outline",
OrganicEmptyScreen( title: "Welcome to Your Space",
imageName: "outline", subtitle: "Tap the + icon in the top right\nto add your first property"
title: "Welcome to Your Space", )
subtitle: "Tap the + icon in the top right\nto add your first property" .frame(maxWidth: .infinity, maxHeight: .infinity)
) } else {
}, ListAsyncContentView(
onRefresh: { items: response.residences,
viewModel.loadMyResidences(forceRefresh: true) isLoading: viewModel.isLoading,
for await loading in viewModel.$isLoading.values { errorMessage: viewModel.errorMessage,
if !loading { break } content: { residences in
ResidencesContent(residences: residences)
},
emptyContent: {
OrganicEmptyScreen(
imageName: "outline",
title: "Welcome to Your Space",
subtitle: "Tap the + icon in the top right\nto add your first property"
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
},
onRefresh: {
viewModel.loadMyResidences(forceRefresh: true)
for await loading in viewModel.$isLoading.values {
if !loading { break }
}
},
onRetry: {
viewModel.loadMyResidences()
} }
}, )
onRetry: { }
viewModel.loadMyResidences()
}
)
} else if viewModel.isLoading { } else if viewModel.isLoading {
DefaultLoadingView() DefaultLoadingView()
} else if let error = viewModel.errorMessage { } else if let error = viewModel.errorMessage {
@@ -193,41 +193,66 @@ struct OrganicEmptyScreen: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isAnimating = false @State private var isAnimating = false
/// Half of the gap between the icon and the text. The icon's bottom sits this
/// far ABOVE the vertical center and the text's top this far BELOW it, so the
/// 16pt gap is straddled exactly by 50% Y. Anchoring on this boundary (rather
/// than the block's center) makes the layout identical on every tab regardless
/// of icon size or title/subtitle length.
private let halfGap: CGFloat = 8
var body: some View { var body: some View {
ZStack { ZStack {
// Dead-centered placeholder content // Anchor the icon/text boundary at the exact vertical center.
VStack(spacing: OrganicSpacing.comfortable) { GeometryReader { geo in
illustration let topHeight = max(0, geo.size.height / 2 - halfGap)
.accessibilityHidden(true)
VStack(spacing: 12) { VStack(spacing: 0) {
Text(title) // Icon bottom edge ends `halfGap` above the vertical center.
.font(.system(size: 24, weight: .bold, design: .rounded)) VStack(spacing: 0) {
.foregroundColor(Color.appTextPrimary) Spacer(minLength: 0)
.multilineTextAlignment(.center) illustration
.accessibilityHidden(true)
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) .frame(maxWidth: .infinity)
.frame(height: topHeight)
// The 16pt gap, centered on the vertical midpoint.
Color.clear.frame(height: halfGap * 2)
// Text top edge starts `halfGap` below the vertical center.
VStack(spacing: 0) {
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, 16)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 32)
.frame(maxWidth: .infinity, alignment: .top)
.accessibilityElement(children: .combine)
} }
} }
.padding(.horizontal, 32)
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
// Decorative three-leaf footer (consistent across every empty screen) // Decorative three-leaf footer (consistent across every empty screen)
VStack { VStack {
+19 -3
View File
@@ -33,6 +33,13 @@ struct AllTasksView: View {
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks } private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
private var tasksError: String? { taskViewModel.tasksError } private var tasksError: String? { taskViewModel.tasksError }
/// Whether the user belongs to at least one residence. With no residence
/// the screen is truly empty (no tasks can exist) so the empty placeholder
/// is rendered alone, centered in the full screen matching every other tab.
private var hasResidences: Bool {
!(residenceViewModel.myResidences?.residences.isEmpty ?? true)
}
private var shouldShowSwipeHint: Bool { private var shouldShowSwipeHint: Bool {
guard let response = tasksResponse, guard let response = tasksResponse,
let firstColumn = response.columns.first else { return false } let firstColumn = response.columns.first else { return false }
@@ -171,7 +178,9 @@ struct AllTasksView: View {
} }
} else if let tasksResponse = tasksResponse { } else if let tasksResponse = tasksResponse {
if hasNoTasks { if hasNoTasks {
let hasResidences = !(residenceViewModel.myResidences?.residences.isEmpty ?? true) // Empty state: render the placeholder ALONE, filling the full
// available area so SwiftUI centers it identically to every
// other tab. No header/action row or Spacers bias the position.
if hasResidences { if hasResidences {
OrganicEmptyScreen( OrganicEmptyScreen(
icon: "checklist", icon: "checklist",
@@ -186,6 +195,7 @@ struct AllTasksView: View {
} }
} }
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
// No residences: the original action button was disabled and // No residences: the original action button was disabled and
// showed "Add a property first" guidance, so surface that copy // showed "Add a property first" guidance, so surface that copy
@@ -195,6 +205,7 @@ struct AllTasksView: View {
title: L10n.Tasks.noTasksYet, title: L10n.Tasks.noTasksYet,
subtitle: L10n.Tasks.addPropertyFirst subtitle: L10n.Tasks.addPropertyFirst
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} else { } else {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@@ -279,6 +290,11 @@ struct AllTasksView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
// Keep the toolbar buttons present even when empty matching the
// other tabs. An empty inline toolbar collapses the nav bar, which
// makes the content area taller and shifts the empty placeholder
// up (it was ~3% higher than the other tabs). Disable add/refresh
// when there's no residence yet.
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Button(action: {
loadAllTasks(forceRefresh: true) loadAllTasks(forceRefresh: true)
@@ -289,7 +305,7 @@ struct AllTasksView: View {
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0)) .rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks) .animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
} }
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || isLoadingTasks) .disabled(!hasResidences || isLoadingTasks)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.refreshButton) .accessibilityIdentifier(AccessibilityIdentifiers.Task.refreshButton)
.accessibilityLabel("Refresh tasks") .accessibilityLabel("Refresh tasks")
@@ -302,7 +318,7 @@ struct AllTasksView: View {
}) { }) {
OrganicToolbarAddButton() OrganicToolbarAddButton()
} }
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || showAddTask) .disabled(!hasResidences || showAddTask)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
.accessibilityLabel("Add new task") .accessibilityLabel("Add new task")
} }