From b39d37a6e8d4d7eb3921997eb545c4fb67b6973f Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 17 Dec 2025 22:58:55 -0600 Subject: [PATCH] Fix residence auto-update, widget theming, and document patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix residence detail not updating after edit: - DataManager.updateResidence() now updates both _residences and _myResidences - ResidenceViewModel auto-updates selectedResidence when data changes - No pull-to-refresh needed after editing - Add widget theme support: - Widgets now use user's selected theme via App Group UserDefaults - ThemeManager has simplified version for widget extension context - Added WIDGET_EXTENSION compiler flag to CaseraExtension target - Redesign widget views with organic aesthetic: - Updated FreeWidgetView, SmallWidgetView, MediumWidgetView, LargeWidgetView - Created OrganicTaskRowView, OrganicStatsView, OrganicStatPillWidget - Document patterns in CLAUDE.md: - Added Mutation & Auto-Update Pattern section - Added iOS Shared Components documentation - Documented reusable buttons, forms, empty states, cards, modifiers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 322 ++++++++++ .../com/example/casera/data/DataManager.kt | 7 + .../com/example/casera/network/ApiConfig.kt | 2 +- iosApp/Casera/MyCrib.swift | 589 ++++++++++-------- iosApp/iosApp.xcodeproj/project.pbxproj | 5 + iosApp/iosApp/Design/DesignSystem.swift | 3 + iosApp/iosApp/Helpers/ThemeManager.swift | 35 +- .../iosApp/Residence/ResidenceViewModel.swift | 10 + 8 files changed, 719 insertions(+), 254 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30093a4..4361869 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,328 @@ Task { } ``` +### Mutation & Auto-Update Pattern + +**CRITICAL: When implementing CRUD operations, follow this pattern to ensure UI updates automatically without requiring pull-to-refresh.** + +#### Kotlin DataManager Update Methods + +When updating a single item, ensure ALL related caches are updated: + +```kotlin +// ✅ CORRECT: Update all caches that contain the item +fun updateResidence(residence: Residence) { + // Update primary list + _residences.value = _residences.value.map { + if (it.id == residence.id) residence else it + } + // Also update related caches (myResidences is checked by getResidence) + _myResidences.value?.let { myRes -> + val updatedResidences = myRes.residences.map { + if (it.id == residence.id) residence else it + } + _myResidences.value = myRes.copy(residences = updatedResidences) + } + persistToDisk() +} + +// ❌ WRONG: Only updating one cache causes stale data +fun updateResidence(residence: Residence) { + _residences.value = _residences.value.map { + if (it.id == residence.id) residence else it + } + // Missing: _myResidences update - getResidence may return stale data! +} +``` + +#### iOS ViewModel: Auto-Update Selected Items + +When a detail view displays a `selectedItem`, the ViewModel must auto-update it when DataManager data changes: + +```swift +// ✅ CORRECT: Auto-update selectedResidence when data changes +init() { + // Observe residences list + DataManagerObservable.shared.$residences + .receive(on: DispatchQueue.main) + .sink { [weak self] residences in + self?.residences = residences + // Auto-update selectedResidence if it exists in the updated list + if let currentSelected = self?.selectedResidence, + let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) { + self?.selectedResidence = updatedResidence + } + } + .store(in: &cancellables) + + // Also observe myResidences (another source of residence data) + DataManagerObservable.shared.$myResidences + .receive(on: DispatchQueue.main) + .sink { [weak self] myResidences in + self?.myResidences = myResidences + // Auto-update selectedResidence here too + if let currentSelected = self?.selectedResidence, + let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) { + self?.selectedResidence = updatedResidence + } + } + .store(in: &cancellables) +} + +// ❌ WRONG: Only storing list data, selectedResidence becomes stale +init() { + DataManagerObservable.shared.$residences + .sink { [weak self] residences in + self?.residences = residences + // Missing: selectedResidence auto-update! + } + .store(in: &cancellables) +} +``` + +#### Complete Data Flow for Mutations + +``` +1. User edits item in FormView (has its own ViewModel instance) +2. FormView.viewModel calls APILayer.updateItem() +3. APILayer calls API, on success: + - DataManager.updateItem() updates ALL relevant caches + - Returns updated item +4. DataManager StateFlows emit new values +5. DataManagerObservable picks up changes, publishes to @Published +6. ALL ViewModels observing that data receive updates via Combine +7. Each ViewModel's sink checks if selectedItem matches and updates it +8. SwiftUI re-renders automatically - NO pull-to-refresh needed +``` + +#### Checklist for New CRUD Features + +- [ ] Kotlin `DataManager.updateX()` updates ALL caches containing that data type +- [ ] iOS ViewModel observes ALL relevant `DataManagerObservable` publishers +- [ ] iOS ViewModel auto-updates `selectedX` in each Combine sink +- [ ] No manual refresh calls needed after mutations (architecture handles it) + +### iOS Shared Components (`iosApp/iosApp/Shared/`) + +**CRITICAL: Always check the Shared folder for reusable components before creating new ones.** This folder contains standardized UI components, view modifiers, and utilities that ensure consistency across the app. + +#### Directory Structure + +``` +iosApp/iosApp/Shared/ +├── Components/ +│ ├── FormComponents.swift # Form headers, sections, text fields +│ ├── ButtonStyles.swift # Primary, secondary, destructive buttons +│ └── SharedEmptyStateView.swift # Empty state views +├── Modifiers/ +│ └── CardModifiers.swift # Card styling modifiers +├── Extensions/ +│ ├── ViewExtensions.swift # Form styling, loading overlays +│ ├── StringExtensions.swift # String utilities +│ ├── DoubleExtensions.swift # Number formatting +│ └── DateExtensions.swift # Date formatting +└── Utilities/ + ├── ValidationHelpers.swift # Form validation + └── SharedErrorMessageParser.swift +``` + +#### Reusable Button Components + +```swift +// Primary filled button (main actions) +PrimaryButton(title: "Save", icon: "checkmark", isLoading: isLoading) { + saveAction() +} + +// Secondary outlined button +SecondaryButton(title: "Cancel", icon: "xmark") { + cancelAction() +} + +// Destructive button (delete, remove) +DestructiveButton(title: "Delete", icon: "trash") { + deleteAction() +} + +// Text-only button +TextButton(title: "Learn More", icon: "arrow.right") { + navigateAction() +} + +// Compact button for cards/rows +CompactButton(title: "Edit", icon: "pencil", color: .appPrimary, isFilled: false) { + editAction() +} + +// Organic button with gradient (premium feel) +OrganicPrimaryButton(title: "Continue", isLoading: isLoading) { + continueAction() +} +``` + +#### Reusable Form Components + +```swift +// Form header with icon +FormHeader( + icon: "house.fill", + title: "Add Property", + subtitle: "Enter your property details" +) + +// Organic form header (radial gradient style) +OrganicFormHeader( + icon: "person.fill", + title: "Create Account", + subtitle: "Join Casera today" +) + +// Form section with icon header +IconFormSection(icon: "info.circle", title: "Details", footer: "Optional info") { + TextField("Name", text: $name) +} + +// Error display section +if let error = errorMessage { + ErrorSection(message: error) +} + +// Success message section +SuccessSection(message: "Changes saved successfully") + +// Form action button (submit) +FormActionButton(title: "Submit", isLoading: isSubmitting) { + submitForm() +} + +// Text field with icon +IconTextField( + icon: "envelope", + placeholder: "Email", + text: $email, + keyboardType: .emailAddress +) + +// Secure text field with visibility toggle +SecureIconTextField( + icon: "lock", + placeholder: "Password", + text: $password, + isVisible: $showPassword +) + +// Field label with optional required indicator +FieldLabel(text: "Username", isRequired: true) + +// Field error message +FieldError(message: "This field is required") +``` + +#### Reusable Empty State Views + +```swift +// Standard empty state +StandardEmptyStateView( + icon: "tray", + title: "No Items", + subtitle: "Get started by adding your first item", + actionLabel: "Add Item", + action: { showAddSheet = true } +) + +// Organic empty state (matches app design) +OrganicEmptyState( + icon: "house", + title: "No Properties", + subtitle: "Add your first property to get started", + actionLabel: "Add Property", + action: { showAddSheet = true }, + blobVariation: 1 +) + +// Simple list empty state +ListEmptyState(icon: "doc.text", message: "No documents found") +``` + +#### Reusable Card Modifiers + +```swift +// Standard card styling +VStack { content } + .standardCard() // Default padding, background, shadow + +// Compact card (smaller padding) +HStack { content } + .compactCard() + +// Organic card (matches design system) +VStack { content } + .organicCardStyle(showBlob: true, blobVariation: 0) + +// List row card +ForEach(items) { item in + ItemRow(item: item) + .listRowCard() +} + +// Metadata pill (tags, badges) +Text("Active") + .metadataPill() +``` + +#### Reusable View Extensions + +```swift +// Standard form styling (ALWAYS use on Form views) +Form { + // content +} +.standardFormStyle() // Applies .listStyle(.plain), .scrollContentBackground(.hidden) + +// Section backgrounds +Section { content } + .sectionBackground() // Uses Color.appBackgroundSecondary + +Section { headerContent } + .headerSectionBackground() // Clear background for headers + +// Loading overlay +content + .loadingOverlay(isLoading: isLoading, message: "Saving...") + +// Conditional modifiers +content + .if(condition) { view in + view.opacity(0.5) + } + +// Dismiss keyboard on tap +ScrollView { content } + .dismissKeyboardOnTap() + +// Standard loading view +StandardLoadingView(message: "Loading...") +``` + +#### When to Create New vs Reuse + +**Reuse existing components when:** +- Building forms (use `FormComponents.swift`) +- Adding buttons (use `ButtonStyles.swift`) +- Showing empty states (use `SharedEmptyStateView.swift`) +- Styling cards (use `CardModifiers.swift`) +- Styling forms (use `ViewExtensions.swift`) + +**Create new components when:** +- The pattern will be used 3+ times +- It doesn't fit existing components +- It's domain-specific (put in feature folder, not Shared) + +**Add to Shared folder when:** +- Component is generic and reusable across features +- It follows existing design patterns +- It doesn't depend on specific business logic + ### iOS Design System **CRITICAL**: Always use the custom design system colors defined in `iosApp/iosApp/Design/DesignSystem.swift` and Xcode Asset Catalog. Never use system colors directly. diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index 68c15b1..755cb5b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -370,6 +370,13 @@ object DataManager { _residences.value = _residences.value.map { if (it.id == residence.id) residence else it } + // Also update myResidences if present (used by getResidence cache lookup) + _myResidences.value?.let { myRes -> + val updatedResidences = myRes.residences.map { + if (it.id == residence.id) residence else it + } + _myResidences.value = myRes.copy(residences = updatedResidences) + } persistToDisk() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index 0829e7c..cd5e298 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -9,7 +9,7 @@ package com.example.casera.network */ object ApiConfig { // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ - val CURRENT_ENV = Environment.LOCAL + val CURRENT_ENV = Environment.DEV enum class Environment { LOCAL, diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift index 65da4dd..b2f8fd8 100644 --- a/iosApp/Casera/MyCrib.swift +++ b/iosApp/Casera/MyCrib.swift @@ -253,228 +253,350 @@ struct CaseraEntryView : View { // MARK: - Free Tier Widget View (Non-Interactive) struct FreeWidgetView: View { let entry: SimpleEntry + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(spacing: 12) { + VStack(spacing: OrganicSpacing.cozy) { Spacer() - // Task count display - Text("\(entry.taskCount)") - .font(.system(size: 56, weight: .bold)) - .foregroundStyle(.blue) + // Organic task count with glow + ZStack { + // Soft glow behind number + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.2), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 50 + ) + ) + .frame(width: 100, height: 100) - Text(entry.taskCount == 1 ? "task waiting on you" : "tasks waiting on you") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + Text("\(entry.taskCount)") + .font(.system(size: 52, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + Text(entry.taskCount == 1 ? "task waiting" : "tasks waiting") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) .multilineTextAlignment(.center) Spacer() - // Subtle upgrade hint + // Subtle upgrade hint with organic styling Text("Upgrade for interactive widgets") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color.appTextSecondary.opacity(0.6)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.15 : 0.08)) + ) } - .padding(16) + .padding(OrganicSpacing.cozy) } } // MARK: - Small Widget View struct SmallWidgetView: View { let entry: SimpleEntry + @Environment(\.colorScheme) var colorScheme var body: some View { VStack(alignment: .leading, spacing: 0) { - // Task Count - VStack(alignment: .leading, spacing: 4) { - Text("\(entry.taskCount)") - .font(.system(size: 36, weight: .bold)) - .foregroundStyle(.blue) + // Task Count with organic glow + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("\(entry.taskCount)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) - if entry.taskCount > 0 { - Text(entry.taskCount == 1 ? "upcoming task" : "upcoming tasks") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + if entry.taskCount > 0 { + Text(entry.taskCount == 1 ? "task" : "tasks") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) + } } + + Spacer() + + // Small decorative accent + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 0, + endRadius: 20 + ) + ) + .frame(width: 40, height: 40) + .offset(x: 10, y: -10) } - // Next Task with Complete Button + Spacer(minLength: 8) + + // Next Task Card if let nextTask = entry.nextTask { VStack(alignment: .leading, spacing: 6) { - Text("NEXT UP") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - .tracking(0.5) - Text(nextTask.title) - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) .lineLimit(1) - .foregroundStyle(.primary) + .foregroundStyle(Color.appTextPrimary) - HStack(spacing: 8) { + HStack(spacing: 6) { if let dueDate = nextTask.dueDate { - HStack(spacing: 4) { + HStack(spacing: 3) { Image(systemName: "calendar") - .font(.system(size: 9)) + .font(.system(size: 8, weight: .medium)) Text(formatWidgetDate(dueDate)) - .font(.system(size: 10, weight: .medium)) + .font(.system(size: 9, weight: .semibold, design: .rounded)) } - .foregroundStyle(nextTask.isOverdue ? .red : .orange) + .foregroundStyle(nextTask.isOverdue ? Color.appError : Color.appAccent) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule() + .fill((nextTask.isOverdue ? Color.appError : Color.appAccent).opacity(colorScheme == .dark ? 0.2 : 0.12)) + ) } Spacer() - // Complete button + // Organic complete button Button(intent: CompleteTaskIntent(taskId: nextTask.id, taskTitle: nextTask.title)) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 20)) - .foregroundStyle(.green) + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.15)) + .frame(width: 28, height: 28) + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color.appPrimary) + } } .buttonStyle(.plain) } } - .padding(.top, 8) - .padding(.horizontal, 10) - .padding(.vertical, 8) + .padding(OrganicSpacing.compact) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.blue.opacity(0.1)) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.12 : 0.08)) ) } else { - HStack { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 16)) - .foregroundStyle(.green) + // Empty state + HStack(spacing: 8) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.15)) + .frame(width: 24, height: 24) + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.appPrimary) + } Text("All caught up!") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) } .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 10) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.green.opacity(0.1)) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.1 : 0.06)) ) } } - .padding(16) + .padding(14) } } // MARK: - Medium Widget View struct MediumWidgetView: View { let entry: SimpleEntry + @Environment(\.colorScheme) var colorScheme var body: some View { - HStack(spacing: 16) { - // Left side - Task count - VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 0) { + // Left side - Task count with organic styling + VStack(alignment: .center, spacing: 4) { Spacer() - Text("\(entry.taskCount)") - .font(.system(size: 42, weight: .bold)) - .foregroundStyle(.blue) + ZStack { + // Soft glow + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 0, + endRadius: 35 + ) + ) + .frame(width: 70, height: 70) - VStack(alignment: .leading) { - Text("upcoming") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(2) - - Text(entry.taskCount == 1 ? "task" : "tasks") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(2) + Text("\(entry.taskCount)") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) } + Text(entry.taskCount == 1 ? "task" : "tasks") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) + Spacer() } - .frame(maxWidth: 75) + .frame(width: 85) - Divider() + // Organic divider + Rectangle() + .fill( + LinearGradient( + colors: [Color.appTextSecondary.opacity(0), Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 1) + .padding(.vertical, 12) - // Right side - Next tasks with interactive buttons + // Right side - Task list VStack(alignment: .leading, spacing: 6) { if entry.nextTask != nil { - Text("NEXT UP") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - .tracking(0.5) - ForEach(Array(entry.upcomingTasks.prefix(3).enumerated()), id: \.element.id) { index, task in - InteractiveTaskRowView(task: task) + OrganicTaskRowView(task: task, compact: true) } - - Spacer() + Spacer(minLength: 0) } else { Spacer() - + // Empty state VStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(.green) + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color.appPrimary) + } Text("All caught up!") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) } .frame(maxWidth: .infinity) - Spacer() } } .frame(maxWidth: .infinity) + .padding(.leading, 12) } - .padding(16) + .padding(14) } } -// MARK: - Interactive Task Row (for Medium widget) -struct InteractiveTaskRowView: View { +// MARK: - Organic Task Row View +struct OrganicTaskRowView: View { let task: CacheManager.CustomTask + var compact: Bool = false + var showResidence: Bool = false + @Environment(\.colorScheme) var colorScheme var body: some View { - HStack(spacing: 8) { - // Checkbox to complete task (color indicates priority) + HStack(spacing: compact ? 8 : 10) { + // Organic checkbox button Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) { - Image(systemName: "circle") - .font(.system(size: 18)) - .foregroundStyle(priorityColor) + ZStack { + Circle() + .stroke( + LinearGradient( + colors: [priorityColor, priorityColor.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 2 + ) + .frame(width: compact ? 18 : 22, height: compact ? 18 : 22) + + Circle() + .fill(priorityColor.opacity(colorScheme == .dark ? 0.15 : 0.1)) + .frame(width: compact ? 14 : 18, height: compact ? 14 : 18) + } } .buttonStyle(.plain) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: compact ? 2 : 3) { Text(task.title) - .font(.system(size: 11, weight: .semibold)) - .lineLimit(1) - .foregroundStyle(.primary) + .font(.system(size: compact ? 11 : 12, weight: .semibold, design: .rounded)) + .lineLimit(compact ? 1 : 2) + .foregroundStyle(Color.appTextPrimary) - if let dueDate = task.dueDate { - HStack(spacing: 3) { - Image(systemName: "calendar") - .font(.system(size: 8)) - Text(formatWidgetDate(dueDate)) - .font(.system(size: 9, weight: .medium)) + HStack(spacing: 8) { + if showResidence, let residenceName = task.residenceName, !residenceName.isEmpty { + HStack(spacing: 2) { + Image("icon") + .resizable() + .frame(width: 7, height: 7) + Text(residenceName) + .font(.system(size: 9, weight: .medium, design: .rounded)) + } + .foregroundStyle(Color.appTextSecondary) + } + + if let dueDate = task.dueDate { + HStack(spacing: 2) { + Image(systemName: "calendar") + .font(.system(size: 7, weight: .medium)) + Text(formatWidgetDate(dueDate)) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + } + .foregroundStyle(task.isOverdue ? Color.appError : Color.appAccent) } - .foregroundStyle(task.isOverdue ? .red : .secondary) } } - Spacer() + Spacer(minLength: 0) } - .padding(.vertical, 3) + .padding(.vertical, compact ? 4 : 6) + .padding(.horizontal, compact ? 6 : 8) + .background( + RoundedRectangle(cornerRadius: compact ? 10 : 12, style: .continuous) + .fill(priorityColor.opacity(colorScheme == .dark ? 0.12 : 0.06)) + ) } private var priorityColor: Color { - // Overdue tasks are always red if task.isOverdue { - return .red + return Color.appError } switch task.priority?.lowercased() { - case "urgent": return .red - case "high": return .orange - case "medium": return .yellow - default: return .green + case "urgent": return Color.appError + case "high": return Color.appAccent + case "medium": return Color(red: 0.92, green: 0.70, blue: 0.03) // Yellow + default: return Color.appPrimary } } } @@ -482,185 +604,137 @@ struct InteractiveTaskRowView: View { // MARK: - Large Widget View struct LargeWidgetView: View { let entry: SimpleEntry + @Environment(\.colorScheme) var colorScheme private var maxTasksToShow: Int { 5 } var body: some View { VStack(alignment: .leading, spacing: 0) { if entry.upcomingTasks.isEmpty { - // Empty state - centered + // Empty state - centered with organic styling Spacer() - VStack(spacing: 12) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 48)) - .foregroundStyle(.green) + VStack(spacing: 14) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.2), Color.appPrimary.opacity(0.05), Color.clear], + center: .center, + startRadius: 0, + endRadius: 40 + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + Text("All caught up!") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.secondary) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) } .frame(maxWidth: .infinity) Spacer() // Stats even when empty - LargeWidgetStatsView(entry: entry) + OrganicStatsView(entry: entry) } else { - // Tasks section - always at top + // Tasks section with organic rows VStack(alignment: .leading, spacing: 6) { ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in - LargeInteractiveTaskRowView(task: task) + OrganicTaskRowView(task: task, showResidence: true) } if entry.upcomingTasks.count > maxTasksToShow { Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 2) + .padding(.top, 4) } } - Spacer(minLength: 12) + Spacer(minLength: 10) // Stats section at bottom - LargeWidgetStatsView(entry: entry) + OrganicStatsView(entry: entry) } } .padding(14) } } -// MARK: - Large Widget Stats View -struct LargeWidgetStatsView: View { +// MARK: - Organic Stats View +struct OrganicStatsView: View { let entry: SimpleEntry - - var body: some View { - HStack(spacing: 0) { - // Overdue - StatItem( - value: entry.overdueCount, - label: "Overdue", - color: entry.overdueCount > 0 ? .red : .secondary - ) - - Divider() - .frame(height: 30) - - // Next 7 Days (exclusive of overdue) - StatItem( - value: entry.dueNext7DaysCount, - label: "7 Days", - color: .orange - ) - - Divider() - .frame(height: 30) - - // Next 30 Days (days 8-30) - StatItem( - value: entry.dueNext30DaysCount, - label: "30 Days", - color: .green - ) - } - .padding(.vertical, 10) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.primary.opacity(0.05)) - ) - } -} - -// MARK: - Stat Item View -struct StatItem: View { - let value: Int - let label: String - let color: Color - - var body: some View { - VStack(spacing: 2) { - Text("\(value)") - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(color) - - Text(label) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity) - } -} - -// MARK: - Large Interactive Task Row -struct LargeInteractiveTaskRowView: View { - let task: CacheManager.CustomTask + @Environment(\.colorScheme) var colorScheme var body: some View { HStack(spacing: 8) { - // Checkbox to complete task (color indicates priority) - Button(intent: CompleteTaskIntent(taskId: task.id, taskTitle: task.title)) { - Image(systemName: "circle") - .font(.system(size: 20)) - .foregroundStyle(priorityColor) - } - .buttonStyle(.plain) + // Overdue + OrganicStatPillWidget( + value: entry.overdueCount, + label: "Overdue", + color: entry.overdueCount > 0 ? Color.appError : Color.appTextSecondary + ) - VStack(alignment: .leading, spacing: 2) { - Text(task.title) - .font(.system(size: 12, weight: .medium)) - .lineLimit(2) - .foregroundStyle(.primary) + // Next 7 Days + OrganicStatPillWidget( + value: entry.dueNext7DaysCount, + label: "7 Days", + color: Color.appAccent + ) - HStack(spacing: 10) { - if let residenceName = task.residenceName, !residenceName.isEmpty { - HStack(spacing: 2) { - Image("icon") - .resizable() - .frame(width: 7, height: 7) - .font(.system(size: 7)) - Text(residenceName) - .font(.system(size: 9)) - } - .foregroundStyle(.secondary) - } - - if let dueDate = task.dueDate { - HStack(spacing: 2) { - Image(systemName: "calendar") - .font(.system(size: 7)) - Text(formatWidgetDate(dueDate)) - .font(.system(size: 9, weight: task.isOverdue ? .semibold : .regular)) - } - .foregroundStyle(task.isOverdue ? .red : .secondary) - } - } - } - - Spacer() + // Next 30 Days + OrganicStatPillWidget( + value: entry.dueNext30DaysCount, + label: "30 Days", + color: Color.appPrimary + ) } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.primary.opacity(0.05)) - ) } +} - private var priorityColor: Color { - // Overdue tasks are always red - if task.isOverdue { - return .red - } - switch task.priority?.lowercased() { - case "urgent": return .red - case "high": return .orange - case "medium": return .yellow - default: return .green +// MARK: - Organic Stat Pill for Widget +struct OrganicStatPillWidget: View { + let value: Int + let label: String + let color: Color + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: 3) { + Text("\(value)") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [color, color.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Text(label) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.appTextSecondary) + .lineLimit(1) } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(color.opacity(colorScheme == .dark ? 0.15 : 0.08)) + ) } } @@ -670,7 +744,22 @@ struct Casera: Widget { var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in CaseraEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) + .containerBackground(for: .widget) { + // Organic warm gradient background + ZStack { + Color.appBackgroundPrimary + + // Subtle accent gradient + LinearGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.clear + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } } .configurationDisplayName("Casera Tasks") .description("View and complete your upcoming tasks.") diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index b94cd7e..b1acb34 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -135,6 +135,9 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Assets.xcassets, + Design/DesignSystem.swift, + Design/OrganicDesign.swift, + Helpers/ThemeManager.swift, Shared/TaskStatsCalculator.swift, ); target = 1C07893C2EBC218B00392B46 /* CaseraExtension */; @@ -713,6 +716,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -747,6 +751,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-DWIDGET_EXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = com.tt.casera.CaseraDev.CaseraDev; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/iosApp/iosApp/Design/DesignSystem.swift b/iosApp/iosApp/Design/DesignSystem.swift index 6ab9057..bf478b7 100644 --- a/iosApp/iosApp/Design/DesignSystem.swift +++ b/iosApp/iosApp/Design/DesignSystem.swift @@ -8,12 +8,15 @@ import SwiftUI extension Color { // MARK: - Dynamic Theme Resolution private static func themed(_ name: String) -> Color { + // Both main app and widgets use the theme from ThemeManager + // Theme is shared via App Group UserDefaults let theme = ThemeManager.shared.currentTheme.rawValue return Color("\(theme)/\(name)", bundle: nil) } // MARK: - Semantic Colors (Use These in UI) // These dynamically resolve based on ThemeManager.shared.currentTheme + // Theme is shared between main app and widgets via App Group static var appPrimary: Color { themed("Primary") } static var appSecondary: Color { themed("Secondary") } static var appAccent: Color { themed("Accent") } diff --git a/iosApp/iosApp/Helpers/ThemeManager.swift b/iosApp/iosApp/Helpers/ThemeManager.swift index 7bd68e0..71dc0b2 100644 --- a/iosApp/iosApp/Helpers/ThemeManager.swift +++ b/iosApp/iosApp/Helpers/ThemeManager.swift @@ -1,4 +1,7 @@ import SwiftUI +#if !WIDGET_EXTENSION +import Combine +#endif // MARK: - Theme ID Enum enum ThemeID: String, CaseIterable, Codable { @@ -56,7 +59,31 @@ enum ThemeID: String, CaseIterable, Codable { } } +// MARK: - Shared App Group UserDefaults +private let appGroupID = "group.com.tt.casera.CaseraDev" +private let sharedDefaults = UserDefaults(suiteName: appGroupID) ?? UserDefaults.standard + // MARK: - Theme Manager +#if WIDGET_EXTENSION +// Simplified ThemeManager for widget extensions (no ObservableObject needed) +class ThemeManager { + static let shared = ThemeManager() + + var currentTheme: ThemeID { + // Load saved theme from shared App Group defaults + if let savedThemeRawValue = sharedDefaults.string(forKey: themeKey), + let savedTheme = ThemeID(rawValue: savedThemeRawValue) { + return savedTheme + } + return .bright + } + + private let themeKey = "selectedTheme" + + private init() {} +} +#else +// Full ThemeManager for main app with ObservableObject support class ThemeManager: ObservableObject { static let shared = ThemeManager() @@ -69,8 +96,8 @@ class ThemeManager: ObservableObject { private let themeKey = "selectedTheme" private init() { - // Load saved theme or default to .bright - if let savedThemeRawValue = UserDefaults.standard.string(forKey: themeKey), + // Load saved theme from shared App Group defaults + if let savedThemeRawValue = sharedDefaults.string(forKey: themeKey), let savedTheme = ThemeID(rawValue: savedThemeRawValue) { self.currentTheme = savedTheme } else { @@ -79,7 +106,8 @@ class ThemeManager: ObservableObject { } private func saveTheme() { - UserDefaults.standard.set(currentTheme.rawValue, forKey: themeKey) + // Save to shared App Group defaults so widgets can access it + sharedDefaults.set(currentTheme.rawValue, forKey: themeKey) } func setTheme(_ theme: ThemeID) { @@ -88,3 +116,4 @@ class ThemeManager: ObservableObject { } } } +#endif diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index 9b67158..b47801c 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -33,6 +33,11 @@ class ResidenceViewModel: ObservableObject { if myResidences != nil { self?.isLoading = false } + // Auto-update selectedResidence if it exists in the updated list + if let currentSelected = self?.selectedResidence, + let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) { + self?.selectedResidence = updatedResidence + } } .store(in: &cancellables) @@ -40,6 +45,11 @@ class ResidenceViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] residences in self?.residences = residences + // Auto-update selectedResidence if it exists in the updated list + if let currentSelected = self?.selectedResidence, + let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) { + self?.selectedResidence = updatedResidence + } } .store(in: &cancellables)