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,10 +38,37 @@ struct ContractorsListView: View {
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 {
ZStack {
WarmGradientBackground()
if isTrueEmpty {
// True-empty: render only the empty state, dead-center of the
// full screen. No search bar, no filter chips, no offset.
Group {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
OrganicEmptyScreen(
icon: "person.2.fill",
title: L10n.Contractors.emptyTitle,
subtitle: L10n.Contractors.emptyNoFilters
)
} else {
UpgradeFeatureView(
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)
@@ -86,19 +113,16 @@ struct ContractorsListView: View {
)
},
emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
// 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
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)
@@ -112,6 +136,7 @@ struct ContractorsListView: View {
)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@@ -45,10 +45,31 @@ 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 {
ZStack {
WarmGradientBackground()
if isTrulyEmpty {
// Full-screen-centered empty state. No segmented control,
// search bar, filter chip, Spacers, or offset above it.
OrganicEmptyScreen(
icon: "doc.text.viewfinder",
title: L10n.Documents.noWarrantiesFound,
subtitle: L10n.Documents.noWarrantiesMessage
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(spacing: 0) {
// Segmented Control
OrganicSegmentedControl(selection: $selectedTab)
@@ -105,6 +126,7 @@ struct DocumentsWarrantiesView: View {
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@@ -18,6 +18,19 @@ struct ResidencesListView: View {
WarmGradientBackground()
if let response = viewModel.myResidences {
if response.residences.isEmpty && !viewModel.isLoading {
// Empty state: render the empty view ALONE, filling the full
// available area (inside NavigationStack/background, respecting
// safe area) so SwiftUI centers it dead-center of the screen
// identical to the other tabs. No header chrome, Spacers, or
// offsets that would bias the centering.
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)
} else {
ListAsyncContentView(
items: response.residences,
isLoading: viewModel.isLoading,
@@ -31,6 +44,7 @@ struct ResidencesListView: View {
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)
@@ -42,6 +56,7 @@ struct ResidencesListView: View {
viewModel.loadMyResidences()
}
)
}
} else if viewModel.isLoading {
DefaultLoadingView()
} else if let error = viewModel.errorMessage {
@@ -193,13 +193,34 @@ struct OrganicEmptyScreen: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@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 {
ZStack {
// Dead-centered placeholder content
VStack(spacing: OrganicSpacing.comfortable) {
// Anchor the icon/text boundary at the exact vertical center.
GeometryReader { geo in
let topHeight = max(0, geo.size.height / 2 - halfGap)
VStack(spacing: 0) {
// Icon bottom edge ends `halfGap` above the vertical center.
VStack(spacing: 0) {
Spacer(minLength: 0)
illustration
.accessibilityHidden(true)
}
.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))
@@ -222,12 +243,16 @@ struct OrganicEmptyScreen: View {
.padding(.vertical, 14)
.background(Capsule().fill(accentColor))
}
.padding(.top, 4)
.padding(.top, 16)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 32)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .top)
.accessibilityElement(children: .combine)
}
}
// Decorative three-leaf footer (consistent across every empty screen)
VStack {
+19 -3
View File
@@ -33,6 +33,13 @@ struct AllTasksView: View {
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
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 {
guard let response = tasksResponse,
let firstColumn = response.columns.first else { return false }
@@ -171,7 +178,9 @@ struct AllTasksView: View {
}
} else if let tasksResponse = tasksResponse {
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 {
OrganicEmptyScreen(
icon: "checklist",
@@ -186,6 +195,7 @@ struct AllTasksView: View {
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// No residences: the original action button was disabled and
// showed "Add a property first" guidance, so surface that copy
@@ -195,6 +205,7 @@ struct AllTasksView: View {
title: L10n.Tasks.noTasksYet,
subtitle: L10n.Tasks.addPropertyFirst
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
ScrollViewReader { proxy in
@@ -279,6 +290,11 @@ struct AllTasksView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
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) {
Button(action: {
loadAllTasks(forceRefresh: true)
@@ -289,7 +305,7 @@ struct AllTasksView: View {
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.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)
.accessibilityLabel("Refresh tasks")
@@ -302,7 +318,7 @@ struct AllTasksView: View {
}) {
OrganicToolbarAddButton()
}
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || showAddTask)
.disabled(!hasResidences || showAddTask)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
.accessibilityLabel("Add new task")
}