From c3a9494b0f68b62fcb5fc202aa2e65ab323e17ec Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 16 Dec 2025 23:57:01 -0600 Subject: [PATCH] Fix task stats consistency and improve residence card UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 129 ++++--- iosApp/iosApp/Helpers/DateUtils.swift | 10 + iosApp/iosApp/Localizable.xcstrings | 15 - .../iosApp/Residence/ResidencesListView.swift | 142 +++++-- .../Subviews/Residence/ResidenceCard.swift | 353 ++++++++---------- .../Subviews/Residence/SummaryCard.swift | 83 +--- 6 files changed, 378 insertions(+), 354 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d6c073..30093a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -MyCrib is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Django REST Framework API (located in the sibling `myCribAPI` directory). +MyCrib (Casera) is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Go REST API with PostgreSQL (located in the sibling `myCribAPI-go` directory). **Tech Stack:** - **Shared (Kotlin)**: Compose Multiplatform for Android, networking layer, ViewModels, models - **iOS**: SwiftUI with Kotlin shared layer integration via SKIE -- **Backend**: Django REST Framework (separate repository at `../myCribAPI`) +- **Backend**: Go REST API with PostgreSQL (separate directory at `../myCribAPI-go`) ## Build Commands @@ -56,42 +56,64 @@ open iosApp/iosApp.xcodeproj ## Architecture -### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/mycrib/`) +### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/casera/`) **Core Components:** -1. **APILayer** (`network/APILayer.kt`) - - **Single entry point for all API calls** - - Manages caching via DataCache - - Handles automatic cache updates on mutations (create/update/delete) - - Pattern: Cache-first reads with optional `forceRefresh` parameter - - Returns `ApiResult` (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` (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> +DataManager.myResidences: StateFlow +DataManager.allTasks: StateFlow +DataManager.taskCategories: StateFlow> + +// 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 diff --git a/iosApp/iosApp/Helpers/DateUtils.swift b/iosApp/iosApp/Helpers/DateUtils.swift index 6e712c8..0bdec38 100644 --- a/iosApp/iosApp/Helpers/DateUtils.swift +++ b/iosApp/iosApp/Helpers/DateUtils.swift @@ -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 diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 1049fe4..450d239 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -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.", diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index f681a1e..728c34c 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -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( diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 3b84348..009479c 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -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) diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 3fd3b8e..c83dea8 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -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