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:
41
CLAUDE.md
41
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
|
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
|
## Architecture
|
||||||
|
|
||||||
**Clean Architecture + MVVM** with three main layers:
|
**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
|
- **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`)
|
- **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
|
- **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()`
|
- **ViewModels**: `@MainActor @Observable` classes, created via `DIContainer.makeXxxViewModel()`
|
||||||
|
- **Core Data + CloudKit**: `NSPersistentCloudKitContainer` with async store loading and `waitForStoreLoaded()` pattern
|
||||||
|
|
||||||
### Data Flow Example
|
### Data Flow Example
|
||||||
|
|
||||||
@@ -51,16 +68,20 @@ CameraView → IdentificationViewModel → HybridIdentificationUseCase
|
|||||||
|
|
||||||
- `App/` - Entry point, configuration, API keys
|
- `App/` - Entry point, configuration, API keys
|
||||||
- `Core/DI/` - DIContainer and LazyService
|
- `Core/DI/` - DIContainer and LazyService
|
||||||
|
- `Core/DesignSystem/` - Color tokens, animations, appearance management
|
||||||
|
- `Core/Services/` - NotificationService
|
||||||
- `Domain/` - Business logic: Entities, UseCases, RepositoryInterfaces
|
- `Domain/` - Business logic: Entities, UseCases, RepositoryInterfaces
|
||||||
- `Data/` - Persistence: CoreData, Repositories, API services, Mappers
|
- `Data/` - Persistence: CoreData, Repositories, API services, Mappers
|
||||||
- `ML/` - Core ML inference service and image preprocessing
|
- `ML/` - Core ML inference service and image preprocessing
|
||||||
- `Presentation/` - SwiftUI views, ViewModels, navigation
|
- `Presentation/` - SwiftUI views, ViewModels, navigation
|
||||||
|
- `Scenes/` - Feature-specific views (Camera, Collection, Today, Settings, ProgressPhotos, Rooms)
|
||||||
|
- `Common/` - Shared components and modifiers
|
||||||
|
|
||||||
## External Services
|
## External Services
|
||||||
|
|
||||||
- **PlantNet API** (`my.plantnet.org`) - Plant identification via image upload (500 free/day)
|
- **PlantNet API** (`my.plantnet.org`) - Plant identification via image upload (500 free/day)
|
||||||
- **Trefle API** (`trefle.io`) - Botanical care data (400K+ species)
|
- **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.
|
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
|
- Output: 1,081 plant species probabilities
|
||||||
- Conversion scripts in `scripts/` directory
|
- Conversion scripts in `scripts/` directory
|
||||||
|
|
||||||
## Known Issues
|
## Core Data Model
|
||||||
|
|
||||||
See `Docs/ARCHITECTURE_REMEDIATION_PLAN.md` for pre-production fixes:
|
Entities (all CloudKit-compatible with optional attributes):
|
||||||
- `InMemoryPlantRepository` doesn't conform to `PlantRepositoryProtocol`
|
- `PlantMO` - Plant records with identification data
|
||||||
- `CoreDataStack` lazy var initialization isn't thread-safe
|
- `RoomMO` - User-created rooms/zones
|
||||||
- `DIContainer` uses `unowned self` in closures (crash risk)
|
- `CareScheduleMO` - Care schedule configuration per plant
|
||||||
- SwiftData initialized but unused (Core Data is the actual persistence layer)
|
- `CareTaskMO` - Individual care tasks (watering, fertilizing, etc.)
|
||||||
|
- `ProgressPhotoMO` - Progress photos with thumbnails
|
||||||
|
- `IdentificationMO` - Plant identification history
|
||||||
|
- `PlantCareInfoMO` - Cached care information from APIs
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit tests in `PlantGuideTests/` with mock implementations in `Mocks/`
|
- Unit tests in `PlantGuideTests/` with mock implementations in `Mocks/`
|
||||||
- Test fixtures available for `Plant`, `CareTask`, `PlantCareSchedule`
|
- Test fixtures available for `Plant`, `CareTask`, `PlantCareSchedule`
|
||||||
- Mock services: `MockPlantCollectionRepository`, `MockNetworkService`, etc.
|
- Mock services: `MockPlantCollectionRepository`, `MockNetworkService`, etc.
|
||||||
|
- In-memory Core Data stack for test isolation: `CoreDataStack(inMemory: true)`
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
{
|
{
|
||||||
"colors" : [
|
"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"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
|||||||
/// Serial queue for thread-safe operations
|
/// Serial queue for thread-safe operations
|
||||||
private let coreDataQueue = DispatchQueue(label: "com.plantguide.coredata", qos: .userInitiated)
|
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
|
// MARK: - Initialization
|
||||||
|
|
||||||
/// Initializes the Core Data stack
|
/// Initializes the Core Data stack
|
||||||
@@ -123,18 +135,70 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
|||||||
) {
|
) {
|
||||||
self.modelName = modelName
|
self.modelName = modelName
|
||||||
self.migrationConfig = migrationConfig
|
self.migrationConfig = migrationConfig
|
||||||
|
|
||||||
|
// Create and configure the container
|
||||||
self.persistentContainer = Self.createPersistentContainer(
|
self.persistentContainer = Self.createPersistentContainer(
|
||||||
modelName: modelName,
|
modelName: modelName,
|
||||||
migrationConfig: migrationConfig
|
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
|
/// Initializes for testing with in-memory store
|
||||||
/// - Parameter inMemory: If true, uses in-memory store for testing
|
/// - Parameter inMemory: If true, uses in-memory store for testing
|
||||||
convenience init(inMemory: Bool) {
|
/// - Note: This initializer bypasses normal store loading for test isolation
|
||||||
self.init()
|
init(inMemory: Bool) {
|
||||||
|
self.modelName = "PlantGuideModel"
|
||||||
|
self.migrationConfig = .default
|
||||||
|
|
||||||
if inMemory {
|
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"
|
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
|
// Configure view context for main thread usage
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
@@ -255,11 +309,55 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
|||||||
return context
|
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
|
/// Performs a task on a background context asynchronously
|
||||||
/// - Parameter block: The block to execute with the background context
|
/// - Parameter block: The block to execute with the background context
|
||||||
/// - Returns: The result of the block execution
|
/// - Returns: The result of the block execution
|
||||||
/// - Throws: Any error thrown by the block or save operation
|
/// - Throws: Any error thrown by the block or save operation
|
||||||
func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T {
|
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()
|
let context = newBackgroundContext()
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
|||||||
@@ -5,17 +5,17 @@
|
|||||||
<attribute name="confidenceScore" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
<attribute name="confidenceScore" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
||||||
<attribute name="customName" optional="YES" attributeType="String"/>
|
<attribute name="customName" optional="YES" attributeType="String"/>
|
||||||
<attribute name="dateAdded" optional="YES" attributeType="Date" usesScalarType="NO"/>
|
<attribute name="dateAdded" optional="YES" attributeType="Date" usesScalarType="NO"/>
|
||||||
<attribute name="dateIdentified" attributeType="Date" usesScalarType="NO"/>
|
<attribute name="dateIdentified" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
|
||||||
<attribute name="family" attributeType="String"/>
|
<attribute name="family" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="genus" attributeType="String"/>
|
<attribute name="genus" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="identificationSource" attributeType="String"/>
|
<attribute name="identificationSource" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="imageURLs" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[URL]"/>
|
<attribute name="imageURLs" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[URL]"/>
|
||||||
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
|
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
|
||||||
<attribute name="localImagePaths" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
<attribute name="localImagePaths" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||||
<attribute name="location" optional="YES" attributeType="String"/>
|
<attribute name="location" optional="YES" attributeType="String"/>
|
||||||
<attribute name="notes" 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="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="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"/>
|
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
|
||||||
@@ -24,32 +24,32 @@
|
|||||||
</entity>
|
</entity>
|
||||||
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
||||||
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
|
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
|
||||||
<attribute name="date" attributeType="Date" usesScalarType="NO"/>
|
<attribute name="date" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
||||||
<attribute name="latitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
<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="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"/>
|
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="identifications" inverseEntity="PlantMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="CareScheduleMO" representedClassName="CareScheduleMO" syncable="YES">
|
<entity name="CareScheduleMO" representedClassName="CareScheduleMO" syncable="YES">
|
||||||
<attribute name="fertilizerSchedule" attributeType="String"/>
|
<attribute name="fertilizerSchedule" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="lightRequirement" attributeType="String"/>
|
<attribute name="lightRequirement" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="temperatureMax" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
<attribute name="temperatureMax" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
||||||
<attribute name="temperatureMin" 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="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"/>
|
<relationship name="tasks" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="CareTaskMO" inverseName="careSchedule" inverseEntity="CareTaskMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="CareTaskMO" representedClassName="CareTaskMO" syncable="YES">
|
<entity name="CareTaskMO" representedClassName="CareTaskMO" syncable="YES">
|
||||||
<attribute name="completedDate" optional="YES" attributeType="Date" usesScalarType="NO"/>
|
<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="notes" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="scheduledDate" attributeType="Date" usesScalarType="NO"/>
|
<attribute name="scheduledDate" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
|
||||||
<attribute name="type" attributeType="String"/>
|
<attribute name="type" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CareScheduleMO" inverseName="tasks" inverseEntity="CareScheduleMO"/>
|
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CareScheduleMO" inverseName="tasks" inverseEntity="CareScheduleMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
|
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
|
||||||
@@ -57,32 +57,32 @@
|
|||||||
<attribute name="bloomingSeasonData" optional="YES" attributeType="Binary"/>
|
<attribute name="bloomingSeasonData" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="commonName" optional="YES" attributeType="String"/>
|
<attribute name="commonName" optional="YES" attributeType="String"/>
|
||||||
<attribute name="fertilizerScheduleData" optional="YES" attributeType="Binary"/>
|
<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="growthRate" optional="YES" attributeType="String"/>
|
||||||
<attribute name="humidity" optional="YES" attributeType="String"/>
|
<attribute name="humidity" optional="YES" attributeType="String"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="lightRequirement" attributeType="String"/>
|
<attribute name="lightRequirement" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="scientificName" attributeType="String"/>
|
<attribute name="scientificName" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="sourceURL" optional="YES" attributeType="URI"/>
|
<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="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"/>
|
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="RoomMO" representedClassName="RoomMO" syncable="YES">
|
<entity name="RoomMO" representedClassName="RoomMO" syncable="YES">
|
||||||
<attribute name="icon" attributeType="String"/>
|
<attribute name="icon" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="isDefault" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
|
<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"/>
|
<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"/>
|
<relationship name="plants" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="room" inverseEntity="PlantMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="ProgressPhotoMO" representedClassName="ProgressPhotoMO" syncable="YES">
|
<entity name="ProgressPhotoMO" representedClassName="ProgressPhotoMO" syncable="YES">
|
||||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
<attribute name="plantID" optional="YES" attributeType="UUID" usesScalarType="NO"/>
|
||||||
<attribute name="imageData" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
||||||
<attribute name="thumbnailData" attributeType="Binary"/>
|
<attribute name="thumbnailData" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="dateTaken" attributeType="Date" usesScalarType="NO"/>
|
<attribute name="dateTaken" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarType="NO"/>
|
||||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||||
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="progressPhotos" inverseEntity="PlantMO"/>
|
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="progressPhotos" inverseEntity="PlantMO"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import SwiftUI
|
|||||||
|
|
||||||
enum Tab: String, CaseIterable {
|
enum Tab: String, CaseIterable {
|
||||||
case camera
|
case camera
|
||||||
case browse
|
|
||||||
case collection
|
case collection
|
||||||
case today
|
case today
|
||||||
case settings
|
case settings
|
||||||
@@ -20,12 +19,6 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.tag(Tab.camera)
|
.tag(Tab.camera)
|
||||||
|
|
||||||
BrowsePlantsView(viewModel: DIContainer.shared.makeBrowsePlantsViewModel())
|
|
||||||
.tabItem {
|
|
||||||
Label("Browse", systemImage: "book.fill")
|
|
||||||
}
|
|
||||||
.tag(Tab.browse)
|
|
||||||
|
|
||||||
CollectionView()
|
CollectionView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Collection", systemImage: "leaf.fill")
|
Label("Collection", systemImage: "leaf.fill")
|
||||||
|
|||||||
Reference in New Issue
Block a user