713c8d9cbb
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>
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
struct ResidencesListView: View {
|
|
@StateObject private var viewModel = ResidenceViewModel()
|
|
@StateObject private var taskViewModel = TaskViewModel()
|
|
@State private var showingAddResidence = false
|
|
@State private var showingJoinResidence = false
|
|
@State private var showingUpgradePrompt = false
|
|
@State private var showingSettings = false
|
|
@State private var pushTargetResidenceId: Int32?
|
|
@StateObject private var authManager = AuthenticationManager.shared
|
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Warm organic background
|
|
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,
|
|
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()
|
|
}
|
|
)
|
|
}
|
|
} else if viewModel.isLoading {
|
|
DefaultLoadingView()
|
|
} else if let error = viewModel.errorMessage {
|
|
DefaultErrorView(message: error, onRetry: {
|
|
viewModel.loadMyResidences()
|
|
})
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: {
|
|
showingSettings = true
|
|
}) {
|
|
OrganicToolbarButton(systemName: "gearshape.fill")
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
|
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
|
Button(action: {
|
|
// Check if we should show upgrade prompt before joining
|
|
let currentCount = viewModel.myResidences?.residences.count ?? 0
|
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
|
|
showingUpgradePrompt = true
|
|
} else {
|
|
showingJoinResidence = true
|
|
}
|
|
}) {
|
|
OrganicToolbarButton(systemName: "person.badge.plus")
|
|
}
|
|
.accessibilityLabel("Join a property")
|
|
|
|
Button(action: {
|
|
// Check if we should show upgrade prompt before adding
|
|
let currentCount = viewModel.myResidences?.residences.count ?? 0
|
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
|
|
showingUpgradePrompt = true
|
|
} else {
|
|
showingAddResidence = true
|
|
}
|
|
}) {
|
|
OrganicToolbarButton(systemName: "plus", isPrimary: true)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton)
|
|
.accessibilityLabel("Add new property")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAddResidence) {
|
|
AddResidenceView(
|
|
isPresented: $showingAddResidence,
|
|
onResidenceCreated: {
|
|
refreshWithTimeout()
|
|
}
|
|
)
|
|
}
|
|
.sheet(isPresented: $showingJoinResidence) {
|
|
JoinResidenceView(onJoined: {
|
|
refreshWithTimeout()
|
|
})
|
|
}
|
|
.sheet(isPresented: $showingUpgradePrompt) {
|
|
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
NavigationStack {
|
|
ProfileTabView()
|
|
}
|
|
}
|
|
.onAppear {
|
|
AnalyticsManager.shared.trackScreen(.residences)
|
|
if let pendingResidenceId = PushNotificationManager.shared.pendingNavigationResidenceId {
|
|
navigateToResidenceFromPush(residenceId: pendingResidenceId)
|
|
}
|
|
if authManager.isAuthenticated {
|
|
viewModel.loadMyResidences()
|
|
// Also load tasks to populate summary stats
|
|
taskViewModel.loadTasks()
|
|
}
|
|
}
|
|
// P-5: Removed redundant .onChange(of: scenePhase) handler.
|
|
// iOSApp.swift already handles foreground refresh globally, so per-view
|
|
// scenePhase handlers fire duplicate network requests.
|
|
.onChange(of: authManager.isAuthenticated) { _, isAuth in
|
|
if isAuth {
|
|
// User just logged in or registered - load their residences and tasks
|
|
viewModel.loadMyResidences()
|
|
taskViewModel.loadTasks()
|
|
} else {
|
|
// User logged out - clear data
|
|
viewModel.myResidences = nil
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
|
|
if let residenceId = notification.userInfo?["residenceId"] as? Int { // i18n-ignore: NotificationCenter userInfo key (non-UI)
|
|
navigateToResidenceFromPush(residenceId: residenceId)
|
|
}
|
|
}
|
|
.navigationDestination(item: $pushTargetResidenceId) { residenceId in
|
|
ResidenceDetailView(residenceId: residenceId)
|
|
}
|
|
}
|
|
|
|
/// Refresh residences with a 10-second timeout to prevent indefinite loading
|
|
private func refreshWithTimeout() {
|
|
viewModel.loadMyResidences(forceRefresh: true)
|
|
// Safety timeout: if the API hangs, clear loading state after 10 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
|
if viewModel.isLoading {
|
|
viewModel.isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func navigateToResidenceFromPush(residenceId: Int) {
|
|
pushTargetResidenceId = Int32(residenceId)
|
|
PushNotificationManager.shared.pendingNavigationResidenceId = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Toolbar Button
|
|
|
|
private struct OrganicToolbarButton: View {
|
|
let systemName: String
|
|
var isPrimary: Bool = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if isPrimary {
|
|
Circle()
|
|
.fill(Color.appPrimary)
|
|
.frame(width: 32, height: 32)
|
|
|
|
Image(systemName: systemName)
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
} else {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
|
|
Image(systemName: systemName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Residences Content View
|
|
|
|
private struct ResidencesContent: View {
|
|
let residences: [ResidenceResponse]
|
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
|
|
|
/// Compute total summary from DataManagerObservable (single source of truth)
|
|
private var computedSummary: TotalSummary {
|
|
let metrics = dataManager.totalTaskMetrics
|
|
return TotalSummary(
|
|
totalResidences: Int32(residences.count),
|
|
totalTasks: Int32(dataManager.activeTasks.count),
|
|
totalPending: 0,
|
|
totalOverdue: Int32(metrics.overdueCount),
|
|
tasksDueNextWeek: Int32(metrics.upcoming7Days),
|
|
tasksDueNextMonth: Int32(metrics.upcoming30Days)
|
|
)
|
|
}
|
|
|
|
/// Get task metrics for a specific residence from DataManagerObservable
|
|
private func taskMetrics(for residenceId: Int32) -> WidgetDataManager.TaskMetrics {
|
|
dataManager.taskMetrics(for: residenceId)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
// Summary Card with enhanced styling
|
|
SummaryCard(summary: computedSummary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
|
|
// Residences List with staggered animation
|
|
LazyVStack(spacing: 16) {
|
|
ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in
|
|
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
|
ResidenceCard(
|
|
residence: residence,
|
|
taskMetrics: taskMetrics(for: residence.id)
|
|
)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
.buttonStyle(OrganicCardButtonStyle())
|
|
.accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.cellPrefix).\(residence.id)")
|
|
.transition(.asymmetric(
|
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
|
removal: .opacity
|
|
))
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, OrganicSpacing.airy)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.residencesList)
|
|
.safeAreaInset(edge: .bottom) {
|
|
Color.clear.frame(height: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
ResidencesListView()
|
|
}
|
|
}
|