iOS: absolutely center empty states across every tab
Android UI Tests / ui-tests (push) Has been cancelled
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:
@@ -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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user