Fix Core Data CloudKit compatibility and update CLAUDE.md

- Make all Core Data attributes CloudKit-compatible (optional or with defaults)
- Fix CoreDataStack to wait for persistent store loading before operations
- Add AccentColor to asset catalog (green theme color)
- Remove Browse tab from navigation (keep underlying code)
- Update CLAUDE.md with current features, architecture, and tab structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 16:40:35 -06:00
parent 4fcec31c02
commit bd4db08587
5 changed files with 203 additions and 60 deletions

View File

@@ -18,6 +18,22 @@ xcodebuild test -project PlantGuide.xcodeproj -scheme PlantGuide -destination 'p
xcodebuild test -project PlantGuide.xcodeproj -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:PlantGuideTests/SavePlantUseCaseTests/testSavePlant_Success
```
## App Structure
**Tab Navigation**: Camera | Collection | Today | Settings
### Implemented Features
- **Plant Identification** - Camera-based plant ID using on-device ML + PlantNet API fallback
- **Plant Collection** - Save identified plants with care schedules
- **Plant Rooms/Zones** - Organize plants by room (Kitchen, Living Room, Bedroom, etc.)
- **Today View** - Dashboard showing overdue + today's care tasks grouped by room
- **Progress Photos** - Capture growth photos with time-lapse playback and photo reminders
- **Care Scheduling** - Watering, fertilizing, repotting, pruning, pest control tasks
- **Notifications** - Local notifications for care reminders and photo reminders
- **CloudKit Sync** - iCloud sync via NSPersistentCloudKitContainer
- **Dark Mode** - System-following color scheme with semantic color tokens
## Architecture
**Clean Architecture + MVVM** with three main layers:
@@ -35,8 +51,9 @@ Data (Repository Implementations + Data Sources)
- **Dependency Injection**: `DIContainer` (singleton) provides all dependencies via `LazyService<T>` wrappers
- **Use Cases**: Each user action is a separate use case class (e.g., `SavePlantUseCase`, `HybridIdentificationUseCase`)
- **Repository Pattern**: Protocols in Domain layer, implementations in Data layer
- **Actor-based Concurrency**: `PlantClassificationService` and Core Data access use actors for thread safety
- **Actor-based Concurrency**: `PlantClassificationService`, `NotificationService`, and Core Data background tasks use actors
- **ViewModels**: `@MainActor @Observable` classes, created via `DIContainer.makeXxxViewModel()`
- **Core Data + CloudKit**: `NSPersistentCloudKitContainer` with async store loading and `waitForStoreLoaded()` pattern
### Data Flow Example
@@ -51,16 +68,20 @@ CameraView → IdentificationViewModel → HybridIdentificationUseCase
- `App/` - Entry point, configuration, API keys
- `Core/DI/` - DIContainer and LazyService
- `Core/DesignSystem/` - Color tokens, animations, appearance management
- `Core/Services/` - NotificationService
- `Domain/` - Business logic: Entities, UseCases, RepositoryInterfaces
- `Data/` - Persistence: CoreData, Repositories, API services, Mappers
- `ML/` - Core ML inference service and image preprocessing
- `Presentation/` - SwiftUI views, ViewModels, navigation
- `Scenes/` - Feature-specific views (Camera, Collection, Today, Settings, ProgressPhotos, Rooms)
- `Common/` - Shared components and modifiers
## External Services
- **PlantNet API** (`my.plantnet.org`) - Plant identification via image upload (500 free/day)
- **Trefle API** (`trefle.io`) - Botanical care data (400K+ species)
- **Local Database** - `Resources/houseplants_list.json` (2,278 species)
- **CloudKit** - iCloud sync for plants, rooms, care tasks, and progress photos
API keys configured in `App/Configuration/APIKeys.swift` via environment variables.
@@ -71,16 +92,20 @@ PlantNet-300K ResNet50 for on-device plant classification:
- Output: 1,081 plant species probabilities
- Conversion scripts in `scripts/` directory
## Known Issues
## Core Data Model
See `Docs/ARCHITECTURE_REMEDIATION_PLAN.md` for pre-production fixes:
- `InMemoryPlantRepository` doesn't conform to `PlantRepositoryProtocol`
- `CoreDataStack` lazy var initialization isn't thread-safe
- `DIContainer` uses `unowned self` in closures (crash risk)
- SwiftData initialized but unused (Core Data is the actual persistence layer)
Entities (all CloudKit-compatible with optional attributes):
- `PlantMO` - Plant records with identification data
- `RoomMO` - User-created rooms/zones
- `CareScheduleMO` - Care schedule configuration per plant
- `CareTaskMO` - Individual care tasks (watering, fertilizing, etc.)
- `ProgressPhotoMO` - Progress photos with thumbnails
- `IdentificationMO` - Plant identification history
- `PlantCareInfoMO` - Cached care information from APIs
## Testing
- Unit tests in `PlantGuideTests/` with mock implementations in `Mocks/`
- Test fixtures available for `Plant`, `CareTask`, `PlantCareSchedule`
- Mock services: `MockPlantCollectionRepository`, `MockNetworkService`, etc.
- In-memory Core Data stack for test isolation: `CoreDataStack(inMemory: true)`

View File

@@ -1,6 +1,33 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.345",
"green" : "0.780",
"red" : "0.298"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.400",
"green" : "0.820",
"red" : "0.350"
}
},
"idiom" : "universal"
}
],

View File

@@ -111,6 +111,18 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
/// Serial queue for thread-safe operations
private let coreDataQueue = DispatchQueue(label: "com.plantguide.coredata", qos: .userInitiated)
/// Continuation for waiting on store loading to complete
private var storeLoadedContinuation: CheckedContinuation<Void, Error>?
/// Whether the persistent store has finished loading
private var isStoreLoaded = false
/// Lock for thread-safe access to store loading state
private let storeLoadLock = NSLock()
/// Error from store loading, if any
private var storeLoadError: Error?
// MARK: - Initialization
/// Initializes the Core Data stack
@@ -123,18 +135,70 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
) {
self.modelName = modelName
self.migrationConfig = migrationConfig
// Create and configure the container
self.persistentContainer = Self.createPersistentContainer(
modelName: modelName,
migrationConfig: migrationConfig
)
// Load stores after init completes (deferred to avoid self capture issues)
loadStoresAsync()
}
/// Loads persistent stores asynchronously after initialization
private func loadStoresAsync() {
persistentContainer.loadPersistentStores { [weak self] storeDescription, error in
guard let self = self else { return }
self.storeLoadLock.lock()
defer { self.storeLoadLock.unlock() }
if let error = error as NSError? {
// Log the error with full details
print("Core Data persistent store error: \(error), \(error.userInfo)")
self.storeLoadError = error
// Attempt migration recovery if needed
Self.attemptMigrationRecovery(container: self.persistentContainer, error: error)
} else {
print("Core Data persistent store loaded successfully")
}
self.isStoreLoaded = true
self.storeLoadedContinuation?.resume(returning: ())
self.storeLoadedContinuation = nil
}
}
/// Initializes for testing with in-memory store
/// - Parameter inMemory: If true, uses in-memory store for testing
convenience init(inMemory: Bool) {
self.init()
/// - Note: This initializer bypasses normal store loading for test isolation
init(inMemory: Bool) {
self.modelName = "PlantGuideModel"
self.migrationConfig = .default
if inMemory {
Self.setupInMemoryStore(for: persistentContainer)
// Create container without normal persistent store
let container = NSPersistentCloudKitContainer(name: modelName)
Self.setupInMemoryStore(for: container)
self.persistentContainer = container
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
// Mark as loaded immediately for in-memory
isStoreLoaded = true
} else {
// Use normal initialization
self.persistentContainer = Self.createPersistentContainer(
modelName: modelName,
migrationConfig: migrationConfig
)
loadStoresAsync()
}
}
@@ -160,16 +224,6 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
containerIdentifier: "iCloud.com.t-t.PlantGuide"
)
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// Log the error - in production, consider recovery strategies
print("Core Data persistent store error: \(error), \(error.userInfo)")
// Attempt migration recovery if needed
attemptMigrationRecovery(container: container, error: error)
}
}
// Configure view context for main thread usage
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
@@ -255,11 +309,55 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
return context
}
/// Waits for the persistent store to finish loading
/// - Throws: CoreDataError.failedToLoadPersistentStore if loading failed
func waitForStoreLoaded() async throws {
storeLoadLock.lock()
let alreadyLoaded = isStoreLoaded
let existingError = storeLoadError
storeLoadLock.unlock()
// If already loaded, check for error and return
if alreadyLoaded {
if let error = existingError {
throw CoreDataError.failedToLoadPersistentStore(error)
}
return
}
// Wait for store to load
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
storeLoadLock.lock()
defer { storeLoadLock.unlock() }
// Double-check in case it loaded while we were waiting for lock
if isStoreLoaded {
if let error = storeLoadError {
continuation.resume(throwing: CoreDataError.failedToLoadPersistentStore(error))
} else {
continuation.resume(returning: ())
}
return
}
// Store continuation to be called when loading completes
storeLoadedContinuation = continuation
}
// Check for error after resuming
if let error = storeLoadError {
throw CoreDataError.failedToLoadPersistentStore(error)
}
}
/// Performs a task on a background context asynchronously
/// - Parameter block: The block to execute with the background context
/// - Returns: The result of the block execution
/// - Throws: Any error thrown by the block or save operation
func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T {
// Wait for persistent store to be loaded before attempting any operations
try await waitForStoreLoaded()
let context = newBackgroundContext()
return try await withCheckedThrowingContinuation { continuation in

View File

@@ -5,17 +5,17 @@
<attribute name="confidenceScore" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
<attribute name="customName" optional="YES" attributeType="String"/>
<attribute name="dateAdded" optional="YES" attributeType="Date" usesScalarType="NO"/>
<attribute name="dateIdentified" attributeType="Date" usesScalarType="NO"/>
<attribute name="family" attributeType="String"/>
<attribute name="genus" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="identificationSource" attributeType="String"/>
<attribute name="dateIdentified" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
<attribute name="family" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="genus" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="identificationSource" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="imageURLs" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[URL]"/>
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
<attribute name="localImagePaths" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
<attribute name="location" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="scientificName" attributeType="String"/>
<attribute name="scientificName" optional="YES" attributeType="String" defaultValueString=""/>
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CareScheduleMO" inverseName="plant" inverseEntity="CareScheduleMO"/>
<relationship name="identifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="IdentificationMO" inverseName="plant" inverseEntity="IdentificationMO"/>
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
@@ -24,32 +24,32 @@
</entity>
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
<attribute name="date" attributeType="Date" usesScalarType="NO"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="latitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
<attribute name="longitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
<attribute name="source" attributeType="String"/>
<attribute name="source" optional="YES" attributeType="String" defaultValueString=""/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="identifications" inverseEntity="PlantMO"/>
</entity>
<entity name="CareScheduleMO" representedClassName="CareScheduleMO" syncable="YES">
<attribute name="fertilizerSchedule" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="lightRequirement" attributeType="String"/>
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
<attribute name="fertilizerSchedule" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="lightRequirement" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="temperatureMax" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<attribute name="temperatureMin" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<attribute name="wateringSchedule" attributeType="String"/>
<attribute name="wateringSchedule" optional="YES" attributeType="String" defaultValueString=""/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="careSchedule" inverseEntity="PlantMO"/>
<relationship name="tasks" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="CareTaskMO" inverseName="careSchedule" inverseEntity="CareTaskMO"/>
</entity>
<entity name="CareTaskMO" representedClassName="CareTaskMO" syncable="YES">
<attribute name="completedDate" optional="YES" attributeType="Date" usesScalarType="NO"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="notes" attributeType="String" defaultValueString=""/>
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
<attribute name="scheduledDate" attributeType="Date" usesScalarType="NO"/>
<attribute name="type" attributeType="String"/>
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="scheduledDate" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
<attribute name="type" optional="YES" attributeType="String" defaultValueString=""/>
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CareScheduleMO" inverseName="tasks" inverseEntity="CareScheduleMO"/>
</entity>
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
@@ -57,32 +57,32 @@
<attribute name="bloomingSeasonData" optional="YES" attributeType="Binary"/>
<attribute name="commonName" optional="YES" attributeType="String"/>
<attribute name="fertilizerScheduleData" optional="YES" attributeType="Binary"/>
<attribute name="fetchedAt" attributeType="Date" usesScalarType="NO"/>
<attribute name="fetchedAt" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
<attribute name="growthRate" optional="YES" attributeType="String"/>
<attribute name="humidity" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="lightRequirement" attributeType="String"/>
<attribute name="scientificName" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="lightRequirement" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="scientificName" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="sourceURL" optional="YES" attributeType="URI"/>
<attribute name="temperatureRangeData" attributeType="Binary"/>
<attribute name="temperatureRangeData" optional="YES" attributeType="Binary"/>
<attribute name="trefleID" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<attribute name="wateringScheduleData" attributeType="Binary"/>
<attribute name="wateringScheduleData" optional="YES" attributeType="Binary"/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
</entity>
<entity name="RoomMO" representedClassName="RoomMO" syncable="YES">
<attribute name="icon" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="icon" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="isDefault" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
<attribute name="name" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
<relationship name="plants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="room" inverseEntity="PlantMO"/>
</entity>
<entity name="ProgressPhotoMO" representedClassName="ProgressPhotoMO" syncable="YES">
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
<attribute name="imageData" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="thumbnailData" attributeType="Binary"/>
<attribute name="dateTaken" attributeType="Date" usesScalarType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="thumbnailData" optional="YES" attributeType="Binary"/>
<attribute name="dateTaken" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="progressPhotos" inverseEntity="PlantMO"/>
</entity>

View File

@@ -2,7 +2,6 @@ import SwiftUI
enum Tab: String, CaseIterable {
case camera
case browse
case collection
case today
case settings
@@ -20,12 +19,6 @@ struct MainTabView: View {
}
.tag(Tab.camera)
BrowsePlantsView(viewModel: DIContainer.shared.makeBrowsePlantsViewModel())
.tabItem {
Label("Browse", systemImage: "book.fill")
}
.tag(Tab.browse)
CollectionView()
.tabItem {
Label("Collection", systemImage: "leaf.fill")