From bd4db0858767cb0ef7d1a2a538c7a78b8eae2805 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 23 Jan 2026 16:40:35 -0600 Subject: [PATCH] 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 --- CLAUDE.md | 41 ++++-- .../AccentColor.colorset/Contents.json | 27 ++++ .../Local/CoreData/CoreDataStack.swift | 124 ++++++++++++++++-- .../PlantGuideModel.xcdatamodel/contents | 64 ++++----- .../Presentation/Navigation/MainTabView.swift | 7 - 5 files changed, 203 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d83a6e3..5ba0c5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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)` diff --git a/PlantGuide/Assets.xcassets/AccentColor.colorset/Contents.json b/PlantGuide/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..2e519ea 100644 --- a/PlantGuide/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/PlantGuide/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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" } ], diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift index f8b79ae..d92eda6 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift @@ -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? + + /// 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) 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(_ 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 diff --git a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents index e727b5a..b07faa5 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents +++ b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents @@ -5,17 +5,17 @@ - - - - - + + + + + - + @@ -24,32 +24,32 @@ - - + + - + - - - - + + + + - + - + - - - + + + @@ -57,32 +57,32 @@ - + - - - + + + - + - + - - + + - + - - - - - + + + + + diff --git a/PlantGuide/Presentation/Navigation/MainTabView.swift b/PlantGuide/Presentation/Navigation/MainTabView.swift index d6ca12c..f83baed 100644 --- a/PlantGuide/Presentation/Navigation/MainTabView.swift +++ b/PlantGuide/Presentation/Navigation/MainTabView.swift @@ -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")