- Fix TokenStorage.getToken() returning stale cached token after login/logout - Add comprehensive ErrorMessageParser with 80+ error code mappings - Add Suite9 and Suite10 UI test files for E2E integration testing - Fix accessibility identifiers in RegisterView and ResidenceFormView - Fix UITestHelpers logout to target alert button specifically - Update various UI components with proper accessibility identifiers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
424 lines
15 KiB
Swift
424 lines
15 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
|
|
@StateObject private var authManager = AuthenticationManager.shared
|
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Warm organic background
|
|
WarmGradientBackground()
|
|
|
|
if let response = viewModel.myResidences {
|
|
ListAsyncContentView(
|
|
items: response.residences,
|
|
isLoading: viewModel.isLoading,
|
|
errorMessage: viewModel.errorMessage,
|
|
content: { residences in
|
|
ResidencesContent(
|
|
residences: residences,
|
|
tasksResponse: taskViewModel.tasksResponse
|
|
)
|
|
},
|
|
emptyContent: {
|
|
OrganicEmptyResidencesView()
|
|
},
|
|
onRefresh: {
|
|
viewModel.loadMyResidences(forceRefresh: true)
|
|
},
|
|
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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAddResidence) {
|
|
AddResidenceView(
|
|
isPresented: $showingAddResidence,
|
|
onResidenceCreated: {
|
|
viewModel.loadMyResidences(forceRefresh: true)
|
|
}
|
|
)
|
|
}
|
|
.sheet(isPresented: $showingJoinResidence) {
|
|
JoinResidenceView(onJoined: {
|
|
viewModel.loadMyResidences()
|
|
})
|
|
}
|
|
.sheet(isPresented: $showingUpgradePrompt) {
|
|
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
NavigationView {
|
|
ProfileTabView()
|
|
}
|
|
}
|
|
.onAppear {
|
|
PostHogAnalytics.shared.screen(AnalyticsEvents.residenceScreenShown)
|
|
if authManager.isAuthenticated {
|
|
viewModel.loadMyResidences()
|
|
// Also load tasks to populate summary stats
|
|
taskViewModel.loadTasks()
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { newPhase in
|
|
// Refresh data when app comes back from background
|
|
if newPhase == .active && authManager.isAuthenticated {
|
|
viewModel.loadMyResidences(forceRefresh: true)
|
|
taskViewModel.loadTasks(forceRefresh: true)
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $authManager.isAuthenticated.negated) {
|
|
LoginView(onLoginSuccess: {
|
|
authManager.isAuthenticated = true
|
|
viewModel.loadMyResidences()
|
|
taskViewModel.loadTasks()
|
|
})
|
|
.interactiveDismissDisabled()
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: - Residence Task Stats
|
|
struct ResidenceTaskStats {
|
|
let totalCount: Int
|
|
let overdueCount: Int
|
|
let dueThisWeekCount: Int
|
|
let dueNext30DaysCount: Int
|
|
}
|
|
|
|
// MARK: - Residences Content View
|
|
|
|
private struct ResidencesContent: View {
|
|
let residences: [ResidenceResponse]
|
|
let tasksResponse: TaskColumnsResponse?
|
|
|
|
/// Extract active tasks - skip completed_tasks and cancelled_tasks columns
|
|
private var activeTasks: [TaskResponse] {
|
|
guard let response = tasksResponse else { return [] }
|
|
|
|
var tasks: [TaskResponse] = []
|
|
for column in response.columns {
|
|
// Skip completed and cancelled columns (cancelled includes archived)
|
|
let columnName = column.name.lowercased()
|
|
if columnName == "completed_tasks" || columnName == "cancelled_tasks" {
|
|
continue
|
|
}
|
|
|
|
// Add tasks from this column
|
|
for task in column.tasks {
|
|
tasks.append(task)
|
|
}
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
/// Compute total summary from task data using date logic
|
|
private var computedSummary: TotalSummary {
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today
|
|
let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today
|
|
|
|
var overdueCount: Int32 = 0
|
|
var dueThisWeekCount: Int32 = 0
|
|
var dueNext30DaysCount: Int32 = 0
|
|
|
|
for task in activeTasks {
|
|
guard let dueDateStr = task.effectiveDueDate,
|
|
let dueDate = DateUtils.parseDate(dueDateStr) else {
|
|
continue
|
|
}
|
|
|
|
let taskDate = calendar.startOfDay(for: dueDate)
|
|
|
|
if taskDate < today {
|
|
overdueCount += 1
|
|
} else if taskDate <= in7Days {
|
|
dueThisWeekCount += 1
|
|
} else if taskDate <= in30Days {
|
|
dueNext30DaysCount += 1
|
|
}
|
|
}
|
|
|
|
return TotalSummary(
|
|
totalResidences: Int32(residences.count),
|
|
totalTasks: Int32(activeTasks.count),
|
|
totalPending: 0,
|
|
totalOverdue: overdueCount,
|
|
tasksDueNextWeek: dueThisWeekCount,
|
|
tasksDueNextMonth: dueNext30DaysCount
|
|
)
|
|
}
|
|
|
|
/// Get task stats for a specific residence
|
|
private func taskStats(for residenceId: Int32) -> ResidenceTaskStats {
|
|
let residenceTasks = activeTasks.filter { $0.residenceId == residenceId }
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today
|
|
let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today
|
|
|
|
var overdueCount = 0
|
|
var dueThisWeekCount = 0
|
|
var dueNext30DaysCount = 0
|
|
|
|
for task in residenceTasks {
|
|
guard let dueDateStr = task.effectiveDueDate,
|
|
let dueDate = DateUtils.parseDate(dueDateStr) else {
|
|
continue
|
|
}
|
|
|
|
let taskDate = calendar.startOfDay(for: dueDate)
|
|
|
|
if taskDate < today {
|
|
overdueCount += 1
|
|
} else if taskDate <= in7Days {
|
|
dueThisWeekCount += 1
|
|
} else if taskDate <= in30Days {
|
|
dueNext30DaysCount += 1
|
|
}
|
|
}
|
|
|
|
return ResidenceTaskStats(
|
|
totalCount: residenceTasks.count,
|
|
overdueCount: overdueCount,
|
|
dueThisWeekCount: dueThisWeekCount,
|
|
dueNext30DaysCount: dueNext30DaysCount
|
|
)
|
|
}
|
|
|
|
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,
|
|
taskStats: taskStats(for: residence.id)
|
|
)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
.buttonStyle(OrganicCardButtonStyle())
|
|
.transition(.asymmetric(
|
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
|
removal: .opacity
|
|
))
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, OrganicSpacing.airy)
|
|
}
|
|
.safeAreaInset(edge: .bottom) {
|
|
Color.clear.frame(height: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Card Button Style
|
|
|
|
private struct OrganicCardButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
|
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Empty Residences View
|
|
|
|
private struct OrganicEmptyResidencesView: View {
|
|
@State private var isAnimating = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
Spacer()
|
|
|
|
// Animated house illustration
|
|
ZStack {
|
|
// Background glow
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.appPrimary.opacity(0.15),
|
|
Color.appPrimary.opacity(0.05),
|
|
Color.clear
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 80
|
|
)
|
|
)
|
|
.frame(width: 160, height: 160)
|
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
|
.animation(
|
|
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
|
value: isAnimating
|
|
)
|
|
|
|
// House icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 100, height: 100)
|
|
|
|
Image("house_outline")
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 48, height: 48)
|
|
.foregroundColor(Color.appPrimary)
|
|
.offset(y: isAnimating ? -2 : 2)
|
|
.animation(
|
|
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
|
value: isAnimating
|
|
)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
Text("Welcome to Your Space")
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("Tap the + icon in the top right\nto add your first property")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineSpacing(4)
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
Spacer()
|
|
|
|
// Decorative footer elements
|
|
HStack(spacing: 40) {
|
|
FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary)
|
|
FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent)
|
|
FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary)
|
|
}
|
|
.opacity(0.6)
|
|
.padding(.bottom, 40)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onAppear {
|
|
isAnimating = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationView {
|
|
ResidencesListView()
|
|
}
|
|
}
|
|
|
|
extension Binding where Value == Bool {
|
|
var negated: Binding<Bool> {
|
|
Binding<Bool>(
|
|
get: { !self.wrappedValue },
|
|
set: { self.wrappedValue = !$0 }
|
|
)
|
|
}
|
|
}
|