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
|
## 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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user