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

View File

@@ -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

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.",
"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.",

View File

@@ -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(

View File

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

View File

@@ -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