From 713c8d9cbbe0ce5ccfc0dc885144cc53379db716 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 6 Jun 2026 12:08:54 -0500 Subject: [PATCH] iOS: absolutely center empty states across every tab 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) --- .../EmptyStateScreenshotUITests.swift | 28 ++++ .../Contractor/ContractorsListView.swift | 145 ++++++++++-------- .../Documents/DocumentsWarrantiesView.swift | 124 +++++++++------ .../iosApp/Residence/ResidencesListView.swift | 61 +++++--- .../Components/SharedEmptyStateView.swift | 83 ++++++---- iosApp/iosApp/Task/AllTasksView.swift | 22 ++- 6 files changed, 297 insertions(+), 166 deletions(-) create mode 100644 iosApp/HoneyDueUITests/CrossCutting/EmptyStateScreenshotUITests.swift diff --git a/iosApp/HoneyDueUITests/CrossCutting/EmptyStateScreenshotUITests.swift b/iosApp/HoneyDueUITests/CrossCutting/EmptyStateScreenshotUITests.swift new file mode 100644 index 0000000..f2860ce --- /dev/null +++ b/iosApp/HoneyDueUITests/CrossCutting/EmptyStateScreenshotUITests.swift @@ -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) + } + } +} diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index f516800..4ab9fe2 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -38,78 +38,103 @@ 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() - 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 + 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 ) - }, - emptyContent: { - if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { + } 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) + .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 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() } - }, - onRefresh: { - viewModel.loadContractors(forceRefresh: true) - for await loading in viewModel.$isLoading.values { - if !loading { break } - } - }, - onRetry: { - loadContractors() - } - ) + ) + } } } .navigationBarTitleDisplayMode(.inline) diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 096d5ce..a693659 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -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 { ZStack { WarmGradientBackground() - VStack(spacing: 0) { - // Segmented Control - OrganicSegmentedControl(selection: $selectedTab) - .padding(.horizontal, 16) - .padding(.top, 8) - - // Search Bar - OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) - .padding(.horizontal, 16) - .padding(.top, 8) - - // Active Filters - if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) { - 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 } - ) - } - } + 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) .padding(.horizontal, 16) - } - .padding(.vertical, 8) - } + .padding(.top, 8) - // Content - if selectedTab == .warranties { - WarrantiesTabContent( - viewModel: documentViewModel, - searchText: searchText - ) - } else { - DocumentsTabContent( - viewModel: documentViewModel, - searchText: searchText - ) + // Search Bar + OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) + .padding(.horizontal, 16) + .padding(.top, 8) + + // Active Filters + if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) { + 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(.vertical, 8) + } + + // Content + if selectedTab == .warranties { + WarrantiesTabContent( + viewModel: documentViewModel, + searchText: searchText + ) + } else { + DocumentsTabContent( + viewModel: documentViewModel, + searchText: searchText + ) + } } } } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 58782c4..2b8b206 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -18,30 +18,45 @@ struct ResidencesListView: View { WarmGradientBackground() if let response = viewModel.myResidences { - ListAsyncContentView( - items: response.residences, - isLoading: viewModel.isLoading, - errorMessage: viewModel.errorMessage, - 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" - ) - }, - onRefresh: { - viewModel.loadMyResidences(forceRefresh: true) - for await loading in viewModel.$isLoading.values { - if !loading { break } + 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, + errorMessage: viewModel.errorMessage, + 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 { DefaultLoadingView() } else if let error = viewModel.errorMessage { diff --git a/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift b/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift index 54d4daf..9e392b7 100644 --- a/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift +++ b/iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift @@ -193,41 +193,66 @@ 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) { - illustration - .accessibilityHidden(true) + // Anchor the icon/text boundary at the exact vertical center. + GeometryReader { geo in + let topHeight = max(0, geo.size.height / 2 - halfGap) - 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)) + VStack(spacing: 0) { + // Icon — bottom edge ends `halfGap` above the vertical center. + VStack(spacing: 0) { + Spacer(minLength: 0) + illustration + .accessibilityHidden(true) } - .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) VStack { diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 42ea574..1751f18 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -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") }