Fix residence auto-update, widget theming, and document patterns
- 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 <noreply@anthropic.com>
This commit is contained in:
322
CLAUDE.md
322
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.
|
||||
|
||||
Reference in New Issue
Block a user