Files
honeyDueKMP/iosApp/iosApp/Residence/ResidencesListView.swift
Trey t bcd8b36a9b Fix TokenStorage stale cache bug and add user-friendly error messages
- 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>
2025-12-17 11:48:35 -06:00

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 }
)
}
}