diff --git a/PlantGuide/Core/DI/DIContainer.swift b/PlantGuide/Core/DI/DIContainer.swift index 30c68fe..05073aa 100644 --- a/PlantGuide/Core/DI/DIContainer.swift +++ b/PlantGuide/Core/DI/DIContainer.swift @@ -205,6 +205,35 @@ final class DIContainer: DIContainerProtocol, ObservableObject { } }() + // MARK: - Room Services + + private lazy var _coreDataRoomStorage: LazyService = { + LazyService { + CoreDataRoomRepository(coreDataStack: CoreDataStack.shared) + } + }() + + private lazy var _createDefaultRoomsUseCase: LazyService = { + LazyService { [weak self] in + guard let self else { + fatalError("DIContainer deallocated unexpectedly") + } + return CreateDefaultRoomsUseCase(roomRepository: self.roomRepository) + } + }() + + private lazy var _manageRoomsUseCase: LazyService = { + 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 private lazy var _plantDatabaseService: LazyService = { @@ -270,6 +299,23 @@ final class DIContainer: DIContainerProtocol, ObservableObject { _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 private init() {} @@ -498,6 +544,11 @@ final class DIContainer: DIContainerProtocol, ObservableObject { BrowsePlantsViewModel(databaseService: plantDatabaseService) } + /// Factory method for RoomsViewModel + func makeRoomsViewModel() -> RoomsViewModel { + RoomsViewModel(manageRoomsUseCase: manageRoomsUseCase) + } + // MARK: - Custom Registration /// Register a custom factory for a type @@ -559,6 +610,10 @@ final class DIContainer: DIContainerProtocol, ObservableObject { // Identification use cases _identifyPlantOnDeviceUseCase.reset() _identifyPlantOnlineUseCase.reset() + // Room services + _coreDataRoomStorage.reset() + _createDefaultRoomsUseCase.reset() + _manageRoomsUseCase.reset() factories.removeAll() resolvedInstances.removeAll() } diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift index d679f0b..7e2ecd5 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift @@ -97,7 +97,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla if let existingPlant = existingPlants.first { // Update existing plant existingPlant.updateFromPlant(plant) - Self.updateMutableFields(on: existingPlant, from: plant) + Self.updateMutableFields(on: existingPlant, from: plant, in: context) } else { // Create new plant 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) managedObject.updateFromPlant(plant) - Self.updateMutableFields(on: managedObject, from: plant) + Self.updateMutableFields(on: managedObject, from: plant, in: context) } return () @@ -315,7 +315,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla } existingPlant.updateFromPlant(plant) - Self.updateMutableFields(on: existingPlant, from: plant) + Self.updateMutableFields(on: existingPlant, from: plant, in: context) return () } } @@ -442,14 +442,28 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla /// - Parameters: /// - managedObject: The managed object to update /// - 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.dateAdded, forKey: "dateAdded") managedObject.setValue(plant.confidenceScore, forKey: "confidenceScore") managedObject.setValue(plant.notes, forKey: "notes") managedObject.setValue(plant.isFavorite, forKey: "isFavorite") 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 @@ -479,7 +493,14 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla let notes = managedObject.value(forKey: "notes") as? String let isFavorite = (managedObject.value(forKey: "isFavorite") as? Bool) ?? false 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( id: id, @@ -496,7 +517,7 @@ final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePla notes: notes, isFavorite: isFavorite, customName: customName, - location: location + roomID: roomID ) } diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataRoomRepository.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataRoomRepository.swift new file mode 100644 index 0000000..4697375 --- /dev/null +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataRoomRepository.swift @@ -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(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 diff --git a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift index 172933e..c89c9ef 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift @@ -68,6 +68,9 @@ public class PlantMO: NSManagedObject { /// Cached care information from Trefle API (optional, one-to-one, cascade delete) @NSManaged public var plantCareInfo: PlantCareInfoMO? + + /// The room where this plant is located (optional, to-one) + @NSManaged public var room: RoomMO? } // MARK: - Domain Model Conversion @@ -94,7 +97,7 @@ extension PlantMO { notes: notes, isFavorite: isFavorite, customName: customName, - location: location + roomID: room?.id ) } @@ -120,13 +123,16 @@ extension PlantMO { plantMO.notes = plant.notes plantMO.isFavorite = plant.isFavorite 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 } /// Updates this managed object with values from a Plant domain model. /// - 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) { id = plant.id scientificName = plant.scientificName @@ -142,7 +148,28 @@ extension PlantMO { notes = plant.notes isFavorite = plant.isFavorite 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 + } } } diff --git a/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/RoomMO.swift b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/RoomMO.swift new file mode 100644 index 0000000..bfb203e --- /dev/null +++ b/PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/RoomMO.swift @@ -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 { + return NSFetchRequest(entityName: "RoomMO") + } +} diff --git a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents index f9058ee..8a47f77 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents +++ b/PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld/PlantGuideModel.xcdatamodel/contents @@ -19,6 +19,7 @@ + @@ -67,4 +68,12 @@ + + + + + + + + diff --git a/PlantGuide/Data/Mappers/PredictionToPlantMapper.swift b/PlantGuide/Data/Mappers/PredictionToPlantMapper.swift index ba132c3..d431556 100644 --- a/PlantGuide/Data/Mappers/PredictionToPlantMapper.swift +++ b/PlantGuide/Data/Mappers/PredictionToPlantMapper.swift @@ -65,7 +65,7 @@ struct PredictionToPlantMapper { notes: nil, isFavorite: false, customName: nil, - location: nil + roomID: nil ) } diff --git a/PlantGuide/Data/Mappers/RoomMapper.swift b/PlantGuide/Data/Mappers/RoomMapper.swift new file mode 100644 index 0000000..839397e --- /dev/null +++ b/PlantGuide/Data/Mappers/RoomMapper.swift @@ -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) + } +} diff --git a/PlantGuide/Domain/Entities/Plant.swift b/PlantGuide/Domain/Entities/Plant.swift index 16a79a4..bc30314 100644 --- a/PlantGuide/Domain/Entities/Plant.swift +++ b/PlantGuide/Domain/Entities/Plant.swift @@ -56,8 +56,8 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable { /// A custom name the user has given to this plant var customName: String? - /// Description of where the plant is located (e.g., "Living room window", "Backyard garden") - var location: String? + /// The ID of the room where the plant is located (e.g., Kitchen, Living Room) + var roomID: UUID? // MARK: - Computed Properties @@ -85,7 +85,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable { /// - notes: User notes about the plant. Defaults to nil. /// - isFavorite: Whether marked as favorite. Defaults to false. /// - 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( id: UUID = UUID(), scientificName: String, @@ -101,7 +101,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable { notes: String? = nil, isFavorite: Bool = false, customName: String? = nil, - location: String? = nil + roomID: UUID? = nil ) { self.id = id self.scientificName = scientificName @@ -117,7 +117,7 @@ struct Plant: Identifiable, Sendable, Equatable, Hashable { self.notes = notes self.isFavorite = isFavorite self.customName = customName - self.location = location + self.roomID = roomID } // MARK: - Hashable diff --git a/PlantGuide/Domain/Entities/Room.swift b/PlantGuide/Domain/Entities/Room.swift new file mode 100644 index 0000000..861e9b7 --- /dev/null +++ b/PlantGuide/Domain/Entities/Room.swift @@ -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), + ] +} diff --git a/PlantGuide/Domain/RepositoryInterfaces/RoomRepositoryProtocol.swift b/PlantGuide/Domain/RepositoryInterfaces/RoomRepositoryProtocol.swift new file mode 100644 index 0000000..cd449e1 --- /dev/null +++ b/PlantGuide/Domain/RepositoryInterfaces/RoomRepositoryProtocol.swift @@ -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 +} diff --git a/PlantGuide/Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift b/PlantGuide/Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift new file mode 100644 index 0000000..3e5f358 --- /dev/null +++ b/PlantGuide/Domain/UseCases/Room/CreateDefaultRoomsUseCase.swift @@ -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) + } + } +} diff --git a/PlantGuide/Domain/UseCases/Room/ManageRoomsUseCase.swift b/PlantGuide/Domain/UseCases/Room/ManageRoomsUseCase.swift new file mode 100644 index 0000000..dc5f080 --- /dev/null +++ b/PlantGuide/Domain/UseCases/Room/ManageRoomsUseCase.swift @@ -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) + } + } + } +} diff --git a/PlantGuide/PlantGuideApp.swift b/PlantGuide/PlantGuideApp.swift index 183244a..5f20cdd 100644 --- a/PlantGuide/PlantGuideApp.swift +++ b/PlantGuide/PlantGuideApp.swift @@ -33,6 +33,22 @@ struct PlantGuideApp: App { MainTabView() .environment(appearanceManager) .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)") } } } diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift index 132e835..0029bf0 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift @@ -45,6 +45,9 @@ struct PlantDetailView: View { } else if let error = viewModel.error { errorView(error: error) } else { + // Room assignment section + roomSection + // Care information section if let careInfo = viewModel.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 private var identificationInfoSection: some View { diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift index 6586228..8b6ab2c 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift @@ -311,4 +311,19 @@ final class PlantDetailViewModel { 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 + } + } } diff --git a/PlantGuide/Presentation/Scenes/Rooms/RoomEditorView.swift b/PlantGuide/Presentation/Scenes/Rooms/RoomEditorView.swift new file mode 100644 index 0000000..1f1b617 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/Rooms/RoomEditorView.swift @@ -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)") + } +} diff --git a/PlantGuide/Presentation/Scenes/Rooms/RoomPickerView.swift b/PlantGuide/Presentation/Scenes/Rooms/RoomPickerView.swift new file mode 100644 index 0000000..6920ce0 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/Rooms/RoomPickerView.swift @@ -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)) + } +} diff --git a/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift b/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift new file mode 100644 index 0000000..72d1644 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift @@ -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() +} diff --git a/PlantGuide/Presentation/Scenes/Rooms/RoomsViewModel.swift b/PlantGuide/Presentation/Scenes/Rooms/RoomsViewModel.swift new file mode 100644 index 0000000..018a735 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/Rooms/RoomsViewModel.swift @@ -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 + } +} diff --git a/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift b/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift index 5756668..940c49a 100644 --- a/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift +++ b/PlantGuide/Presentation/Scenes/Settings/SettingsView.swift @@ -33,6 +33,7 @@ struct SettingsView: View { NavigationStack { Form { appearanceSection + organizationSection identificationSection notificationsSection 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 private var notificationsSection: some View { diff --git a/PlantGuideTests/UpdatePlantUseCaseTests.swift b/PlantGuideTests/UpdatePlantUseCaseTests.swift index 0ac7baa..6f331ed 100644 --- a/PlantGuideTests/UpdatePlantUseCaseTests.swift +++ b/PlantGuideTests/UpdatePlantUseCaseTests.swift @@ -277,10 +277,11 @@ final class UpdatePlantUseCaseTests: XCTestCase { let originalPlant = createTestPlant(id: plantID, notes: "Original notes") mockRepository.addPlant(originalPlant) + let roomID = UUID() var updatedPlant = originalPlant updatedPlant.notes = "Updated notes" updatedPlant.customName = "My Monstera" - updatedPlant.location = "Living Room" + updatedPlant.roomID = roomID // When let result = try await sut.execute(plant: updatedPlant) @@ -289,7 +290,7 @@ final class UpdatePlantUseCaseTests: XCTestCase { XCTAssertEqual(result.id, plantID) XCTAssertEqual(result.notes, "Updated notes") XCTAssertEqual(result.customName, "My Monstera") - XCTAssertEqual(result.location, "Living Room") + XCTAssertEqual(result.roomID, roomID) XCTAssertEqual(mockRepository.existsCallCount, 1) XCTAssertEqual(mockRepository.updatePlantCallCount, 1) @@ -345,20 +346,21 @@ final class UpdatePlantUseCaseTests: XCTestCase { XCTAssertEqual(result.customName, "Bob the Plant") } - func testExecute_WhenUpdatingOnlyLocation_SuccessfullyUpdates() async throws { + func testExecute_WhenUpdatingOnlyRoomID_SuccessfullyUpdates() async throws { // Given let plantID = UUID() let originalPlant = createTestPlant(id: plantID) mockRepository.addPlant(originalPlant) + let kitchenRoomID = UUID() var updatedPlant = originalPlant - updatedPlant.location = "Kitchen windowsill" + updatedPlant.roomID = kitchenRoomID // When let result = try await sut.execute(plant: updatedPlant) // Then - XCTAssertEqual(result.location, "Kitchen windowsill") + XCTAssertEqual(result.roomID, kitchenRoomID) } func testExecute_PreservesImmutableProperties() async throws {