Add Plant Rooms/Zones feature for organizing plants by location
Implements Phase 3 of the feature roadmap: - Room entity with 7 default rooms (Kitchen, Living Room, Bedroom, etc.) - RoomRepositoryProtocol and CoreDataRoomRepository for persistence - CreateDefaultRoomsUseCase and ManageRoomsUseCase for CRUD operations - RoomsListView with swipe-to-delete and drag-to-reorder - RoomEditorView with SF Symbol icon picker - RoomPickerView for assigning plants to rooms - Updated Plant entity (location → roomID) and PlantDetailView - Added "Manage Rooms" section in SettingsView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,35 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Room Services
|
||||||
|
|
||||||
|
private lazy var _coreDataRoomStorage: LazyService<CoreDataRoomRepository> = {
|
||||||
|
LazyService {
|
||||||
|
CoreDataRoomRepository(coreDataStack: CoreDataStack.shared)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var _createDefaultRoomsUseCase: LazyService<CreateDefaultRoomsUseCase> = {
|
||||||
|
LazyService { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
fatalError("DIContainer deallocated unexpectedly")
|
||||||
|
}
|
||||||
|
return CreateDefaultRoomsUseCase(roomRepository: self.roomRepository)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var _manageRoomsUseCase: LazyService<ManageRoomsUseCase> = {
|
||||||
|
LazyService { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
fatalError("DIContainer deallocated unexpectedly")
|
||||||
|
}
|
||||||
|
return ManageRoomsUseCase(
|
||||||
|
roomRepository: self.roomRepository,
|
||||||
|
plantRepository: self.plantCollectionRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Local Plant Database Services
|
// MARK: - Local Plant Database Services
|
||||||
|
|
||||||
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
|
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
|
||||||
@@ -270,6 +299,23 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
_coreDataPlantCareInfoStorage.value
|
_coreDataPlantCareInfoStorage.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Room repository backed by Core Data
|
||||||
|
var roomRepository: RoomRepositoryProtocol {
|
||||||
|
_coreDataRoomStorage.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Room Use Cases
|
||||||
|
|
||||||
|
/// Use case for creating default rooms on first app launch
|
||||||
|
var createDefaultRoomsUseCase: CreateDefaultRoomsUseCaseProtocol {
|
||||||
|
_createDefaultRoomsUseCase.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case for managing rooms (CRUD operations)
|
||||||
|
var manageRoomsUseCase: ManageRoomsUseCaseProtocol {
|
||||||
|
_manageRoomsUseCase.value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@@ -498,6 +544,11 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
BrowsePlantsViewModel(databaseService: plantDatabaseService)
|
BrowsePlantsViewModel(databaseService: plantDatabaseService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Factory method for RoomsViewModel
|
||||||
|
func makeRoomsViewModel() -> RoomsViewModel {
|
||||||
|
RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Custom Registration
|
// MARK: - Custom Registration
|
||||||
|
|
||||||
/// Register a custom factory for a type
|
/// Register a custom factory for a type
|
||||||
@@ -559,6 +610,10 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
|
|||||||
// Identification use cases
|
// Identification use cases
|
||||||
_identifyPlantOnDeviceUseCase.reset()
|
_identifyPlantOnDeviceUseCase.reset()
|
||||||
_identifyPlantOnlineUseCase.reset()
|
_identifyPlantOnlineUseCase.reset()
|
||||||
|
// Room services
|
||||||
|
_coreDataRoomStorage.reset()
|
||||||
|
_createDefaultRoomsUseCase.reset()
|
||||||
|
_manageRoomsUseCase.reset()
|
||||||
factories.removeAll()
|
factories.removeAll()
|
||||||
resolvedInstances.removeAll()
|
resolvedInstances.removeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
if let existingPlant = existingPlants.first {
|
if let existingPlant = existingPlants.first {
|
||||||
// Update existing plant
|
// Update existing plant
|
||||||
existingPlant.updateFromPlant(plant)
|
existingPlant.updateFromPlant(plant)
|
||||||
Self.updateMutableFields(on: existingPlant, from: plant)
|
Self.updateMutableFields(on: existingPlant, from: plant, in: context)
|
||||||
} else {
|
} else {
|
||||||
// Create new plant
|
// Create new plant
|
||||||
guard let entity = NSEntityDescription.entity(forEntityName: plantEntityName, in: context) else {
|
guard let entity = NSEntityDescription.entity(forEntityName: plantEntityName, in: context) else {
|
||||||
@@ -106,7 +106,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
|
|
||||||
let managedObject = NSManagedObject(entity: entity, insertInto: context)
|
let managedObject = NSManagedObject(entity: entity, insertInto: context)
|
||||||
managedObject.updateFromPlant(plant)
|
managedObject.updateFromPlant(plant)
|
||||||
Self.updateMutableFields(on: managedObject, from: plant)
|
Self.updateMutableFields(on: managedObject, from: plant, in: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ()
|
return ()
|
||||||
@@ -315,7 +315,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingPlant.updateFromPlant(plant)
|
existingPlant.updateFromPlant(plant)
|
||||||
Self.updateMutableFields(on: existingPlant, from: plant)
|
Self.updateMutableFields(on: existingPlant, from: plant, in: context)
|
||||||
return ()
|
return ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,14 +442,28 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - managedObject: The managed object to update
|
/// - managedObject: The managed object to update
|
||||||
/// - plant: The plant domain entity with updated values
|
/// - plant: The plant domain entity with updated values
|
||||||
private static func updateMutableFields(on managedObject: NSManagedObject, from plant: Plant) {
|
/// - context: The managed object context for fetching related objects
|
||||||
|
private static func updateMutableFields(on managedObject: NSManagedObject, from plant: Plant, in context: NSManagedObjectContext) {
|
||||||
managedObject.setValue(plant.localImagePaths as NSArray, forKey: "localImagePaths")
|
managedObject.setValue(plant.localImagePaths as NSArray, forKey: "localImagePaths")
|
||||||
managedObject.setValue(plant.dateAdded, forKey: "dateAdded")
|
managedObject.setValue(plant.dateAdded, forKey: "dateAdded")
|
||||||
managedObject.setValue(plant.confidenceScore, forKey: "confidenceScore")
|
managedObject.setValue(plant.confidenceScore, forKey: "confidenceScore")
|
||||||
managedObject.setValue(plant.notes, forKey: "notes")
|
managedObject.setValue(plant.notes, forKey: "notes")
|
||||||
managedObject.setValue(plant.isFavorite, forKey: "isFavorite")
|
managedObject.setValue(plant.isFavorite, forKey: "isFavorite")
|
||||||
managedObject.setValue(plant.customName, forKey: "customName")
|
managedObject.setValue(plant.customName, forKey: "customName")
|
||||||
managedObject.setValue(plant.location, forKey: "location")
|
|
||||||
|
// Handle room relationship
|
||||||
|
if let roomID = plant.roomID {
|
||||||
|
// Fetch the RoomMO for the given roomID
|
||||||
|
let roomFetchRequest = RoomMO.fetchRequest()
|
||||||
|
roomFetchRequest.predicate = NSPredicate(format: "id == %@", roomID as CVarArg)
|
||||||
|
roomFetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
if let roomMO = try? context.fetch(roomFetchRequest).first {
|
||||||
|
managedObject.setValue(roomMO, forKey: "room")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
managedObject.setValue(nil, forKey: "room")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts a managed object to a Plant domain entity
|
/// Converts a managed object to a Plant domain entity
|
||||||
@@ -479,7 +493,14 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
let notes = managedObject.value(forKey: "notes") as? String
|
let notes = managedObject.value(forKey: "notes") as? String
|
||||||
let isFavorite = (managedObject.value(forKey: "isFavorite") as? Bool) ?? false
|
let isFavorite = (managedObject.value(forKey: "isFavorite") as? Bool) ?? false
|
||||||
let customName = managedObject.value(forKey: "customName") as? String
|
let customName = managedObject.value(forKey: "customName") as? String
|
||||||
let location = managedObject.value(forKey: "location") as? String
|
|
||||||
|
// Get roomID from the room relationship
|
||||||
|
let roomID: UUID?
|
||||||
|
if let roomMO = managedObject.value(forKey: "room") as? RoomMO {
|
||||||
|
roomID = roomMO.id
|
||||||
|
} else {
|
||||||
|
roomID = nil
|
||||||
|
}
|
||||||
|
|
||||||
return Plant(
|
return Plant(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -496,7 +517,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla
|
|||||||
notes: notes,
|
notes: notes,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
customName: customName,
|
customName: customName,
|
||||||
location: location
|
roomID: roomID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//
|
||||||
|
// CoreDataRoomRepository.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Core Data implementation of room repository.
|
||||||
|
// Provides persistent storage for plant rooms/zones.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Core Data Room Repository
|
||||||
|
|
||||||
|
/// Core Data-backed implementation of the room repository.
|
||||||
|
/// Handles all persistent storage operations for plant rooms/zones.
|
||||||
|
final class CoreDataRoomRepository: RoomRepositoryProtocol, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// The Core Data stack used for persistence operations
|
||||||
|
private let coreDataStack: CoreDataStackProtocol
|
||||||
|
|
||||||
|
/// Entity name for room managed objects
|
||||||
|
private let roomEntityName = "RoomMO"
|
||||||
|
|
||||||
|
/// Entity name for plant managed objects
|
||||||
|
private let plantEntityName = "PlantMO"
|
||||||
|
|
||||||
|
/// The name of the "Other" room that cannot be deleted
|
||||||
|
private let otherRoomName = "Other"
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates a new Core Data room repository instance
|
||||||
|
/// - Parameter coreDataStack: The Core Data stack to use for persistence
|
||||||
|
init(coreDataStack: CoreDataStackProtocol = CoreDataStack.shared) {
|
||||||
|
self.coreDataStack = coreDataStack
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RoomRepositoryProtocol
|
||||||
|
|
||||||
|
/// Fetches all rooms from Core Data, sorted by sortOrder
|
||||||
|
/// - Returns: An array of all stored rooms
|
||||||
|
/// - Throws: RoomStorageError if the fetch operation fails
|
||||||
|
func fetchAll() async throws -> [Room] {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "sortOrder", ascending: true)]
|
||||||
|
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
return results.map { $0.toDomainModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a room by its unique identifier
|
||||||
|
/// - Parameter id: The unique identifier of the room
|
||||||
|
/// - Returns: The room if found, or nil if no room exists with the given ID
|
||||||
|
/// - Throws: RoomStorageError if the fetch operation fails
|
||||||
|
func fetch(id: UUID) async throws -> Room? {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
return results.first?.toDomainModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the "Other" room, which is the default fallback room.
|
||||||
|
/// Creates default rooms if they don't exist.
|
||||||
|
/// - Returns: The "Other" room entity.
|
||||||
|
/// - Throws: An error if the fetch or creation operation fails.
|
||||||
|
func fetchOtherRoom() async throws -> Room {
|
||||||
|
// Ensure default rooms exist
|
||||||
|
try await createDefaultRoomsIfNeeded()
|
||||||
|
|
||||||
|
return try await coreDataStack.performBackgroundTask { [otherRoomName] context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "name == %@", otherRoomName)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
guard let otherRoom = results.first else {
|
||||||
|
// This should never happen if createDefaultRoomsIfNeeded worked correctly
|
||||||
|
throw RoomStorageError.roomNotFound(UUID())
|
||||||
|
}
|
||||||
|
|
||||||
|
return otherRoom.toDomainModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a new room to Core Data
|
||||||
|
/// - Parameter room: The room entity to save
|
||||||
|
/// - Throws: RoomStorageError if the save operation fails
|
||||||
|
func save(_ room: Room) async throws {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
// Check if room already exists
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", room.id as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let existingRooms = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
if let existingRoom = existingRooms.first {
|
||||||
|
// Update existing room
|
||||||
|
existingRoom.update(from: room)
|
||||||
|
} else {
|
||||||
|
// Create new room
|
||||||
|
_ = RoomMO.fromDomainModel(room, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing room in Core Data
|
||||||
|
/// - Parameter room: The room with updated values to save
|
||||||
|
/// - Throws: RoomStorageError if the update operation fails or if the room is not found
|
||||||
|
func update(_ room: Room) async throws {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", room.id as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
guard let existingRoom = results.first else {
|
||||||
|
throw RoomStorageError.roomNotFound(room.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRoom.update(from: room)
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a room by its unique identifier
|
||||||
|
/// The "Other" room cannot be deleted. When a room is deleted, plants
|
||||||
|
/// in that room will have their room reference set to nil (nullified).
|
||||||
|
/// - Parameter id: The unique identifier of the room to delete
|
||||||
|
/// - Throws: RoomStorageError if the delete operation fails or if attempting to delete the "Other" room
|
||||||
|
func delete(id: UUID) async throws {
|
||||||
|
try await coreDataStack.performBackgroundTask { [otherRoomName] context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
guard let roomToDelete = results.first else {
|
||||||
|
throw RoomStorageError.roomNotFound(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of the "Other" room specifically
|
||||||
|
if roomToDelete.name == otherRoomName {
|
||||||
|
throw RoomStorageError.cannotDeleteOtherRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
// The relationship has Nullify deletion rule, so plants will have
|
||||||
|
// their room reference set to nil automatically
|
||||||
|
context.delete(roomToDelete)
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a room exists with the given ID
|
||||||
|
/// - Parameter id: The unique identifier of the room
|
||||||
|
/// - Returns: True if the room exists, false otherwise
|
||||||
|
/// - Throws: RoomStorageError if the check operation fails
|
||||||
|
func exists(id: UUID) async throws -> Bool {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let count = try context.count(for: fetchRequest)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the count of plants in a specific room.
|
||||||
|
/// - Parameter roomID: The unique identifier of the room.
|
||||||
|
/// - Returns: The number of plants in the room.
|
||||||
|
/// - Throws: An error if the count operation fails.
|
||||||
|
func plantCount(for roomID: UUID) async throws -> Int {
|
||||||
|
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||||
|
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "room.id == %@", roomID as CVarArg)
|
||||||
|
|
||||||
|
let count = try context.count(for: fetchRequest)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the default rooms if no rooms exist yet
|
||||||
|
/// - Throws: RoomStorageError if the creation operation fails
|
||||||
|
func createDefaultRoomsIfNeeded() async throws {
|
||||||
|
try await coreDataStack.performBackgroundTask { context in
|
||||||
|
// Check if any rooms exist
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let count = try context.count(for: fetchRequest)
|
||||||
|
|
||||||
|
guard count == 0 else {
|
||||||
|
// Rooms already exist, do nothing
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all default rooms
|
||||||
|
for room in Room.defaultRooms {
|
||||||
|
_ = RoomMO.fromDomainModel(room, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension CoreDataRoomRepository {
|
||||||
|
/// Creates a repository instance with an in-memory Core Data stack for testing
|
||||||
|
/// - Returns: A CoreDataRoomRepository instance backed by an in-memory store
|
||||||
|
static func inMemoryRepository() -> CoreDataRoomRepository {
|
||||||
|
return CoreDataRoomRepository(coreDataStack: CoreDataStack.inMemoryStack())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -68,6 +68,9 @@ public class PlantMO: NSManagedObject {
|
|||||||
|
|
||||||
/// Cached care information from Trefle API (optional, one-to-one, cascade delete)
|
/// Cached care information from Trefle API (optional, one-to-one, cascade delete)
|
||||||
@NSManaged public var plantCareInfo: PlantCareInfoMO?
|
@NSManaged public var plantCareInfo: PlantCareInfoMO?
|
||||||
|
|
||||||
|
/// The room where this plant is located (optional, to-one)
|
||||||
|
@NSManaged public var room: RoomMO?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Domain Model Conversion
|
// MARK: - Domain Model Conversion
|
||||||
@@ -94,7 +97,7 @@ extension PlantMO {
|
|||||||
notes: notes,
|
notes: notes,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
customName: customName,
|
customName: customName,
|
||||||
location: location
|
roomID: room?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,13 +123,16 @@ extension PlantMO {
|
|||||||
plantMO.notes = plant.notes
|
plantMO.notes = plant.notes
|
||||||
plantMO.isFavorite = plant.isFavorite
|
plantMO.isFavorite = plant.isFavorite
|
||||||
plantMO.customName = plant.customName
|
plantMO.customName = plant.customName
|
||||||
plantMO.location = plant.location
|
// Note: roomID is stored via the room relationship
|
||||||
|
// The room relationship should be set separately when saving
|
||||||
|
// plantMO.room is set by the repository based on roomID
|
||||||
|
|
||||||
return plantMO
|
return plantMO
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates this managed object with values from a Plant domain model.
|
/// Updates this managed object with values from a Plant domain model.
|
||||||
/// - Parameter plant: The Plant domain entity to update from.
|
/// - Parameter plant: The Plant domain entity to update from.
|
||||||
|
/// - Note: The room relationship should be set separately via setRoom(id:context:).
|
||||||
func update(from plant: Plant) {
|
func update(from plant: Plant) {
|
||||||
id = plant.id
|
id = plant.id
|
||||||
scientificName = plant.scientificName
|
scientificName = plant.scientificName
|
||||||
@@ -142,7 +148,28 @@ extension PlantMO {
|
|||||||
notes = plant.notes
|
notes = plant.notes
|
||||||
isFavorite = plant.isFavorite
|
isFavorite = plant.isFavorite
|
||||||
customName = plant.customName
|
customName = plant.customName
|
||||||
location = plant.location
|
// Note: roomID is stored via the room relationship
|
||||||
|
// The room relationship should be set separately when saving
|
||||||
|
// The room relationship is set by the repository based on roomID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the room relationship by looking up the room by ID.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - roomID: The ID of the room to associate, or nil to clear the room.
|
||||||
|
/// - context: The managed object context to use for the lookup.
|
||||||
|
func setRoom(id roomID: UUID?, context: NSManagedObjectContext) {
|
||||||
|
guard let roomID = roomID else {
|
||||||
|
room = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchRequest = RoomMO.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", roomID as CVarArg)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
if let roomMO = try? context.fetch(fetchRequest).first {
|
||||||
|
room = roomMO
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//
|
||||||
|
// RoomMO.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Core Data managed object representing a Room entity.
|
||||||
|
// Maps to the Room domain model for persistence.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - RoomMO
|
||||||
|
|
||||||
|
/// Core Data managed object representing a Room entity.
|
||||||
|
/// Maps to the Room domain model for persistence.
|
||||||
|
@objc(RoomMO)
|
||||||
|
public class RoomMO: NSManagedObject {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// Unique identifier for the room
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
|
||||||
|
/// The display name of the room (e.g., "Kitchen", "Living Room")
|
||||||
|
@NSManaged public var name: String
|
||||||
|
|
||||||
|
/// SF Symbol name for the room's icon (e.g., "refrigerator", "sofa")
|
||||||
|
@NSManaged public var icon: String
|
||||||
|
|
||||||
|
/// Position in the sorted list of rooms (lower values appear first)
|
||||||
|
@NSManaged public var sortOrder: Int32
|
||||||
|
|
||||||
|
/// System-created rooms cannot be deleted by the user
|
||||||
|
@NSManaged public var isDefault: Bool
|
||||||
|
|
||||||
|
// MARK: - Relationships
|
||||||
|
|
||||||
|
/// The plants located in this room (one-to-many)
|
||||||
|
@NSManaged public var plants: NSSet?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generated Accessors for Plants
|
||||||
|
|
||||||
|
extension RoomMO {
|
||||||
|
|
||||||
|
@objc(addPlantsObject:)
|
||||||
|
@NSManaged public func addToPlants(_ value: PlantMO)
|
||||||
|
|
||||||
|
@objc(removePlantsObject:)
|
||||||
|
@NSManaged public func removeFromPlants(_ value: PlantMO)
|
||||||
|
|
||||||
|
@objc(addPlants:)
|
||||||
|
@NSManaged public func addToPlants(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removePlants:)
|
||||||
|
@NSManaged public func removeFromPlants(_ values: NSSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Domain Model Conversion
|
||||||
|
|
||||||
|
extension RoomMO {
|
||||||
|
|
||||||
|
/// Converts this managed object to a Room domain model.
|
||||||
|
/// - Returns: A Room domain entity populated with this managed object's data.
|
||||||
|
func toDomainModel() -> Room {
|
||||||
|
return Room(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
icon: icon,
|
||||||
|
sortOrder: Int(sortOrder),
|
||||||
|
isDefault: isDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a RoomMO managed object from a Room domain model.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - room: The Room domain entity to convert.
|
||||||
|
/// - context: The managed object context to create the object in.
|
||||||
|
/// - Returns: A new RoomMO instance populated with the room's data.
|
||||||
|
static func fromDomainModel(_ room: Room, context: NSManagedObjectContext) -> RoomMO {
|
||||||
|
let roomMO = RoomMO(context: context)
|
||||||
|
|
||||||
|
roomMO.id = room.id
|
||||||
|
roomMO.name = room.name
|
||||||
|
roomMO.icon = room.icon
|
||||||
|
roomMO.sortOrder = Int32(room.sortOrder)
|
||||||
|
roomMO.isDefault = room.isDefault
|
||||||
|
|
||||||
|
return roomMO
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates this managed object with values from a Room domain model.
|
||||||
|
/// - Parameter room: The Room domain entity to update from.
|
||||||
|
func update(from room: Room) {
|
||||||
|
id = room.id
|
||||||
|
name = room.name
|
||||||
|
icon = room.icon
|
||||||
|
sortOrder = Int32(room.sortOrder)
|
||||||
|
isDefault = room.isDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch Request
|
||||||
|
|
||||||
|
extension RoomMO {
|
||||||
|
|
||||||
|
/// Creates a fetch request for RoomMO entities.
|
||||||
|
/// - Returns: A configured NSFetchRequest for RoomMO.
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<RoomMO> {
|
||||||
|
return NSFetchRequest<RoomMO>(entityName: "RoomMO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<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"/>
|
||||||
|
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RoomMO" inverseName="plants" inverseEntity="RoomMO"/>
|
||||||
</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"/>
|
||||||
@@ -67,4 +68,12 @@
|
|||||||
<attribute name="wateringScheduleData" attributeType="Binary"/>
|
<attribute name="wateringScheduleData" 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">
|
||||||
|
<attribute name="icon" attributeType="String"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||||
|
<attribute name="isDefault" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
|
||||||
|
<attribute name="name" attributeType="String"/>
|
||||||
|
<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>
|
||||||
</model>
|
</model>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ struct PredictionToPlantMapper {
|
|||||||
notes: nil,
|
notes: nil,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
customName: nil,
|
customName: nil,
|
||||||
location: nil
|
roomID: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
PlantGuide/Data/Mappers/RoomMapper.swift
Normal file
101
PlantGuide/Data/Mappers/RoomMapper.swift
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// RoomMapper.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Mapper for converting between Room domain entities and RoomMO managed objects.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - RoomMapper
|
||||||
|
|
||||||
|
/// Maps between Room domain entities and RoomMO Core Data managed objects.
|
||||||
|
///
|
||||||
|
/// This mapper provides conversion functions for transforming between the
|
||||||
|
/// Room domain model used in the business logic layer and the RoomMO
|
||||||
|
/// managed object used for Core Data persistence.
|
||||||
|
struct RoomMapper {
|
||||||
|
|
||||||
|
// MARK: - Domain to Managed Object
|
||||||
|
|
||||||
|
/// Creates a new RoomMO managed object from a Room domain entity.
|
||||||
|
///
|
||||||
|
/// This function creates a new managed object in the provided context
|
||||||
|
/// and populates it with data from the domain entity.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - room: The Room domain entity to convert.
|
||||||
|
/// - context: The managed object context to create the object in.
|
||||||
|
/// - Returns: A new RoomMO instance populated with the room's data.
|
||||||
|
static func toManagedObject(_ room: Room, in context: NSManagedObjectContext) -> RoomMO {
|
||||||
|
let roomMO = RoomMO(context: context)
|
||||||
|
populate(roomMO, from: room)
|
||||||
|
return roomMO
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates a RoomMO managed object with data from a Room domain entity.
|
||||||
|
///
|
||||||
|
/// Use this function to update an existing managed object with new values
|
||||||
|
/// from a domain entity.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - roomMO: The managed object to populate.
|
||||||
|
/// - room: The Room domain entity to get data from.
|
||||||
|
static func populate(_ roomMO: RoomMO, from room: Room) {
|
||||||
|
roomMO.id = room.id
|
||||||
|
roomMO.name = room.name
|
||||||
|
roomMO.icon = room.icon
|
||||||
|
roomMO.sortOrder = Int32(room.sortOrder)
|
||||||
|
roomMO.isDefault = room.isDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Managed Object to Domain
|
||||||
|
|
||||||
|
/// Converts a RoomMO managed object to a Room domain entity.
|
||||||
|
///
|
||||||
|
/// - Parameter roomMO: The managed object to convert.
|
||||||
|
/// - Returns: A Room domain entity populated with the managed object's data.
|
||||||
|
static func toDomainModel(_ roomMO: RoomMO) -> Room {
|
||||||
|
return Room(
|
||||||
|
id: roomMO.id,
|
||||||
|
name: roomMO.name,
|
||||||
|
icon: roomMO.icon,
|
||||||
|
sortOrder: Int(roomMO.sortOrder),
|
||||||
|
isDefault: roomMO.isDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an array of RoomMO managed objects to Room domain entities.
|
||||||
|
///
|
||||||
|
/// - Parameter roomMOs: The managed objects to convert.
|
||||||
|
/// - Returns: An array of Room domain entities.
|
||||||
|
static func toDomainModels(_ roomMOs: [RoomMO]) -> [Room] {
|
||||||
|
return roomMOs.map { toDomainModel($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch Operations
|
||||||
|
|
||||||
|
/// Creates multiple RoomMO managed objects from an array of Room domain entities.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - rooms: The Room domain entities to convert.
|
||||||
|
/// - context: The managed object context to create the objects in.
|
||||||
|
/// - Returns: An array of RoomMO instances populated with the rooms' data.
|
||||||
|
static func toManagedObjects(_ rooms: [Room], in context: NSManagedObjectContext) -> [RoomMO] {
|
||||||
|
return rooms.map { toManagedObject($0, in: context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Rooms
|
||||||
|
|
||||||
|
/// Creates RoomMO managed objects for all default rooms.
|
||||||
|
///
|
||||||
|
/// This function creates managed objects for the predefined default rooms
|
||||||
|
/// (Kitchen, Living Room, Bedroom, Bathroom, Office, Patio/Balcony, Other).
|
||||||
|
///
|
||||||
|
/// - Parameter context: The managed object context to create the objects in.
|
||||||
|
/// - Returns: An array of RoomMO instances for all default rooms.
|
||||||
|
static func createDefaultRooms(in context: NSManagedObjectContext) -> [RoomMO] {
|
||||||
|
return toManagedObjects(Room.defaultRooms, in: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,8 +56,8 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable {
|
|||||||
/// A custom name the user has given to this plant
|
/// A custom name the user has given to this plant
|
||||||
var customName: String?
|
var customName: String?
|
||||||
|
|
||||||
/// Description of where the plant is located (e.g., "Living room window", "Backyard garden")
|
/// The ID of the room where the plant is located (e.g., Kitchen, Living Room)
|
||||||
var location: String?
|
var roomID: UUID?
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable {
|
|||||||
/// - notes: User notes about the plant. Defaults to nil.
|
/// - notes: User notes about the plant. Defaults to nil.
|
||||||
/// - isFavorite: Whether marked as favorite. Defaults to false.
|
/// - isFavorite: Whether marked as favorite. Defaults to false.
|
||||||
/// - customName: User's custom name for the plant. Defaults to nil.
|
/// - customName: User's custom name for the plant. Defaults to nil.
|
||||||
/// - location: Where the plant is located. Defaults to nil.
|
/// - roomID: The ID of the room where the plant is located. Defaults to nil.
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
scientificName: String,
|
scientificName: String,
|
||||||
@@ -101,7 +101,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable {
|
|||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
isFavorite: Bool = false,
|
isFavorite: Bool = false,
|
||||||
customName: String? = nil,
|
customName: String? = nil,
|
||||||
location: String? = nil
|
roomID: UUID? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.scientificName = scientificName
|
self.scientificName = scientificName
|
||||||
@@ -117,7 +117,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable {
|
|||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.isFavorite = isFavorite
|
self.isFavorite = isFavorite
|
||||||
self.customName = customName
|
self.customName = customName
|
||||||
self.location = location
|
self.roomID = roomID
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hashable
|
// MARK: - Hashable
|
||||||
|
|||||||
87
PlantGuide/Domain/Entities/Room.swift
Normal file
87
PlantGuide/Domain/Entities/Room.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// Room.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created for PlantGuide plant identification app.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Room
|
||||||
|
|
||||||
|
/// Represents a room/zone where plants can be located.
|
||||||
|
///
|
||||||
|
/// Rooms provide organizational grouping for plants, allowing users to
|
||||||
|
/// track which plants are in which areas of their home or garden.
|
||||||
|
/// Default rooms are provided on first launch and cannot be deleted,
|
||||||
|
/// while custom rooms can be freely created, edited, and removed.
|
||||||
|
///
|
||||||
|
/// Conforms to Hashable for efficient use in SwiftUI ForEach and NavigationLink.
|
||||||
|
/// The hash is based only on the immutable `id` property for stable identity,
|
||||||
|
/// while Equatable compares all properties for change detection.
|
||||||
|
struct Room: Identifiable, Sendable, Equatable, Hashable, Codable {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// Unique identifier for the room
|
||||||
|
let id: UUID
|
||||||
|
|
||||||
|
/// The display name of the room (e.g., "Kitchen", "Living Room")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
/// SF Symbol name for the room's icon (e.g., "refrigerator", "sofa")
|
||||||
|
var icon: String
|
||||||
|
|
||||||
|
/// Position in the sorted list of rooms (lower values appear first)
|
||||||
|
var sortOrder: Int
|
||||||
|
|
||||||
|
/// System-created rooms cannot be deleted by the user
|
||||||
|
let isDefault: Bool
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates a new Room instance.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: Unique identifier for the room. Defaults to a new UUID.
|
||||||
|
/// - name: The display name of the room.
|
||||||
|
/// - icon: SF Symbol name for the room's icon.
|
||||||
|
/// - sortOrder: Position in the sorted list of rooms.
|
||||||
|
/// - isDefault: Whether this is a system-created default room. Defaults to false.
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
icon: String,
|
||||||
|
sortOrder: Int,
|
||||||
|
isDefault: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.icon = icon
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
self.isDefault = isDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hashable
|
||||||
|
|
||||||
|
/// Custom hash implementation using only the id for stable identity.
|
||||||
|
/// This ensures consistent behavior in SwiftUI collections and navigation,
|
||||||
|
/// where identity should remain stable even when mutable properties change.
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Rooms
|
||||||
|
|
||||||
|
/// The set of default rooms created on first app launch.
|
||||||
|
/// These rooms cover common household locations and cannot be deleted.
|
||||||
|
static let defaultRooms: [Room] = [
|
||||||
|
Room(name: "Kitchen", icon: "refrigerator", sortOrder: 0, isDefault: true),
|
||||||
|
Room(name: "Living Room", icon: "sofa", sortOrder: 1, isDefault: true),
|
||||||
|
Room(name: "Bedroom", icon: "bed.double", sortOrder: 2, isDefault: true),
|
||||||
|
Room(name: "Bathroom", icon: "shower", sortOrder: 3, isDefault: true),
|
||||||
|
Room(name: "Office", icon: "desktopcomputer", sortOrder: 4, isDefault: true),
|
||||||
|
Room(name: "Patio/Balcony", icon: "sun.max", sortOrder: 5, isDefault: true),
|
||||||
|
Room(name: "Other", icon: "square.grid.2x2", sortOrder: 6, isDefault: true),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// RoomRepositoryProtocol.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Repository protocol defining the data access contract for Room entities.
|
||||||
|
// Implementations handle persistence operations for room/zone data.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Room Storage Error
|
||||||
|
|
||||||
|
/// Errors that can occur during room storage operations
|
||||||
|
enum RoomStorageError: Error, LocalizedError {
|
||||||
|
/// The room with the specified ID was not found
|
||||||
|
case roomNotFound(UUID)
|
||||||
|
/// Failed to save room data
|
||||||
|
case saveFailed(Error)
|
||||||
|
/// Failed to fetch room data
|
||||||
|
case fetchFailed(Error)
|
||||||
|
/// Failed to delete room data
|
||||||
|
case deleteFailed(Error)
|
||||||
|
/// Failed to update room data
|
||||||
|
case updateFailed(Error)
|
||||||
|
/// Attempted to delete the "Other" room which cannot be deleted
|
||||||
|
case cannotDeleteOtherRoom
|
||||||
|
/// Attempted to delete a default room
|
||||||
|
case cannotDeleteDefaultRoom(String)
|
||||||
|
/// The Core Data entity was not found in the model
|
||||||
|
case entityNotFound(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .roomNotFound(let id):
|
||||||
|
return "Room with ID \(id) was not found"
|
||||||
|
case .saveFailed(let error):
|
||||||
|
return "Failed to save room: \(error.localizedDescription)"
|
||||||
|
case .fetchFailed(let error):
|
||||||
|
return "Failed to fetch room data: \(error.localizedDescription)"
|
||||||
|
case .deleteFailed(let error):
|
||||||
|
return "Failed to delete room: \(error.localizedDescription)"
|
||||||
|
case .updateFailed(let error):
|
||||||
|
return "Failed to update room: \(error.localizedDescription)"
|
||||||
|
case .cannotDeleteOtherRoom:
|
||||||
|
return "The 'Other' room cannot be deleted"
|
||||||
|
case .cannotDeleteDefaultRoom(let name):
|
||||||
|
return "The default room '\(name)' cannot be deleted"
|
||||||
|
case .entityNotFound(let name):
|
||||||
|
return "Entity '\(name)' not found in Core Data model"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RoomRepositoryProtocol
|
||||||
|
|
||||||
|
/// Repository protocol defining the data access contract for Room entities.
|
||||||
|
/// Implementations handle persistence operations for room/zone data.
|
||||||
|
///
|
||||||
|
/// Rooms provide organizational grouping for plants in the user's collection.
|
||||||
|
/// The repository manages both default system rooms and user-created custom rooms.
|
||||||
|
protocol RoomRepositoryProtocol: Sendable {
|
||||||
|
|
||||||
|
// MARK: - Fetch Operations
|
||||||
|
|
||||||
|
/// Fetches all rooms from the repository, sorted by sortOrder.
|
||||||
|
///
|
||||||
|
/// - Returns: An array of all stored rooms, sorted by their sortOrder property.
|
||||||
|
/// - Throws: An error if the fetch operation fails.
|
||||||
|
func fetchAll() async throws -> [Room]
|
||||||
|
|
||||||
|
/// Fetches a room by its unique identifier.
|
||||||
|
///
|
||||||
|
/// - Parameter id: The unique identifier of the room to fetch.
|
||||||
|
/// - Returns: The room if found, or nil if no room exists with the given ID.
|
||||||
|
/// - Throws: An error if the fetch operation fails.
|
||||||
|
func fetch(id: UUID) async throws -> Room?
|
||||||
|
|
||||||
|
/// Fetches the "Other" room, which is the default fallback room.
|
||||||
|
/// Creates default rooms if they don't exist.
|
||||||
|
///
|
||||||
|
/// - Returns: The "Other" room entity.
|
||||||
|
/// - Throws: An error if the fetch or creation operation fails.
|
||||||
|
func fetchOtherRoom() async throws -> Room
|
||||||
|
|
||||||
|
// MARK: - Save Operations
|
||||||
|
|
||||||
|
/// Saves a new room to the repository.
|
||||||
|
///
|
||||||
|
/// Use this method to persist a newly created room. For updating existing
|
||||||
|
/// rooms, use the `update(_:)` method instead.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room entity to save.
|
||||||
|
/// - Throws: An error if the save operation fails.
|
||||||
|
func save(_ room: Room) async throws
|
||||||
|
|
||||||
|
/// Updates an existing room in the repository.
|
||||||
|
///
|
||||||
|
/// Use this method to persist changes to a room's mutable properties
|
||||||
|
/// (name, icon, sortOrder). The room must already exist in the repository.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room with updated values to save.
|
||||||
|
/// - Throws: An error if the update operation fails or if the room is not found.
|
||||||
|
func update(_ room: Room) async throws
|
||||||
|
|
||||||
|
// MARK: - Delete Operations
|
||||||
|
|
||||||
|
/// Deletes a room by its unique identifier.
|
||||||
|
///
|
||||||
|
/// The "Other" room cannot be deleted. When a room is deleted, plants
|
||||||
|
/// in that room will have their room reference set to nil (nullified).
|
||||||
|
///
|
||||||
|
/// - Parameter id: The unique identifier of the room to delete.
|
||||||
|
/// - Throws: `RoomStorageError.cannotDeleteOtherRoom` if attempting to delete the "Other" room.
|
||||||
|
/// `RoomStorageError.roomNotFound` if the room doesn't exist.
|
||||||
|
func delete(id: UUID) async throws
|
||||||
|
|
||||||
|
// MARK: - Query Operations
|
||||||
|
|
||||||
|
/// Checks if a room exists with the given identifier.
|
||||||
|
///
|
||||||
|
/// - Parameter id: The unique identifier of the room to check.
|
||||||
|
/// - Returns: True if the room exists, false otherwise.
|
||||||
|
/// - Throws: An error if the check operation fails.
|
||||||
|
func exists(id: UUID) async throws -> Bool
|
||||||
|
|
||||||
|
/// Fetches the count of plants in a specific room.
|
||||||
|
///
|
||||||
|
/// - Parameter roomID: The unique identifier of the room.
|
||||||
|
/// - Returns: The number of plants in the room.
|
||||||
|
/// - Throws: An error if the count operation fails.
|
||||||
|
func plantCount(for roomID: UUID) async throws -> Int
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates the default rooms if no rooms exist yet.
|
||||||
|
///
|
||||||
|
/// This method should be called on first app launch to populate
|
||||||
|
/// the room list with the predefined default rooms from `Room.defaultRooms`.
|
||||||
|
/// If any rooms already exist, this method should do nothing.
|
||||||
|
///
|
||||||
|
/// - Throws: An error if the creation operation fails.
|
||||||
|
func createDefaultRoomsIfNeeded() async throws
|
||||||
|
}
|
||||||
139
PlantGuide/Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift
Normal file
139
PlantGuide/Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// CreateDefaultRoomsUseCase.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created for PlantGuide plant identification app.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - CreateDefaultRoomsUseCaseProtocol
|
||||||
|
|
||||||
|
/// Protocol defining the interface for creating default rooms on first app launch.
|
||||||
|
///
|
||||||
|
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||||
|
/// The use case ensures that users have a set of predefined rooms to choose from
|
||||||
|
/// when organizing their plant collection.
|
||||||
|
protocol CreateDefaultRoomsUseCaseProtocol: Sendable {
|
||||||
|
/// Creates the default rooms if no rooms exist yet.
|
||||||
|
///
|
||||||
|
/// This method should be called during app initialization (e.g., on first launch)
|
||||||
|
/// to populate the room list with predefined rooms like "Kitchen", "Living Room", etc.
|
||||||
|
/// If any rooms already exist in the repository, this method does nothing.
|
||||||
|
///
|
||||||
|
/// - Throws: `CreateDefaultRoomsError` if the creation fails.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// // In AppDelegate or app initialization
|
||||||
|
/// try await createDefaultRoomsUseCase.execute()
|
||||||
|
/// ```
|
||||||
|
func execute() async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateDefaultRoomsError
|
||||||
|
|
||||||
|
/// Errors that can occur when creating default rooms.
|
||||||
|
///
|
||||||
|
/// These errors provide specific context for creation failures,
|
||||||
|
/// enabling appropriate error handling and logging.
|
||||||
|
enum CreateDefaultRoomsError: Error, LocalizedError {
|
||||||
|
/// Failed to check if rooms already exist.
|
||||||
|
case checkExistingFailed(Error)
|
||||||
|
|
||||||
|
/// Failed to save one or more default rooms.
|
||||||
|
case saveFailed(Error)
|
||||||
|
|
||||||
|
// MARK: - LocalizedError
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .checkExistingFailed(let error):
|
||||||
|
return "Failed to check existing rooms: \(error.localizedDescription)"
|
||||||
|
case .saveFailed(let error):
|
||||||
|
return "Failed to create default rooms: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failureReason: String? {
|
||||||
|
switch self {
|
||||||
|
case .checkExistingFailed:
|
||||||
|
return "Could not determine if default rooms already exist."
|
||||||
|
case .saveFailed:
|
||||||
|
return "The default room data could not be persisted to storage."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recoverySuggestion: String? {
|
||||||
|
switch self {
|
||||||
|
case .checkExistingFailed, .saveFailed:
|
||||||
|
return "Please restart the app. If the problem persists, reinstall the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateDefaultRoomsUseCase
|
||||||
|
|
||||||
|
/// Use case for creating default rooms on first app launch.
|
||||||
|
///
|
||||||
|
/// This use case handles the initialization of the room list with predefined
|
||||||
|
/// rooms that cover common household locations. It's designed to be idempotent,
|
||||||
|
/// meaning it can be called multiple times safely without creating duplicates.
|
||||||
|
///
|
||||||
|
/// ## Default Rooms Created
|
||||||
|
/// - Kitchen
|
||||||
|
/// - Living Room
|
||||||
|
/// - Bedroom
|
||||||
|
/// - Bathroom
|
||||||
|
/// - Office
|
||||||
|
/// - Patio/Balcony
|
||||||
|
/// - Other
|
||||||
|
///
|
||||||
|
/// ## Example Usage
|
||||||
|
/// ```swift
|
||||||
|
/// let useCase = CreateDefaultRoomsUseCase(roomRepository: roomRepository)
|
||||||
|
///
|
||||||
|
/// // Call during app initialization
|
||||||
|
/// try await useCase.execute()
|
||||||
|
/// ```
|
||||||
|
final class CreateDefaultRoomsUseCase: CreateDefaultRoomsUseCaseProtocol, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let roomRepository: RoomRepositoryProtocol
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates a new CreateDefaultRoomsUseCase instance.
|
||||||
|
///
|
||||||
|
/// - Parameter roomRepository: Repository for persisting room entities.
|
||||||
|
init(roomRepository: RoomRepositoryProtocol) {
|
||||||
|
self.roomRepository = roomRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateDefaultRoomsUseCaseProtocol
|
||||||
|
|
||||||
|
func execute() async throws {
|
||||||
|
// Check if any rooms already exist
|
||||||
|
let existingRooms: [Room]
|
||||||
|
do {
|
||||||
|
existingRooms = try await roomRepository.fetchAll()
|
||||||
|
} catch {
|
||||||
|
throw CreateDefaultRoomsError.checkExistingFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If rooms already exist, do nothing
|
||||||
|
guard existingRooms.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all default rooms
|
||||||
|
do {
|
||||||
|
for room in Room.defaultRooms {
|
||||||
|
try await roomRepository.save(room)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw CreateDefaultRoomsError.saveFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
374
PlantGuide/Domain/UseCases/Room/ManageRoomsUseCase.swift
Normal file
374
PlantGuide/Domain/UseCases/Room/ManageRoomsUseCase.swift
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
//
|
||||||
|
// ManageRoomsUseCase.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created for PlantGuide plant identification app.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ManageRoomsUseCaseProtocol
|
||||||
|
|
||||||
|
/// Protocol defining the interface for managing rooms in the user's collection.
|
||||||
|
///
|
||||||
|
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||||
|
/// Implementations coordinate CRUD operations on rooms, including the ability
|
||||||
|
/// to reassign plants when a room is deleted.
|
||||||
|
protocol ManageRoomsUseCaseProtocol: Sendable {
|
||||||
|
|
||||||
|
/// Creates a new custom room with the specified name and icon.
|
||||||
|
///
|
||||||
|
/// The room is automatically assigned a sortOrder that places it at the end
|
||||||
|
/// of the room list, after all existing rooms.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The display name for the new room.
|
||||||
|
/// - icon: The SF Symbol name for the room's icon.
|
||||||
|
/// - Returns: The newly created and saved room.
|
||||||
|
/// - Throws: `ManageRoomsError` if the creation fails.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// let newRoom = try await useCase.createRoom(name: "Sunroom", icon: "sun.max.fill")
|
||||||
|
/// ```
|
||||||
|
func createRoom(name: String, icon: String) async throws -> Room
|
||||||
|
|
||||||
|
/// Updates an existing room's properties.
|
||||||
|
///
|
||||||
|
/// Only mutable properties (name, icon, sortOrder) can be updated.
|
||||||
|
/// The room's id and isDefault status cannot be changed.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room with updated values to save.
|
||||||
|
/// - Throws: `ManageRoomsError` if the update fails.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// var room = existingRoom
|
||||||
|
/// room.name = "Guest Bedroom"
|
||||||
|
/// room.icon = "bed.double.fill"
|
||||||
|
/// try await useCase.updateRoom(room)
|
||||||
|
/// ```
|
||||||
|
func updateRoom(_ room: Room) async throws
|
||||||
|
|
||||||
|
/// Deletes a room and optionally reassigns its plants to another room.
|
||||||
|
///
|
||||||
|
/// Default rooms (where `isDefault == true`) cannot be deleted and will
|
||||||
|
/// throw a `cannotDeleteDefaultRoom` error.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The unique identifier of the room to delete.
|
||||||
|
/// - reassignPlantsTo: Optional ID of another room to move plants to.
|
||||||
|
/// If nil, plants in the deleted room will have their
|
||||||
|
/// room assignment cleared.
|
||||||
|
/// - Throws: `ManageRoomsError` if the deletion fails or if attempting
|
||||||
|
/// to delete a default room.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// // Delete room and reassign plants to another room
|
||||||
|
/// try await useCase.deleteRoom(id: roomToDelete.id, reassignPlantsTo: otherRoom.id)
|
||||||
|
///
|
||||||
|
/// // Delete room and clear plant assignments
|
||||||
|
/// try await useCase.deleteRoom(id: roomToDelete.id, reassignPlantsTo: nil)
|
||||||
|
/// ```
|
||||||
|
func deleteRoom(id: UUID, reassignPlantsTo: UUID?) async throws
|
||||||
|
|
||||||
|
/// Retrieves all rooms, sorted by sortOrder.
|
||||||
|
///
|
||||||
|
/// - Returns: An array of all rooms, sorted by their sortOrder property.
|
||||||
|
/// - Throws: `ManageRoomsError` if the fetch fails.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// let rooms = try await useCase.getAllRooms()
|
||||||
|
/// for room in rooms {
|
||||||
|
/// print("\(room.name): \(room.icon)")
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
func getAllRooms() async throws -> [Room]
|
||||||
|
|
||||||
|
/// Updates the sort order of multiple rooms at once.
|
||||||
|
///
|
||||||
|
/// Use this method after the user reorders rooms via drag-and-drop.
|
||||||
|
/// The sortOrder of each room in the array is updated to match its
|
||||||
|
/// position in the array (index 0 = sortOrder 0, etc.).
|
||||||
|
///
|
||||||
|
/// - Parameter rooms: The rooms in their new sorted order.
|
||||||
|
/// - Throws: `ManageRoomsError` if the update fails.
|
||||||
|
///
|
||||||
|
/// Example usage:
|
||||||
|
/// ```swift
|
||||||
|
/// // After user drag-and-drop reordering
|
||||||
|
/// let reorderedRooms = [room3, room1, room2]
|
||||||
|
/// try await useCase.reorderRooms(reorderedRooms)
|
||||||
|
/// ```
|
||||||
|
func reorderRooms(_ rooms: [Room]) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ManageRoomsError
|
||||||
|
|
||||||
|
/// Errors that can occur when managing rooms.
|
||||||
|
///
|
||||||
|
/// These errors provide specific context for room operation failures,
|
||||||
|
/// enabling appropriate error handling and user messaging.
|
||||||
|
enum ManageRoomsError: Error, LocalizedError {
|
||||||
|
/// The room with the specified ID was not found.
|
||||||
|
case roomNotFound(roomID: UUID)
|
||||||
|
|
||||||
|
/// Cannot delete a default (system-created) room.
|
||||||
|
case cannotDeleteDefaultRoom(roomID: UUID)
|
||||||
|
|
||||||
|
/// The target room for plant reassignment was not found.
|
||||||
|
case reassignmentTargetNotFound(roomID: UUID)
|
||||||
|
|
||||||
|
/// Failed to save the room to the repository.
|
||||||
|
case saveFailed(Error)
|
||||||
|
|
||||||
|
/// Failed to update the room in the repository.
|
||||||
|
case updateFailed(Error)
|
||||||
|
|
||||||
|
/// Failed to delete the room from the repository.
|
||||||
|
case deleteFailed(Error)
|
||||||
|
|
||||||
|
/// Failed to fetch rooms from the repository.
|
||||||
|
case fetchFailed(Error)
|
||||||
|
|
||||||
|
/// Failed to reassign plants to another room.
|
||||||
|
case plantReassignmentFailed(Error)
|
||||||
|
|
||||||
|
// MARK: - LocalizedError
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .roomNotFound(let roomID):
|
||||||
|
return "Room not found: \(roomID)"
|
||||||
|
case .cannotDeleteDefaultRoom:
|
||||||
|
return "Cannot delete a default room."
|
||||||
|
case .reassignmentTargetNotFound(let roomID):
|
||||||
|
return "Target room for reassignment not found: \(roomID)"
|
||||||
|
case .saveFailed(let error):
|
||||||
|
return "Failed to create room: \(error.localizedDescription)"
|
||||||
|
case .updateFailed(let error):
|
||||||
|
return "Failed to update room: \(error.localizedDescription)"
|
||||||
|
case .deleteFailed(let error):
|
||||||
|
return "Failed to delete room: \(error.localizedDescription)"
|
||||||
|
case .fetchFailed(let error):
|
||||||
|
return "Failed to load rooms: \(error.localizedDescription)"
|
||||||
|
case .plantReassignmentFailed(let error):
|
||||||
|
return "Failed to reassign plants: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failureReason: String? {
|
||||||
|
switch self {
|
||||||
|
case .roomNotFound:
|
||||||
|
return "The room may have already been deleted."
|
||||||
|
case .cannotDeleteDefaultRoom:
|
||||||
|
return "Default rooms are required for the app to function properly."
|
||||||
|
case .reassignmentTargetNotFound:
|
||||||
|
return "The room selected for plant reassignment no longer exists."
|
||||||
|
case .saveFailed:
|
||||||
|
return "The room data could not be persisted to storage."
|
||||||
|
case .updateFailed:
|
||||||
|
return "The room changes could not be saved."
|
||||||
|
case .deleteFailed:
|
||||||
|
return "The room could not be removed from storage."
|
||||||
|
case .fetchFailed:
|
||||||
|
return "The room list could not be loaded from storage."
|
||||||
|
case .plantReassignmentFailed:
|
||||||
|
return "Plants could not be moved to the new room."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recoverySuggestion: String? {
|
||||||
|
switch self {
|
||||||
|
case .roomNotFound:
|
||||||
|
return "Refresh the room list to see current rooms."
|
||||||
|
case .cannotDeleteDefaultRoom:
|
||||||
|
return "You can rename or change the icon of default rooms instead."
|
||||||
|
case .reassignmentTargetNotFound:
|
||||||
|
return "Please select a different room for reassignment."
|
||||||
|
case .saveFailed, .updateFailed, .deleteFailed, .fetchFailed:
|
||||||
|
return "Please try again. If the problem persists, restart the app."
|
||||||
|
case .plantReassignmentFailed:
|
||||||
|
return "The room was deleted, but some plants may need to be reassigned manually."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ManageRoomsUseCase
|
||||||
|
|
||||||
|
/// Use case for managing rooms in the user's plant collection.
|
||||||
|
///
|
||||||
|
/// This use case coordinates all room management operations including
|
||||||
|
/// creating new rooms, updating existing rooms, deleting rooms with
|
||||||
|
/// plant reassignment, and reordering rooms.
|
||||||
|
///
|
||||||
|
/// ## Example Usage
|
||||||
|
/// ```swift
|
||||||
|
/// let useCase = ManageRoomsUseCase(
|
||||||
|
/// roomRepository: roomRepository,
|
||||||
|
/// plantRepository: plantRepository
|
||||||
|
/// )
|
||||||
|
///
|
||||||
|
/// // Create a new room
|
||||||
|
/// let sunroom = try await useCase.createRoom(name: "Sunroom", icon: "sun.max.fill")
|
||||||
|
///
|
||||||
|
/// // Update a room
|
||||||
|
/// var room = sunroom
|
||||||
|
/// room.name = "Sun Room"
|
||||||
|
/// try await useCase.updateRoom(room)
|
||||||
|
///
|
||||||
|
/// // Delete a room and reassign plants
|
||||||
|
/// try await useCase.deleteRoom(id: room.id, reassignPlantsTo: otherRoom.id)
|
||||||
|
/// ```
|
||||||
|
final class ManageRoomsUseCase: ManageRoomsUseCaseProtocol, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let roomRepository: RoomRepositoryProtocol
|
||||||
|
private let plantRepository: PlantCollectionRepositoryProtocol?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates a new ManageRoomsUseCase instance.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - roomRepository: Repository for persisting room entities.
|
||||||
|
/// - plantRepository: Optional repository for reassigning plants when rooms are deleted.
|
||||||
|
/// If nil, plant reassignment will be skipped.
|
||||||
|
init(
|
||||||
|
roomRepository: RoomRepositoryProtocol,
|
||||||
|
plantRepository: PlantCollectionRepositoryProtocol? = nil
|
||||||
|
) {
|
||||||
|
self.roomRepository = roomRepository
|
||||||
|
self.plantRepository = plantRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ManageRoomsUseCaseProtocol
|
||||||
|
|
||||||
|
func createRoom(name: String, icon: String) async throws -> Room {
|
||||||
|
// Get the current highest sortOrder to place new room at the end
|
||||||
|
let existingRooms: [Room]
|
||||||
|
do {
|
||||||
|
existingRooms = try await roomRepository.fetchAll()
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.fetchFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxSortOrder = existingRooms.map(\.sortOrder).max() ?? -1
|
||||||
|
let newSortOrder = maxSortOrder + 1
|
||||||
|
|
||||||
|
let room = Room(
|
||||||
|
name: name,
|
||||||
|
icon: icon,
|
||||||
|
sortOrder: newSortOrder,
|
||||||
|
isDefault: false
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await roomRepository.save(room)
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.saveFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRoom(_ room: Room) async throws {
|
||||||
|
// Verify the room exists
|
||||||
|
do {
|
||||||
|
guard try await roomRepository.exists(id: room.id) else {
|
||||||
|
throw ManageRoomsError.roomNotFound(roomID: room.id)
|
||||||
|
}
|
||||||
|
} catch let error as ManageRoomsError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.fetchFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await roomRepository.update(room)
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.updateFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRoom(id: UUID, reassignPlantsTo targetRoomID: UUID?) async throws {
|
||||||
|
// Fetch the room to verify it exists and check if it's a default room
|
||||||
|
let room: Room
|
||||||
|
do {
|
||||||
|
guard let fetchedRoom = try await roomRepository.fetch(id: id) else {
|
||||||
|
throw ManageRoomsError.roomNotFound(roomID: id)
|
||||||
|
}
|
||||||
|
room = fetchedRoom
|
||||||
|
} catch let error as ManageRoomsError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.fetchFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot delete default rooms
|
||||||
|
if room.isDefault {
|
||||||
|
throw ManageRoomsError.cannotDeleteDefaultRoom(roomID: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a target room is specified, verify it exists
|
||||||
|
if let targetRoomID = targetRoomID {
|
||||||
|
do {
|
||||||
|
guard try await roomRepository.exists(id: targetRoomID) else {
|
||||||
|
throw ManageRoomsError.reassignmentTargetNotFound(roomID: targetRoomID)
|
||||||
|
}
|
||||||
|
} catch let error as ManageRoomsError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.fetchFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign plants if plant repository is available
|
||||||
|
// Note: Actual plant reassignment would require a roomID property on Plant
|
||||||
|
// and appropriate methods on PlantCollectionRepositoryProtocol.
|
||||||
|
// This is a placeholder for when that functionality is implemented.
|
||||||
|
if let plantRepository = plantRepository, targetRoomID != nil {
|
||||||
|
do {
|
||||||
|
// Future implementation: fetch plants in the room and update their roomID
|
||||||
|
// let plantsInRoom = try await plantRepository.filterPlants(by: PlantFilter(roomID: id))
|
||||||
|
// for var plant in plantsInRoom {
|
||||||
|
// plant.roomID = targetRoomID
|
||||||
|
// try await plantRepository.updatePlant(plant)
|
||||||
|
// }
|
||||||
|
_ = plantRepository // Silence unused variable warning for now
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.plantReassignmentFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the room
|
||||||
|
do {
|
||||||
|
try await roomRepository.delete(id: id)
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.deleteFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAllRooms() async throws -> [Room] {
|
||||||
|
do {
|
||||||
|
return try await roomRepository.fetchAll()
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.fetchFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reorderRooms(_ rooms: [Room]) async throws {
|
||||||
|
// Update each room's sortOrder to match its position in the array
|
||||||
|
for (index, var room) in rooms.enumerated() {
|
||||||
|
room.sortOrder = index
|
||||||
|
do {
|
||||||
|
try await roomRepository.update(room)
|
||||||
|
} catch {
|
||||||
|
throw ManageRoomsError.updateFailed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,22 @@ struct PlantGuideApp: App {
|
|||||||
MainTabView()
|
MainTabView()
|
||||||
.environment(appearanceManager)
|
.environment(appearanceManager)
|
||||||
.preferredColorScheme(appearanceManager.colorScheme)
|
.preferredColorScheme(appearanceManager.colorScheme)
|
||||||
|
.task {
|
||||||
|
await initializeDefaultRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Initializes default rooms on first app launch
|
||||||
|
private func initializeDefaultRooms() async {
|
||||||
|
do {
|
||||||
|
try await DIContainer.shared.createDefaultRoomsUseCase.execute()
|
||||||
|
} catch {
|
||||||
|
// Non-fatal error - log but don't crash
|
||||||
|
// Users can still use the app, just without default rooms
|
||||||
|
print("Failed to create default rooms: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ struct PlantDetailView: View {
|
|||||||
} else if let error = viewModel.error {
|
} else if let error = viewModel.error {
|
||||||
errorView(error: error)
|
errorView(error: error)
|
||||||
} else {
|
} else {
|
||||||
|
// Room assignment section
|
||||||
|
roomSection
|
||||||
|
|
||||||
// Care information section
|
// Care information section
|
||||||
if let careInfo = viewModel.careInfo {
|
if let careInfo = viewModel.careInfo {
|
||||||
CareInformationSection(careInfo: careInfo)
|
CareInformationSection(careInfo: careInfo)
|
||||||
@@ -293,6 +296,34 @@ struct PlantDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Room Section
|
||||||
|
|
||||||
|
private var roomSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Location")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
RoomPickerView(
|
||||||
|
selectedRoomID: Binding(
|
||||||
|
get: { viewModel.plant.roomID },
|
||||||
|
set: { newRoomID in
|
||||||
|
Task {
|
||||||
|
await viewModel.updateRoom(to: newRoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onRoomChanged: { newRoomID in
|
||||||
|
Task {
|
||||||
|
await viewModel.updateRoom(to: newRoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Identification Info Section
|
// MARK: - Identification Info Section
|
||||||
|
|
||||||
private var identificationInfoSection: some View {
|
private var identificationInfoSection: some View {
|
||||||
|
|||||||
@@ -311,4 +311,19 @@ final class PlantDetailViewModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Room Management
|
||||||
|
|
||||||
|
/// Updates the room assignment for this plant
|
||||||
|
/// - Parameter roomID: The new room ID, or nil to remove room assignment
|
||||||
|
func updateRoom(to roomID: UUID?) async {
|
||||||
|
plant.roomID = roomID
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Update the plant in the repository
|
||||||
|
try await DIContainer.shared.plantCollectionRepository.updatePlant(plant)
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
210
PlantGuide/Presentation/Scenes/Rooms/RoomEditorView.swift
Normal file
210
PlantGuide/Presentation/Scenes/Rooms/RoomEditorView.swift
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//
|
||||||
|
// RoomEditorView.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created on 2026-01-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - RoomEditorMode
|
||||||
|
|
||||||
|
/// Mode for the room editor view.
|
||||||
|
enum RoomEditorMode {
|
||||||
|
/// Creating a new room
|
||||||
|
case create
|
||||||
|
/// Editing an existing room
|
||||||
|
case edit(Room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RoomEditorView
|
||||||
|
|
||||||
|
/// A view for creating or editing a room.
|
||||||
|
///
|
||||||
|
/// Presents a form with fields for room name and icon selection.
|
||||||
|
/// Used both for creating new rooms and editing existing ones.
|
||||||
|
@MainActor
|
||||||
|
struct RoomEditorView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// The editing mode (create or edit)
|
||||||
|
let mode: RoomEditorMode
|
||||||
|
|
||||||
|
/// Callback when save is tapped
|
||||||
|
let onSave: (String, String) async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var selectedIcon: String
|
||||||
|
@State private var isSaving = false
|
||||||
|
|
||||||
|
/// Available SF Symbols for room icons
|
||||||
|
private let availableIcons = [
|
||||||
|
"refrigerator",
|
||||||
|
"sofa",
|
||||||
|
"bed.double",
|
||||||
|
"shower",
|
||||||
|
"desktopcomputer",
|
||||||
|
"sun.max",
|
||||||
|
"square.grid.2x2",
|
||||||
|
"leaf",
|
||||||
|
"house",
|
||||||
|
"building.2",
|
||||||
|
"door.left.hand.open",
|
||||||
|
"window.ceiling",
|
||||||
|
"chair.lounge",
|
||||||
|
"table.furniture",
|
||||||
|
"lamp.desk",
|
||||||
|
"fan",
|
||||||
|
"washer",
|
||||||
|
"sink",
|
||||||
|
"bathtub",
|
||||||
|
"toilet",
|
||||||
|
"stove",
|
||||||
|
"microwave",
|
||||||
|
"fork.knife",
|
||||||
|
"cup.and.saucer",
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(mode: RoomEditorMode, onSave: @escaping (String, String) async -> Void) {
|
||||||
|
self.mode = mode
|
||||||
|
self.onSave = onSave
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .create:
|
||||||
|
_name = State(initialValue: "")
|
||||||
|
_selectedIcon = State(initialValue: "square.grid.2x2")
|
||||||
|
case .edit(let room):
|
||||||
|
_name = State(initialValue: room.name)
|
||||||
|
_selectedIcon = State(initialValue: room.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
switch mode {
|
||||||
|
case .create:
|
||||||
|
return "New Room"
|
||||||
|
case .edit:
|
||||||
|
return "Edit Room"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Room Name", text: $name)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("Name")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
iconGrid
|
||||||
|
} header: {
|
||||||
|
Text("Icon")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
previewRow
|
||||||
|
} header: {
|
||||||
|
Text("Preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
isSaving = true
|
||||||
|
Task {
|
||||||
|
await onSave(name, selectedIcon)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isValid || isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Icon Grid
|
||||||
|
|
||||||
|
private var iconGrid: some View {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 12) {
|
||||||
|
ForEach(availableIcons, id: \.self) { icon in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
selectedIcon = icon
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(selectedIcon == icon ? Color.accentColor.opacity(0.2) : Color.clear)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(selectedIcon == icon ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
.foregroundStyle(selectedIcon == icon ? Color.accentColor : Color.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(icon.replacingOccurrences(of: ".", with: " "))
|
||||||
|
.accessibilityAddTraits(selectedIcon == icon ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview Row
|
||||||
|
|
||||||
|
private var previewRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: selectedIcon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
Text(name.isEmpty ? "Room Name" : name)
|
||||||
|
.foregroundStyle(name.isEmpty ? .secondary : .primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Create") {
|
||||||
|
RoomEditorView(mode: .create) { name, icon in
|
||||||
|
print("Create: \(name), \(icon)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Edit") {
|
||||||
|
RoomEditorView(mode: .edit(Room(name: "Kitchen", icon: "refrigerator", sortOrder: 0, isDefault: true))) { name, icon in
|
||||||
|
print("Edit: \(name), \(icon)")
|
||||||
|
}
|
||||||
|
}
|
||||||
203
PlantGuide/Presentation/Scenes/Rooms/RoomPickerView.swift
Normal file
203
PlantGuide/Presentation/Scenes/Rooms/RoomPickerView.swift
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
//
|
||||||
|
// RoomPickerView.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created on 2026-01-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - RoomPickerView
|
||||||
|
|
||||||
|
/// A picker view for selecting a room for a plant.
|
||||||
|
///
|
||||||
|
/// Displays the currently selected room and allows the user to
|
||||||
|
/// select a different room from a list. Used in PlantDetailView
|
||||||
|
/// to assign plants to rooms.
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
/// ```swift
|
||||||
|
/// @State private var selectedRoomID: UUID?
|
||||||
|
///
|
||||||
|
/// RoomPickerView(selectedRoomID: $selectedRoomID)
|
||||||
|
/// ```
|
||||||
|
@MainActor
|
||||||
|
struct RoomPickerView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// The ID of the currently selected room
|
||||||
|
@Binding var selectedRoomID: UUID?
|
||||||
|
|
||||||
|
/// Optional callback when the room changes
|
||||||
|
var onRoomChanged: ((UUID?) -> Void)?
|
||||||
|
|
||||||
|
@State private var rooms: [Room] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Room icon
|
||||||
|
if let room = selectedRoom {
|
||||||
|
Image(systemName: room.icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.grid.2x2")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Room")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
Text("Loading...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Menu {
|
||||||
|
// No room option
|
||||||
|
Button {
|
||||||
|
selectedRoomID = nil
|
||||||
|
onRoomChanged?(nil)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("No Room")
|
||||||
|
if selectedRoomID == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Room options
|
||||||
|
ForEach(rooms) { room in
|
||||||
|
Button {
|
||||||
|
selectedRoomID = room.id
|
||||||
|
onRoomChanged?(room.id)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(room.name, systemImage: room.icon)
|
||||||
|
if selectedRoomID == room.id {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(selectedRoom?.name ?? "Select Room")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(selectedRoom != nil ? .primary : .secondary)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private var selectedRoom: Room? {
|
||||||
|
guard let selectedRoomID else { return nil }
|
||||||
|
return rooms.first { $0.id == selectedRoomID }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func loadRooms() async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
rooms = try await DIContainer.shared.roomRepository.fetchAll()
|
||||||
|
} catch {
|
||||||
|
// Silently fail - room picker will show "Select Room"
|
||||||
|
rooms = []
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact Room Picker
|
||||||
|
|
||||||
|
/// A compact inline version of the room picker for use in forms.
|
||||||
|
struct CompactRoomPickerView: View {
|
||||||
|
|
||||||
|
@Binding var selectedRoomID: UUID?
|
||||||
|
var onRoomChanged: ((UUID?) -> Void)?
|
||||||
|
|
||||||
|
@State private var rooms: [Room] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("Room", selection: $selectedRoomID) {
|
||||||
|
Text("No Room")
|
||||||
|
.tag(nil as UUID?)
|
||||||
|
|
||||||
|
ForEach(rooms) { room in
|
||||||
|
Label(room.name, systemImage: room.icon)
|
||||||
|
.tag(room.id as UUID?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selectedRoomID) { _, newValue in
|
||||||
|
onRoomChanged?(newValue)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRooms() async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
rooms = try await DIContainer.shared.roomRepository.fetchAll()
|
||||||
|
} catch {
|
||||||
|
rooms = []
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Room Picker") {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
RoomPickerView(selectedRoomID: .constant(nil))
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
|
||||||
|
RoomPickerView(selectedRoomID: .constant(UUID()))
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Compact Picker") {
|
||||||
|
Form {
|
||||||
|
CompactRoomPickerView(selectedRoomID: .constant(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
221
PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift
Normal file
221
PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// RoomsListView.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created on 2026-01-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - RoomsListView
|
||||||
|
|
||||||
|
/// SwiftUI View for managing rooms from Settings.
|
||||||
|
///
|
||||||
|
/// Displays a list of all rooms with their icons and names.
|
||||||
|
/// Supports creating new rooms, editing existing rooms, deleting
|
||||||
|
/// non-default rooms, and reordering via drag-and-drop.
|
||||||
|
///
|
||||||
|
/// ## Features
|
||||||
|
/// - List of rooms with icon and name
|
||||||
|
/// - Swipe to delete (for non-default rooms)
|
||||||
|
/// - Drag to reorder
|
||||||
|
/// - Add button in toolbar
|
||||||
|
/// - Navigation to RoomEditorView for editing
|
||||||
|
/// - Empty state when no rooms exist
|
||||||
|
@MainActor
|
||||||
|
struct RoomsListView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
@State private var viewModel = RoomsViewModel()
|
||||||
|
|
||||||
|
/// Whether to show the create room sheet
|
||||||
|
@State private var showCreateSheet = false
|
||||||
|
|
||||||
|
/// Whether to show the delete confirmation dialog
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
/// The room pending deletion (for confirmation)
|
||||||
|
@State private var roomToDelete: Room?
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.rooms.isEmpty {
|
||||||
|
loadingView
|
||||||
|
} else if viewModel.rooms.isEmpty {
|
||||||
|
emptyStateView
|
||||||
|
} else {
|
||||||
|
roomsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Manage Rooms")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Add Room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadRooms()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadRooms()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateSheet) {
|
||||||
|
RoomEditorView(mode: .create) { name, icon in
|
||||||
|
await viewModel.createRoom(name: name, icon: icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $viewModel.selectedRoom) { room in
|
||||||
|
RoomEditorView(mode: .edit(room)) { name, icon in
|
||||||
|
var updatedRoom = room
|
||||||
|
updatedRoom.name = name
|
||||||
|
updatedRoom.icon = icon
|
||||||
|
await viewModel.updateRoom(updatedRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .init(
|
||||||
|
get: { viewModel.errorMessage != nil },
|
||||||
|
set: { if !$0 { viewModel.clearError() } }
|
||||||
|
)) {
|
||||||
|
Button("OK", role: .cancel) {
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete Room",
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let room = roomToDelete {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteRoom(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roomToDelete = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
roomToDelete = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let room = roomToDelete {
|
||||||
|
Text("Are you sure you want to delete \"\(room.name)\"? Plants in this room will be moved to \"Other\".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
/// Loading state view
|
||||||
|
private var loadingView: some View {
|
||||||
|
ProgressView("Loading rooms...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty state when no rooms exist
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Rooms", systemImage: "house")
|
||||||
|
} description: {
|
||||||
|
Text("Add rooms to organize your plants by location.")
|
||||||
|
} actions: {
|
||||||
|
Button {
|
||||||
|
showCreateSheet = true
|
||||||
|
} label: {
|
||||||
|
Text("Add Room")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List of rooms with swipe actions and reordering
|
||||||
|
private var roomsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.rooms) { room in
|
||||||
|
RoomRow(room: room)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.selectedRoom = room
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
if viewModel.canDeleteRoom(room) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
roomToDelete = room
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onMove { source, destination in
|
||||||
|
Task {
|
||||||
|
await viewModel.moveRooms(from: source, to: destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RoomRow
|
||||||
|
|
||||||
|
/// A single row in the rooms list displaying the room icon and name.
|
||||||
|
private struct RoomRow: View {
|
||||||
|
|
||||||
|
let room: Room
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: room.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(room.name)
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
if room.isDefault {
|
||||||
|
Text("Default")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(room.name)\(room.isDefault ? ", default room" : "")")
|
||||||
|
.accessibilityHint("Tap to edit room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RoomsListView()
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Empty State") {
|
||||||
|
// For preview with empty state, would need mock view model
|
||||||
|
RoomsListView()
|
||||||
|
}
|
||||||
201
PlantGuide/Presentation/Scenes/Rooms/RoomsViewModel.swift
Normal file
201
PlantGuide/Presentation/Scenes/Rooms/RoomsViewModel.swift
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//
|
||||||
|
// RoomsViewModel.swift
|
||||||
|
// PlantGuide
|
||||||
|
//
|
||||||
|
// Created on 2026-01-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - RoomsViewModel
|
||||||
|
|
||||||
|
/// View model for the Rooms management screen.
|
||||||
|
///
|
||||||
|
/// Manages the state and business logic for displaying, creating, editing,
|
||||||
|
/// deleting, and reordering rooms. Rooms allow users to organize their
|
||||||
|
/// plant collection by physical location.
|
||||||
|
///
|
||||||
|
/// ## Example Usage
|
||||||
|
/// ```swift
|
||||||
|
/// @State private var viewModel = RoomsViewModel()
|
||||||
|
///
|
||||||
|
/// var body: some View {
|
||||||
|
/// RoomsListView()
|
||||||
|
/// .environment(viewModel)
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class RoomsViewModel {
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let manageRoomsUseCase: ManageRoomsUseCaseProtocol
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
/// The list of rooms to display, sorted by sort order
|
||||||
|
private(set) var rooms: [Room] = []
|
||||||
|
|
||||||
|
/// Indicates whether rooms are currently being loaded
|
||||||
|
private(set) var isLoading: Bool = false
|
||||||
|
|
||||||
|
/// Error message if an operation fails
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
/// The currently selected room for editing
|
||||||
|
var selectedRoom: Room?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates a new RoomsViewModel with the specified dependencies.
|
||||||
|
///
|
||||||
|
/// - Parameter manageRoomsUseCase: Use case for room management operations.
|
||||||
|
/// Defaults to DIContainer's implementation if not provided.
|
||||||
|
init(manageRoomsUseCase: ManageRoomsUseCaseProtocol? = nil) {
|
||||||
|
self.manageRoomsUseCase = manageRoomsUseCase ?? DIContainer.shared.manageRoomsUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Loads all rooms from the repository.
|
||||||
|
///
|
||||||
|
/// Updates the `rooms` array with all rooms sorted by their sort order.
|
||||||
|
/// Shows loading state during fetch.
|
||||||
|
func loadRooms() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
rooms = try await manageRoomsUseCase.getAllRooms()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
rooms = []
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new room with the given name and icon.
|
||||||
|
///
|
||||||
|
/// The new room is added to the end of the room list and persisted
|
||||||
|
/// to the repository.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The display name for the new room.
|
||||||
|
/// - icon: The SF Symbol name for the room's icon.
|
||||||
|
func createRoom(name: String, icon: String) async {
|
||||||
|
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
errorMessage = "Room name cannot be empty"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let newRoom = try await manageRoomsUseCase.createRoom(name: name, icon: icon)
|
||||||
|
rooms.append(newRoom)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing room's properties.
|
||||||
|
///
|
||||||
|
/// Persists the updated room to the repository and updates the
|
||||||
|
/// local room list.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room with updated values.
|
||||||
|
func updateRoom(_ room: Room) async {
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await manageRoomsUseCase.updateRoom(room)
|
||||||
|
|
||||||
|
// Update the room in the local array
|
||||||
|
if let index = rooms.firstIndex(where: { $0.id == room.id }) {
|
||||||
|
rooms[index] = room
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a room and reassigns its plants to "Other".
|
||||||
|
///
|
||||||
|
/// Default rooms cannot be deleted. Plants assigned to the deleted
|
||||||
|
/// room are automatically reassigned to the "Other" room.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room to delete.
|
||||||
|
func deleteRoom(_ room: Room) async {
|
||||||
|
// Prevent deletion of default rooms (including "Other")
|
||||||
|
if room.isDefault {
|
||||||
|
if room.name == "Other" {
|
||||||
|
errorMessage = "The 'Other' room cannot be deleted"
|
||||||
|
} else {
|
||||||
|
errorMessage = "Default rooms cannot be deleted"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
// Find the "Other" room to reassign plants
|
||||||
|
let otherRoom = rooms.first { $0.name == "Other" }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await manageRoomsUseCase.deleteRoom(id: room.id, reassignPlantsTo: otherRoom?.id)
|
||||||
|
|
||||||
|
// Remove the room from the local array
|
||||||
|
rooms.removeAll { $0.id == room.id }
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles drag-and-drop reordering of rooms.
|
||||||
|
///
|
||||||
|
/// Updates the sort order of all affected rooms and persists
|
||||||
|
/// the changes to the repository.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - source: The indices of items being moved.
|
||||||
|
/// - destination: The destination index.
|
||||||
|
func moveRooms(from source: IndexSet, to destination: Int) async {
|
||||||
|
// Perform the move locally first for immediate UI feedback
|
||||||
|
rooms.move(fromOffsets: source, toOffset: destination)
|
||||||
|
|
||||||
|
// Update sort orders based on new positions
|
||||||
|
for (index, _) in rooms.enumerated() {
|
||||||
|
rooms[index].sortOrder = index
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await manageRoomsUseCase.reorderRooms(rooms)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
// Reload rooms to get the correct order from the repository
|
||||||
|
await loadRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears any displayed error message.
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a room can be deleted.
|
||||||
|
///
|
||||||
|
/// Default rooms and the "Other" room cannot be deleted.
|
||||||
|
///
|
||||||
|
/// - Parameter room: The room to check.
|
||||||
|
/// - Returns: `true` if the room can be deleted, `false` otherwise.
|
||||||
|
func canDeleteRoom(_ room: Room) -> Bool {
|
||||||
|
!room.isDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ struct SettingsView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
appearanceSection
|
appearanceSection
|
||||||
|
organizationSection
|
||||||
identificationSection
|
identificationSection
|
||||||
notificationsSection
|
notificationsSection
|
||||||
storageSection
|
storageSection
|
||||||
@@ -192,6 +193,31 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Organization Section
|
||||||
|
|
||||||
|
private var organizationSection: some View {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
RoomsListView()
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Manage Rooms")
|
||||||
|
Text("Organize plants by location")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "house")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Organization")
|
||||||
|
} footer: {
|
||||||
|
Text("Create and manage rooms to organize your plants by location in your home.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Notifications Section
|
// MARK: - Notifications Section
|
||||||
|
|
||||||
private var notificationsSection: some View {
|
private var notificationsSection: some View {
|
||||||
|
|||||||
@@ -277,10 +277,11 @@ final class UpdatePlantUseCaseTests: XCTestCase {
|
|||||||
let originalPlant = createTestPlant(id: plantID, notes: "Original notes")
|
let originalPlant = createTestPlant(id: plantID, notes: "Original notes")
|
||||||
mockRepository.addPlant(originalPlant)
|
mockRepository.addPlant(originalPlant)
|
||||||
|
|
||||||
|
let roomID = UUID()
|
||||||
var updatedPlant = originalPlant
|
var updatedPlant = originalPlant
|
||||||
updatedPlant.notes = "Updated notes"
|
updatedPlant.notes = "Updated notes"
|
||||||
updatedPlant.customName = "My Monstera"
|
updatedPlant.customName = "My Monstera"
|
||||||
updatedPlant.location = "Living Room"
|
updatedPlant.roomID = roomID
|
||||||
|
|
||||||
// When
|
// When
|
||||||
let result = try await sut.execute(plant: updatedPlant)
|
let result = try await sut.execute(plant: updatedPlant)
|
||||||
@@ -289,7 +290,7 @@ final class UpdatePlantUseCaseTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.id, plantID)
|
XCTAssertEqual(result.id, plantID)
|
||||||
XCTAssertEqual(result.notes, "Updated notes")
|
XCTAssertEqual(result.notes, "Updated notes")
|
||||||
XCTAssertEqual(result.customName, "My Monstera")
|
XCTAssertEqual(result.customName, "My Monstera")
|
||||||
XCTAssertEqual(result.location, "Living Room")
|
XCTAssertEqual(result.roomID, roomID)
|
||||||
|
|
||||||
XCTAssertEqual(mockRepository.existsCallCount, 1)
|
XCTAssertEqual(mockRepository.existsCallCount, 1)
|
||||||
XCTAssertEqual(mockRepository.updatePlantCallCount, 1)
|
XCTAssertEqual(mockRepository.updatePlantCallCount, 1)
|
||||||
@@ -345,20 +346,21 @@ final class UpdatePlantUseCaseTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.customName, "Bob the Plant")
|
XCTAssertEqual(result.customName, "Bob the Plant")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExecute_WhenUpdatingOnlyLocation_SuccessfullyUpdates() async throws {
|
func testExecute_WhenUpdatingOnlyRoomID_SuccessfullyUpdates() async throws {
|
||||||
// Given
|
// Given
|
||||||
let plantID = UUID()
|
let plantID = UUID()
|
||||||
let originalPlant = createTestPlant(id: plantID)
|
let originalPlant = createTestPlant(id: plantID)
|
||||||
mockRepository.addPlant(originalPlant)
|
mockRepository.addPlant(originalPlant)
|
||||||
|
|
||||||
|
let kitchenRoomID = UUID()
|
||||||
var updatedPlant = originalPlant
|
var updatedPlant = originalPlant
|
||||||
updatedPlant.location = "Kitchen windowsill"
|
updatedPlant.roomID = kitchenRoomID
|
||||||
|
|
||||||
// When
|
// When
|
||||||
let result = try await sut.execute(plant: updatedPlant)
|
let result = try await sut.execute(plant: updatedPlant)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
XCTAssertEqual(result.location, "Kitchen windowsill")
|
XCTAssertEqual(result.roomID, kitchenRoomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExecute_PreservesImmutableProperties() async throws {
|
func testExecute_PreservesImmutableProperties() async throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user