Fix task stats consistency and improve residence card UI
- Compute task stats locally from kanban data for both summary card and residence cards - Filter out completed_tasks and cancelled_tasks columns from counts - Use startOfDay for accurate date comparisons (overdue, due this week, next 30 days) - Add parseDate helper to DateUtils - Make address tappable to open in Apple Maps - Remove navigation title from residences list - Update CLAUDE.md with Go backend references and DataManager architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
129
CLAUDE.md
129
CLAUDE.md
@@ -10,12 +10,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
MyCrib is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Django REST Framework API (located in the sibling `myCribAPI` directory).
|
||||
MyCrib (Casera) is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Go REST API with PostgreSQL (located in the sibling `myCribAPI-go` directory).
|
||||
|
||||
**Tech Stack:**
|
||||
- **Shared (Kotlin)**: Compose Multiplatform for Android, networking layer, ViewModels, models
|
||||
- **iOS**: SwiftUI with Kotlin shared layer integration via SKIE
|
||||
- **Backend**: Django REST Framework (separate repository at `../myCribAPI`)
|
||||
- **Backend**: Go REST API with PostgreSQL (separate directory at `../myCribAPI-go`)
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -56,42 +56,64 @@ open iosApp/iosApp.xcodeproj
|
||||
|
||||
## Architecture
|
||||
|
||||
### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/mycrib/`)
|
||||
### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/casera/`)
|
||||
|
||||
**Core Components:**
|
||||
|
||||
1. **APILayer** (`network/APILayer.kt`)
|
||||
- **Single entry point for all API calls**
|
||||
- Manages caching via DataCache
|
||||
- Handles automatic cache updates on mutations (create/update/delete)
|
||||
- Pattern: Cache-first reads with optional `forceRefresh` parameter
|
||||
- Returns `ApiResult<T>` (Success/Error/Loading states)
|
||||
1. **DataManager** (`data/DataManager.kt`) - **Single Source of Truth**
|
||||
- Unified cache for ALL app data (auth, residences, tasks, lookups, etc.)
|
||||
- All data is exposed via `StateFlow` for reactive UI updates
|
||||
- Automatic cache timeout validation (1 hour default)
|
||||
- Persists data to disk for offline access
|
||||
- Platform-specific initialization (TokenManager, ThemeStorage, PersistenceManager)
|
||||
- O(1) lookup helpers: `getTaskPriority(id)`, `getTaskCategory(id)`, etc.
|
||||
|
||||
2. **DataCache** (`cache/DataCache.kt`)
|
||||
- In-memory cache for lookup data (residence types, task categories, priorities, statuses, etc.)
|
||||
- Must be initialized via `APILayer.initializeLookups()` after login
|
||||
- Stores `MutableState` objects that UI can observe directly
|
||||
- Cleared on logout
|
||||
2. **APILayer** (`network/APILayer.kt`) - **Single Entry Point for Network Calls**
|
||||
- Every API response immediately updates DataManager
|
||||
- All screens observe DataManager StateFlows directly
|
||||
- Handles cache-first reads with `forceRefresh` parameter
|
||||
- ETag-based conditional fetching for lookups (304 Not Modified support)
|
||||
- Guards against concurrent initialization/prefetch calls
|
||||
- Returns `ApiResult<T>` (Success/Error/Loading/Idle states)
|
||||
|
||||
3. **TokenStorage** (`storage/TokenStorage.kt`)
|
||||
- Platform-specific secure token storage
|
||||
- Android: EncryptedSharedPreferences
|
||||
- iOS: Keychain
|
||||
- All API calls automatically include token from TokenStorage
|
||||
3. **API Clients** (`network/*Api.kt`)
|
||||
- Domain-specific API clients: `ResidenceApi`, `TaskApi`, `ContractorApi`, etc.
|
||||
- Low-level HTTP calls using Ktor
|
||||
- Error parsing and response handling
|
||||
|
||||
4. **ViewModels** (`viewmodel/`)
|
||||
- Shared ViewModels expose StateFlow for UI observation
|
||||
- Pattern: ViewModel calls APILayer → APILayer manages cache + network → ViewModel emits ApiResult states
|
||||
- ViewModels: `ResidenceViewModel`, `TaskViewModel`, `AuthViewModel`, `ContractorViewModel`, etc.
|
||||
4. **PersistenceManager** (`data/PersistenceManager.kt`)
|
||||
- Platform-specific disk persistence (expect/actual pattern)
|
||||
- Stores serialized JSON for offline access
|
||||
- Loads cached data on app startup
|
||||
|
||||
5. **Navigation** (`navigation/`)
|
||||
- Type-safe navigation using kotlinx.serialization
|
||||
- Routes defined as `@Serializable` data classes
|
||||
- Shared between Android Compose Navigation
|
||||
5. **ViewModels** (`viewmodel/`)
|
||||
- Thin wrappers that call APILayer methods
|
||||
- Expose loading/error states for UI feedback
|
||||
- ViewModels: `ResidenceViewModel`, `TaskViewModel`, `AuthViewModel`, etc.
|
||||
|
||||
**Data Flow:**
|
||||
```
|
||||
UI → ViewModel → APILayer → (Cache Check) → Network API → Update Cache → Return to ViewModel → UI observes StateFlow
|
||||
User Action → ViewModel → APILayer → API Client → Server Response
|
||||
↓
|
||||
DataManager Updated (cache + disk)
|
||||
↓
|
||||
All Screens React (StateFlow observers)
|
||||
```
|
||||
|
||||
**Cache Architecture:**
|
||||
```kotlin
|
||||
// DataManager exposes StateFlows that UI observes directly
|
||||
DataManager.residences: StateFlow<List<Residence>>
|
||||
DataManager.myResidences: StateFlow<MyResidencesResponse?>
|
||||
DataManager.allTasks: StateFlow<TaskColumnsResponse?>
|
||||
DataManager.taskCategories: StateFlow<List<TaskCategory>>
|
||||
|
||||
// Cache validation (1 hour timeout)
|
||||
DataManager.isCacheValid(DataManager.residencesCacheTime)
|
||||
|
||||
// O(1) lookups for IDs
|
||||
DataManager.getTaskPriority(task.priorityId) // Returns TaskPriority?
|
||||
DataManager.getTaskCategory(task.categoryId) // Returns TaskCategory?
|
||||
```
|
||||
|
||||
### iOS Layer (`iosApp/iosApp/`)
|
||||
@@ -855,7 +877,7 @@ fun ThemePickerDialog(
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**API Environment Toggle** (`composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt`):
|
||||
**API Environment Toggle** (`composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt`):
|
||||
|
||||
```kotlin
|
||||
val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
||||
@@ -864,7 +886,7 @@ val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
||||
- `Environment.LOCAL`: Points to `http://10.0.2.2:8000/api` (Android emulator) or `http://127.0.0.1:8000/api` (iOS simulator)
|
||||
- `Environment.DEV`: Points to `https://mycrib.treytartt.com/api`
|
||||
|
||||
**Change this to switch between local Django backend and production server.**
|
||||
**Change this to switch between local Go backend and production server.**
|
||||
|
||||
## Common Development Patterns
|
||||
|
||||
@@ -953,20 +975,34 @@ Currently tests are minimal. When adding tests:
|
||||
|
||||
### Committing Changes
|
||||
|
||||
When committing changes that span both iOS and Android, commit them together in the KMM repository. If backend changes are needed, commit separately in the `myCribAPI` repository.
|
||||
When committing changes that span both iOS and Android, commit them together in the KMM repository. If backend changes are needed, commit separately in the `myCribAPI-go` repository.
|
||||
|
||||
### Data Cache Initialization
|
||||
### DataManager Initialization
|
||||
|
||||
**Critical**: After user login, call `APILayer.initializeLookups()` to populate DataCache with reference data. Without this, dropdowns and pickers will be empty.
|
||||
**Critical**: DataManager must be initialized at app startup with platform-specific managers:
|
||||
|
||||
```kotlin
|
||||
// In Application.onCreate() or equivalent
|
||||
DataManager.initialize(
|
||||
tokenMgr = TokenManager(context),
|
||||
themeMgr = ThemeStorageManager(context),
|
||||
persistenceMgr = PersistenceManager(context)
|
||||
)
|
||||
```
|
||||
|
||||
After user login, call `APILayer.initializeLookups()` to populate DataManager with reference data. This uses ETag-based caching - if data hasn't changed, server returns 304 Not Modified.
|
||||
|
||||
```kotlin
|
||||
// After successful login
|
||||
val initResult = APILayer.initializeLookups()
|
||||
if (initResult is ApiResult.Success) {
|
||||
// Navigate to main screen
|
||||
// Now safe to navigate to main screen
|
||||
// Lookups are cached in DataManager and persisted to disk
|
||||
}
|
||||
```
|
||||
|
||||
Without this, dropdowns and pickers will be empty.
|
||||
|
||||
### iOS Build Issues
|
||||
|
||||
If iOS build fails with type mismatch errors:
|
||||
@@ -989,19 +1025,19 @@ Without `forceRefresh`, APILayer returns cached data.
|
||||
MyCribKMM/
|
||||
├── composeApp/
|
||||
│ └── src/
|
||||
│ ├── commonMain/kotlin/com/example/mycrib/
|
||||
│ │ ├── cache/ # DataCache
|
||||
│ │ ├── models/ # Shared data models
|
||||
│ │ ├── network/ # APILayer, API clients
|
||||
│ │ ├── repository/ # Additional data repositories
|
||||
│ │ ├── storage/ # TokenStorage
|
||||
│ ├── commonMain/kotlin/com/example/casera/
|
||||
│ │ ├── data/ # DataManager, PersistenceManager
|
||||
│ │ ├── models/ # Shared data models (kotlinx.serialization)
|
||||
│ │ ├── network/ # APILayer, *Api clients, ApiConfig
|
||||
│ │ ├── storage/ # TokenManager, ThemeStorageManager
|
||||
│ │ ├── util/ # DateUtils, helpers
|
||||
│ │ ├── ui/ # Compose UI (Android)
|
||||
│ │ │ ├── components/ # Reusable components
|
||||
│ │ │ ├── screens/ # Screen composables
|
||||
│ │ │ └── theme/ # Material theme
|
||||
│ │ │ └── theme/ # Material theme, ThemeManager
|
||||
│ │ ├── viewmodel/ # Shared ViewModels
|
||||
│ │ └── App.kt # Android navigation
|
||||
│ ├── androidMain/ # Android-specific code
|
||||
│ ├── androidMain/ # Android-specific (TokenManager, etc.)
|
||||
│ ├── iosMain/ # iOS-specific Kotlin code
|
||||
│ └── commonTest/ # Shared tests
|
||||
│
|
||||
@@ -1009,9 +1045,9 @@ MyCribKMM/
|
||||
│ ├── *ViewModel.swift # Swift wrappers for Kotlin VMs
|
||||
│ ├── *View.swift # SwiftUI screens
|
||||
│ ├── Components/ # Reusable SwiftUI components
|
||||
│ ├── Design/ # Design system (spacing, colors)
|
||||
│ ├── Design/ # Design system (DesignSystem.swift, OrganicDesign.swift)
|
||||
│ ├── Extensions/ # Swift extensions
|
||||
│ ├── Helpers/ # Utility helpers
|
||||
│ ├── Helpers/ # Utility helpers (DateUtils, etc.)
|
||||
│ ├── PushNotifications/ # APNs integration
|
||||
│ └── [Feature]/ # Feature-grouped files
|
||||
│ ├── Task/
|
||||
@@ -1024,6 +1060,5 @@ MyCribKMM/
|
||||
|
||||
## Related Repositories
|
||||
|
||||
- **Backend API**: `../myCribAPI` - Django REST Framework backend
|
||||
- **Load Testing**: `../myCribAPI/locust` - Locust load testing scripts
|
||||
- **Documentation**: `../myCribAPI/docs` - Server configuration guides
|
||||
- **Backend API**: `../myCribAPI-go` - Go REST API with PostgreSQL
|
||||
- **Documentation**: `../myCribAPI-go/docs` - Server configuration and API docs
|
||||
|
||||
@@ -177,6 +177,16 @@ enum DateUtils {
|
||||
return date < today
|
||||
}
|
||||
|
||||
/// Parse a date string (YYYY-MM-DD or ISO datetime) into a Date object
|
||||
static func parseDate(_ dateString: String?) -> Date? {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return nil }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
return isoDateFormatter.date(from: datePart)
|
||||
}
|
||||
|
||||
// MARK: - Timezone Conversion Utilities
|
||||
|
||||
/// Convert a local hour (0-23) to UTC hour
|
||||
|
||||
@@ -43,18 +43,6 @@
|
||||
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%@, %@" : {
|
||||
"comment" : "A city and state combination.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@, %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@, %@ %@" : {
|
||||
"comment" : "A label displaying the city, state, and postal code of the residence.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -17483,9 +17471,6 @@
|
||||
},
|
||||
"or" : {
|
||||
|
||||
},
|
||||
"Overdue" : {
|
||||
|
||||
},
|
||||
"Overview" : {
|
||||
"comment" : "The title of the overview card.",
|
||||
|
||||
@@ -24,8 +24,8 @@ struct ResidencesListView: View {
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { residences in
|
||||
ResidencesContent(
|
||||
summary: viewModel.totalSummary ?? TotalSummary(totalResidences: Int32(residences.count), totalTasks: 0, totalPending: 0, totalOverdue: 0, tasksDueNextWeek: 0, tasksDueNextMonth: 0),
|
||||
residences: residences
|
||||
residences: residences,
|
||||
tasksResponse: taskViewModel.tasksResponse
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
@@ -46,7 +46,6 @@ struct ResidencesListView: View {
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.Residences.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -171,49 +170,132 @@ private struct OrganicToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 summary: TotalSummary
|
||||
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: summary)
|
||||
SummaryCard(summary: computedSummary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Properties Section Header
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(L10n.Residences.yourProperties)
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)")
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative leaf
|
||||
Image(systemName: "leaf.fill")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.3))
|
||||
.rotationEffect(.degrees(-15))
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.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)
|
||||
.padding(.horizontal, 16)
|
||||
ResidenceCard(
|
||||
residence: residence,
|
||||
taskStats: taskStats(for: residence.id)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.buttonStyle(OrganicCardButtonStyle())
|
||||
.transition(.asymmetric(
|
||||
|
||||
@@ -3,15 +3,36 @@ import ComposeApp
|
||||
|
||||
struct ResidenceCard: View {
|
||||
let residence: ResidenceResponse
|
||||
let taskStats: ResidenceTaskStats
|
||||
|
||||
/// Check if this residence has any overdue tasks
|
||||
private var hasOverdueTasks: Bool {
|
||||
Int(residence.overdueCount) > 0
|
||||
taskStats.overdueCount > 0
|
||||
}
|
||||
|
||||
/// Get task summary categories (max 3)
|
||||
private var displayCategories: [TaskCategorySummary] {
|
||||
Array(residence.taskSummary.categories.prefix(3))
|
||||
/// Open the address in Apple Maps
|
||||
private func openInMaps() {
|
||||
var addressComponents: [String] = []
|
||||
if !residence.streetAddress.isEmpty {
|
||||
addressComponents.append(residence.streetAddress)
|
||||
}
|
||||
if !residence.city.isEmpty {
|
||||
addressComponents.append(residence.city)
|
||||
}
|
||||
if !residence.stateProvince.isEmpty {
|
||||
addressComponents.append(residence.stateProvince)
|
||||
}
|
||||
if !residence.postalCode.isEmpty {
|
||||
addressComponents.append(residence.postalCode)
|
||||
}
|
||||
|
||||
let address = addressComponents.joined(separator: ", ")
|
||||
guard let encodedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "maps://?address=\(encodedAddress)") else {
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -37,17 +58,25 @@ struct ResidenceCard: View {
|
||||
.tracking(1.2)
|
||||
}
|
||||
|
||||
// Address
|
||||
// Address - tappable to open maps
|
||||
if !residence.streetAddress.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.7))
|
||||
Button(action: {
|
||||
openInMaps()
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.7))
|
||||
|
||||
Text(residence.streetAddress)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(1)
|
||||
Text(residence.streetAddress)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
@@ -64,41 +93,43 @@ struct ResidenceCard: View {
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Location Details (if available)
|
||||
if !residence.city.isEmpty || !residence.stateProvince.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.6))
|
||||
|
||||
Text("\(residence.city), \(residence.stateProvince)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.8))
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
// Divider
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
// Task Stats Section
|
||||
if !displayCategories.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(displayCategories, id: \.name) { category in
|
||||
TaskCategoryChip(category: category)
|
||||
}
|
||||
if taskStats.totalCount > 0 {
|
||||
HStack(spacing: 0) {
|
||||
// Total Tasks
|
||||
TaskStatItem(
|
||||
value: taskStats.totalCount,
|
||||
label: "Tasks",
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
// Show overdue count if any
|
||||
if hasOverdueTasks {
|
||||
OverdueChip(count: Int(residence.overdueCount))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 16)
|
||||
// Overdue
|
||||
TaskStatItem(
|
||||
value: taskStats.overdueCount,
|
||||
label: "Overdue",
|
||||
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||
)
|
||||
|
||||
// Due This Week
|
||||
TaskStatItem(
|
||||
value: taskStats.dueThisWeekCount,
|
||||
label: "This Week",
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
// Next 30 Days
|
||||
TaskStatItem(
|
||||
value: taskStats.dueNext30DaysCount,
|
||||
label: "30 Days",
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
// Empty state for tasks
|
||||
HStack(spacing: 8) {
|
||||
@@ -127,41 +158,25 @@ private struct PropertyIconView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with gradient
|
||||
Circle()
|
||||
// Theme-colored background
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary,
|
||||
Color.appPrimary.opacity(0.85)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 52
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
// Inner highlight
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.25),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 26
|
||||
)
|
||||
)
|
||||
.frame(width: 52, height: 52)
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
// House icon
|
||||
Image("house_outline")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
|
||||
// Pulse ring for overdue
|
||||
@@ -214,90 +229,24 @@ private struct PrimaryBadgeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Category Chip
|
||||
// MARK: - Task Stat Item
|
||||
|
||||
private struct TaskCategoryChip: View {
|
||||
let category: TaskCategorySummary
|
||||
|
||||
private var chipColor: Color {
|
||||
Color(hex: category.color) ?? Color.appPrimary
|
||||
}
|
||||
private struct TaskStatItem: View {
|
||||
let value: Int
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
// Icon background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(chipColor.opacity(0.15))
|
||||
.frame(width: 26, height: 26)
|
||||
HStack(spacing: 4) {
|
||||
Text("\(value)")
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
|
||||
Image(systemName: category.icons.ios)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(chipColor)
|
||||
}
|
||||
|
||||
// Count and label
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(category.count)")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(category.displayName)
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appBackgroundPrimary.opacity(0.6))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(chipColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overdue Chip
|
||||
|
||||
private struct OverdueChip: View {
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appError.opacity(0.15))
|
||||
.frame(width: 26, height: 26)
|
||||
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appError)
|
||||
|
||||
Text("Overdue")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(Color.appError.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appError.opacity(0.08))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.appError.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,63 +290,69 @@ private struct CardBackgroundView: View {
|
||||
#Preview("Residence Card") {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
ResidenceCard(residence: ResidenceResponse(
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94102",
|
||||
country: "USA",
|
||||
bedrooms: 4,
|
||||
bathrooms: 2.5,
|
||||
squareFootage: 2400,
|
||||
lotSize: 0.35,
|
||||
yearBuilt: 2018,
|
||||
description: "Beautiful modern home",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
ResidenceCard(
|
||||
residence: ResidenceResponse(
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94102",
|
||||
country: "USA",
|
||||
bedrooms: 4,
|
||||
bathrooms: 2.5,
|
||||
squareFootage: 2400,
|
||||
lotSize: 0.35,
|
||||
yearBuilt: 2018,
|
||||
description: "Beautiful modern home",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
taskStats: ResidenceTaskStats(totalCount: 8, overdueCount: 2, dueThisWeekCount: 3, dueNext30DaysCount: 2)
|
||||
)
|
||||
|
||||
ResidenceCard(residence: ResidenceResponse(
|
||||
id: 2,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Downtown Loft",
|
||||
propertyTypeId: 2,
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment"),
|
||||
streetAddress: "100 Market Street, Unit 502",
|
||||
apartmentUnit: "502",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94105",
|
||||
country: "USA",
|
||||
bedrooms: 2,
|
||||
bathrooms: 1.0,
|
||||
squareFootage: 1100,
|
||||
lotSize: nil,
|
||||
yearBuilt: 2020,
|
||||
description: "",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: false,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
ResidenceCard(
|
||||
residence: ResidenceResponse(
|
||||
id: 2,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Downtown Loft",
|
||||
propertyTypeId: 2,
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment"),
|
||||
streetAddress: "100 Market Street, Unit 502",
|
||||
apartmentUnit: "502",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94105",
|
||||
country: "USA",
|
||||
bedrooms: 2,
|
||||
bathrooms: 1.0,
|
||||
squareFootage: 1100,
|
||||
lotSize: nil,
|
||||
yearBuilt: 2020,
|
||||
description: "",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: false,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
taskStats: ResidenceTaskStats(totalCount: 0, overdueCount: 0, dueThisWeekCount: 0, dueNext30DaysCount: 0)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
@@ -7,44 +7,14 @@ struct SummaryCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with greeting
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(greetingText)
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
Text("Your Home Dashboard")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 28
|
||||
)
|
||||
)
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 20)
|
||||
// Header
|
||||
Text("Your Home Dashboard")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Main Stats Row
|
||||
HStack(spacing: 0) {
|
||||
@@ -85,16 +55,16 @@ struct SummaryCard: View {
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "calendar.badge.clock",
|
||||
icon: "clock.fill",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
label: "This Week",
|
||||
label: "Due This Week",
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "calendar",
|
||||
icon: "arrow.forward.circle.fill",
|
||||
value: "\(summary.tasksDueNextMonth)",
|
||||
label: "30 Days",
|
||||
label: "Next 30 Days",
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
@@ -107,19 +77,6 @@ struct SummaryCard: View {
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
private var greetingText: String {
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
switch hour {
|
||||
case 5..<12:
|
||||
return "Good morning"
|
||||
case 12..<17:
|
||||
return "Good afternoon"
|
||||
case 17..<21:
|
||||
return "Good evening"
|
||||
default:
|
||||
return "Good night"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Stat Item
|
||||
@@ -167,32 +124,32 @@ private struct TimelineStatPill: View {
|
||||
var isAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(isAlert ? color : Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(
|
||||
isAlert
|
||||
? color.opacity(0.08)
|
||||
: Color.appBackgroundPrimary.opacity(0.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(
|
||||
isAlert ? color.opacity(0.2) : Color.clear,
|
||||
lineWidth: 1
|
||||
|
||||
Reference in New Issue
Block a user