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
|
||||
```
|
||||
|
||||
## 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)`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user