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:
Trey t
2025-12-16 23:57:01 -06:00
parent 7f3ed69574
commit c3a9494b0f
6 changed files with 378 additions and 354 deletions

129
CLAUDE.md
View File

@@ -10,12 +10,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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:** **Tech Stack:**
- **Shared (Kotlin)**: Compose Multiplatform for Android, networking layer, ViewModels, models - **Shared (Kotlin)**: Compose Multiplatform for Android, networking layer, ViewModels, models
- **iOS**: SwiftUI with Kotlin shared layer integration via SKIE - **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 ## Build Commands
@@ -56,42 +56,64 @@ open iosApp/iosApp.xcodeproj
## Architecture ## Architecture
### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/mycrib/`) ### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/casera/`)
**Core Components:** **Core Components:**
1. **APILayer** (`network/APILayer.kt`) 1. **DataManager** (`data/DataManager.kt`) - **Single Source of Truth**
- **Single entry point for all API calls** - Unified cache for ALL app data (auth, residences, tasks, lookups, etc.)
- Manages caching via DataCache - All data is exposed via `StateFlow` for reactive UI updates
- Handles automatic cache updates on mutations (create/update/delete) - Automatic cache timeout validation (1 hour default)
- Pattern: Cache-first reads with optional `forceRefresh` parameter - Persists data to disk for offline access
- Returns `ApiResult<T>` (Success/Error/Loading states) - Platform-specific initialization (TokenManager, ThemeStorage, PersistenceManager)
- O(1) lookup helpers: `getTaskPriority(id)`, `getTaskCategory(id)`, etc.
2. **DataCache** (`cache/DataCache.kt`) 2. **APILayer** (`network/APILayer.kt`) - **Single Entry Point for Network Calls**
- In-memory cache for lookup data (residence types, task categories, priorities, statuses, etc.) - Every API response immediately updates DataManager
- Must be initialized via `APILayer.initializeLookups()` after login - All screens observe DataManager StateFlows directly
- Stores `MutableState` objects that UI can observe directly - Handles cache-first reads with `forceRefresh` parameter
- Cleared on logout - 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`) 3. **API Clients** (`network/*Api.kt`)
- Platform-specific secure token storage - Domain-specific API clients: `ResidenceApi`, `TaskApi`, `ContractorApi`, etc.
- Android: EncryptedSharedPreferences - Low-level HTTP calls using Ktor
- iOS: Keychain - Error parsing and response handling
- All API calls automatically include token from TokenStorage
4. **ViewModels** (`viewmodel/`) 4. **PersistenceManager** (`data/PersistenceManager.kt`)
- Shared ViewModels expose StateFlow for UI observation - Platform-specific disk persistence (expect/actual pattern)
- Pattern: ViewModel calls APILayer → APILayer manages cache + network → ViewModel emits ApiResult states - Stores serialized JSON for offline access
- ViewModels: `ResidenceViewModel`, `TaskViewModel`, `AuthViewModel`, `ContractorViewModel`, etc. - Loads cached data on app startup
5. **Navigation** (`navigation/`) 5. **ViewModels** (`viewmodel/`)
- Type-safe navigation using kotlinx.serialization - Thin wrappers that call APILayer methods
- Routes defined as `@Serializable` data classes - Expose loading/error states for UI feedback
- Shared between Android Compose Navigation - ViewModels: `ResidenceViewModel`, `TaskViewModel`, `AuthViewModel`, etc.
**Data Flow:** **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/`) ### iOS Layer (`iosApp/iosApp/`)
@@ -855,7 +877,7 @@ fun ThemePickerDialog(
## Environment Configuration ## 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 ```kotlin
val CURRENT_ENV = Environment.DEV // or Environment.LOCAL 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.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` - `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 ## Common Development Patterns
@@ -953,20 +975,34 @@ Currently tests are minimal. When adding tests:
### Committing Changes ### 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 ```kotlin
// After successful login // After successful login
val initResult = APILayer.initializeLookups() val initResult = APILayer.initializeLookups()
if (initResult is ApiResult.Success) { 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 ### iOS Build Issues
If iOS build fails with type mismatch errors: If iOS build fails with type mismatch errors:
@@ -989,19 +1025,19 @@ Without `forceRefresh`, APILayer returns cached data.
MyCribKMM/ MyCribKMM/
├── composeApp/ ├── composeApp/
│ └── src/ │ └── src/
│ ├── commonMain/kotlin/com/example/mycrib/ │ ├── commonMain/kotlin/com/example/casera/
│ │ ├── cache/ # DataCache │ │ ├── data/ # DataManager, PersistenceManager
│ │ ├── models/ # Shared data models │ │ ├── models/ # Shared data models (kotlinx.serialization)
│ │ ├── network/ # APILayer, API clients │ │ ├── network/ # APILayer, *Api clients, ApiConfig
│ │ ├── repository/ # Additional data repositories │ │ ├── storage/ # TokenManager, ThemeStorageManager
│ │ ├── storage/ # TokenStorage │ │ ├── util/ # DateUtils, helpers
│ │ ├── ui/ # Compose UI (Android) │ │ ├── ui/ # Compose UI (Android)
│ │ │ ├── components/ # Reusable components │ │ │ ├── components/ # Reusable components
│ │ │ ├── screens/ # Screen composables │ │ │ ├── screens/ # Screen composables
│ │ │ └── theme/ # Material theme │ │ │ └── theme/ # Material theme, ThemeManager
│ │ ├── viewmodel/ # Shared ViewModels │ │ ├── viewmodel/ # Shared ViewModels
│ │ └── App.kt # Android navigation │ │ └── App.kt # Android navigation
│ ├── androidMain/ # Android-specific code │ ├── androidMain/ # Android-specific (TokenManager, etc.)
│ ├── iosMain/ # iOS-specific Kotlin code │ ├── iosMain/ # iOS-specific Kotlin code
│ └── commonTest/ # Shared tests │ └── commonTest/ # Shared tests
@@ -1009,9 +1045,9 @@ MyCribKMM/
│ ├── *ViewModel.swift # Swift wrappers for Kotlin VMs │ ├── *ViewModel.swift # Swift wrappers for Kotlin VMs
│ ├── *View.swift # SwiftUI screens │ ├── *View.swift # SwiftUI screens
│ ├── Components/ # Reusable SwiftUI components │ ├── Components/ # Reusable SwiftUI components
│ ├── Design/ # Design system (spacing, colors) │ ├── Design/ # Design system (DesignSystem.swift, OrganicDesign.swift)
│ ├── Extensions/ # Swift extensions │ ├── Extensions/ # Swift extensions
│ ├── Helpers/ # Utility helpers │ ├── Helpers/ # Utility helpers (DateUtils, etc.)
│ ├── PushNotifications/ # APNs integration │ ├── PushNotifications/ # APNs integration
│ └── [Feature]/ # Feature-grouped files │ └── [Feature]/ # Feature-grouped files
│ ├── Task/ │ ├── Task/
@@ -1024,6 +1060,5 @@ MyCribKMM/
## Related Repositories ## Related Repositories
- **Backend API**: `../myCribAPI` - Django REST Framework backend - **Backend API**: `../myCribAPI-go` - Go REST API with PostgreSQL
- **Load Testing**: `../myCribAPI/locust` - Locust load testing scripts - **Documentation**: `../myCribAPI-go/docs` - Server configuration and API docs
- **Documentation**: `../myCribAPI/docs` - Server configuration guides

View File

@@ -177,6 +177,16 @@ enum DateUtils {
return date < today 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 // MARK: - Timezone Conversion Utilities
/// Convert a local hour (0-23) to UTC hour /// Convert a local hour (0-23) to UTC hour

View File

@@ -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.", "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 "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.", "comment" : "A label displaying the city, state, and postal code of the residence.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -17483,9 +17471,6 @@
}, },
"or" : { "or" : {
},
"Overdue" : {
}, },
"Overview" : { "Overview" : {
"comment" : "The title of the overview card.", "comment" : "The title of the overview card.",

View File

@@ -24,8 +24,8 @@ struct ResidencesListView: View {
errorMessage: viewModel.errorMessage, errorMessage: viewModel.errorMessage,
content: { residences in content: { residences in
ResidencesContent( 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: { emptyContent: {
@@ -46,7 +46,6 @@ struct ResidencesListView: View {
}) })
} }
} }
.navigationTitle(L10n.Residences.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { 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 // MARK: - Residences Content View
private struct ResidencesContent: View { private struct ResidencesContent: View {
let summary: TotalSummary
let residences: [ResidenceResponse] 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) { VStack(spacing: OrganicSpacing.comfortable) {
// Summary Card with enhanced styling // Summary Card with enhanced styling
SummaryCard(summary: summary) SummaryCard(summary: computedSummary)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 8) .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 // Residences List with staggered animation
LazyVStack(spacing: 16) { LazyVStack(spacing: 16) {
ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
ResidenceCard(residence: residence) ResidenceCard(
.padding(.horizontal, 16) residence: residence,
taskStats: taskStats(for: residence.id)
)
.padding(.horizontal, 16)
} }
.buttonStyle(OrganicCardButtonStyle()) .buttonStyle(OrganicCardButtonStyle())
.transition(.asymmetric( .transition(.asymmetric(

View File

@@ -3,15 +3,36 @@ import ComposeApp
struct ResidenceCard: View { struct ResidenceCard: View {
let residence: ResidenceResponse let residence: ResidenceResponse
let taskStats: ResidenceTaskStats
/// Check if this residence has any overdue tasks /// Check if this residence has any overdue tasks
private var hasOverdueTasks: Bool { private var hasOverdueTasks: Bool {
Int(residence.overdueCount) > 0 taskStats.overdueCount > 0
} }
/// Get task summary categories (max 3) /// Open the address in Apple Maps
private var displayCategories: [TaskCategorySummary] { private func openInMaps() {
Array(residence.taskSummary.categories.prefix(3)) 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 { var body: some View {
@@ -37,17 +58,25 @@ struct ResidenceCard: View {
.tracking(1.2) .tracking(1.2)
} }
// Address // Address - tappable to open maps
if !residence.streetAddress.isEmpty { if !residence.streetAddress.isEmpty {
HStack(spacing: 6) { Button(action: {
Image(systemName: "mappin") openInMaps()
.font(.system(size: 10, weight: .bold)) }) {
.foregroundColor(Color.appPrimary.opacity(0.7)) HStack(spacing: 6) {
Image(systemName: "mappin")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appPrimary.opacity(0.7))
Text(residence.streetAddress) Text(residence.streetAddress)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appPrimary)
.lineLimit(1) .lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
} }
.padding(.top, 2) .padding(.top, 2)
} }
@@ -64,41 +93,43 @@ struct ResidenceCard: View {
.padding(.top, OrganicSpacing.cozy) .padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 16) .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 // Divider
OrganicDivider() OrganicDivider()
.padding(.horizontal, 16) .padding(.horizontal, 16)
// Task Stats Section // Task Stats Section
if !displayCategories.isEmpty { if taskStats.totalCount > 0 {
ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) {
HStack(spacing: 10) { // Total Tasks
ForEach(displayCategories, id: \.name) { category in TaskStatItem(
TaskCategoryChip(category: category) value: taskStats.totalCount,
} label: "Tasks",
color: Color.appPrimary
)
// Show overdue count if any // Overdue
if hasOverdueTasks { TaskStatItem(
OverdueChip(count: Int(residence.overdueCount)) value: taskStats.overdueCount,
} label: "Overdue",
} color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
.padding(.horizontal, OrganicSpacing.cozy) )
.padding(.vertical, 16)
// 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 { } else {
// Empty state for tasks // Empty state for tasks
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -127,41 +158,25 @@ private struct PropertyIconView: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Background circle with gradient // Theme-colored background
Circle() RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill( .fill(
RadialGradient( LinearGradient(
colors: [ colors: [
Color.appPrimary, Color.appPrimary,
Color.appPrimary.opacity(0.85) Color.appPrimary.opacity(0.85)
], ],
center: .topLeading, startPoint: .topLeading,
startRadius: 0, endPoint: .bottomTrailing
endRadius: 52
) )
) )
.frame(width: 52, height: 52) .frame(width: 56, height: 56)
// Inner highlight
Circle()
.fill(
RadialGradient(
colors: [
Color.white.opacity(0.25),
Color.clear
],
center: .topLeading,
startRadius: 0,
endRadius: 26
)
)
.frame(width: 52, height: 52)
// House icon // House icon
Image("house_outline") Image("house_outline")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 24, height: 24) .frame(width: 48, height: 48)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
// Pulse ring for overdue // Pulse ring for overdue
@@ -214,90 +229,24 @@ private struct PrimaryBadgeView: View {
} }
} }
// MARK: - Task Category Chip // MARK: - Task Stat Item
private struct TaskCategoryChip: View { private struct TaskStatItem: View {
let category: TaskCategorySummary let value: Int
let label: String
private var chipColor: Color { let color: Color
Color(hex: category.color) ?? Color.appPrimary
}
var body: some View { var body: some View {
HStack(spacing: 6) { HStack(spacing: 4) {
// Icon background Text("\(value)")
ZStack { .font(.system(size: 15, weight: .bold, design: .rounded))
Circle() .foregroundColor(color)
.fill(chipColor.opacity(0.15))
.frame(width: 26, height: 26)
Image(systemName: category.icons.ios) Text(label)
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .medium))
.foregroundColor(chipColor) .foregroundColor(Color.appTextSecondary)
}
// 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)
}
} }
.padding(.horizontal, 12) .frame(maxWidth: .infinity)
.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)
)
)
} }
} }
@@ -341,63 +290,69 @@ private struct CardBackgroundView: View {
#Preview("Residence Card") { #Preview("Residence Card") {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
ResidenceCard(residence: ResidenceResponse( ResidenceCard(
id: 1, residence: ResidenceResponse(
ownerId: 1, id: 1,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"), ownerId: 1,
users: [], owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
name: "Sunset Villa", users: [],
propertyTypeId: 1, name: "Sunset Villa",
propertyType: ResidenceType(id: 1, name: "House"), propertyTypeId: 1,
streetAddress: "742 Evergreen Terrace", propertyType: ResidenceType(id: 1, name: "House"),
apartmentUnit: "", streetAddress: "742 Evergreen Terrace",
city: "San Francisco", apartmentUnit: "",
stateProvince: "CA", city: "San Francisco",
postalCode: "94102", stateProvince: "CA",
country: "USA", postalCode: "94102",
bedrooms: 4, country: "USA",
bathrooms: 2.5, bedrooms: 4,
squareFootage: 2400, bathrooms: 2.5,
lotSize: 0.35, squareFootage: 2400,
yearBuilt: 2018, lotSize: 0.35,
description: "Beautiful modern home", yearBuilt: 2018,
purchaseDate: nil, description: "Beautiful modern home",
purchasePrice: nil, purchaseDate: nil,
isPrimary: true, purchasePrice: nil,
isActive: true, isPrimary: true,
overdueCount: 2, isActive: true,
createdAt: "2024-01-01T00:00:00Z", overdueCount: 2,
updatedAt: "2024-01-01T00:00:00Z" 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( ResidenceCard(
id: 2, residence: ResidenceResponse(
ownerId: 1, id: 2,
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"), ownerId: 1,
users: [], owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
name: "Downtown Loft", users: [],
propertyTypeId: 2, name: "Downtown Loft",
propertyType: ResidenceType(id: 2, name: "Apartment"), propertyTypeId: 2,
streetAddress: "100 Market Street, Unit 502", propertyType: ResidenceType(id: 2, name: "Apartment"),
apartmentUnit: "502", streetAddress: "100 Market Street, Unit 502",
city: "San Francisco", apartmentUnit: "502",
stateProvince: "CA", city: "San Francisco",
postalCode: "94105", stateProvince: "CA",
country: "USA", postalCode: "94105",
bedrooms: 2, country: "USA",
bathrooms: 1.0, bedrooms: 2,
squareFootage: 1100, bathrooms: 1.0,
lotSize: nil, squareFootage: 1100,
yearBuilt: 2020, lotSize: nil,
description: "", yearBuilt: 2020,
purchaseDate: nil, description: "",
purchasePrice: nil, purchaseDate: nil,
isPrimary: false, purchasePrice: nil,
isActive: true, isPrimary: false,
overdueCount: 0, isActive: true,
createdAt: "2024-01-01T00:00:00Z", overdueCount: 0,
updatedAt: "2024-01-01T00:00:00Z" 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(.horizontal, 16)
.padding(.vertical, 24) .padding(.vertical, 24)

View File

@@ -7,44 +7,14 @@ struct SummaryCard: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header with greeting // Header
HStack(alignment: .center) { Text("Your Home Dashboard")
VStack(alignment: .leading, spacing: 4) { .font(.system(size: 22, weight: .bold, design: .rounded))
Text(greetingText) .foregroundColor(Color.appTextPrimary)
.font(.system(size: 14, weight: .medium, design: .rounded)) .frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(Color.appTextSecondary) .padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
Text("Your Home Dashboard") .padding(.bottom, 20)
.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)
// Main Stats Row // Main Stats Row
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -85,16 +55,16 @@ struct SummaryCard: View {
) )
TimelineStatPill( TimelineStatPill(
icon: "calendar.badge.clock", icon: "clock.fill",
value: "\(summary.tasksDueNextWeek)", value: "\(summary.tasksDueNextWeek)",
label: "This Week", label: "Due This Week",
color: Color.appAccent color: Color.appAccent
) )
TimelineStatPill( TimelineStatPill(
icon: "calendar", icon: "arrow.forward.circle.fill",
value: "\(summary.tasksDueNextMonth)", value: "\(summary.tasksDueNextMonth)",
label: "30 Days", label: "Next 30 Days",
color: Color.appPrimary.opacity(0.7) color: Color.appPrimary.opacity(0.7)
) )
} }
@@ -107,19 +77,6 @@ struct SummaryCard: View {
.naturalShadow(.pronounced) .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 // MARK: - Organic Stat Item
@@ -167,32 +124,32 @@ private struct TimelineStatPill: View {
var isAlert: Bool = false var isAlert: Bool = false
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 8) {
HStack(spacing: 4) { HStack(spacing: 6) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
.foregroundColor(color) .foregroundColor(color)
Text(value) Text(value)
.font(.system(size: 16, weight: .bold, design: .rounded)) .font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(isAlert ? color : Color.appTextPrimary) .foregroundColor(isAlert ? color : Color.appTextPrimary)
} }
Text(label) Text(label)
.font(.system(size: 10, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 18)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill( .fill(
isAlert isAlert
? color.opacity(0.08) ? color.opacity(0.08)
: Color.appBackgroundPrimary.opacity(0.5) : Color.appBackgroundPrimary.opacity(0.5)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke( .stroke(
isAlert ? color.opacity(0.2) : Color.clear, isAlert ? color.opacity(0.2) : Color.clear,
lineWidth: 1 lineWidth: 1