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:
Trey t
2026-01-23 14:44:14 -06:00
parent d125216a95
commit 7786a40ae0
22 changed files with 2245 additions and 21 deletions

View File

@@ -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
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
@@ -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()
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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")
}
}

View File

@@ -19,6 +19,7 @@
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CareScheduleMO" inverseName="plant" inverseEntity="CareScheduleMO"/>
<relationship name="identifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="IdentificationMO" inverseName="plant" inverseEntity="IdentificationMO"/>
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RoomMO" inverseName="plants" inverseEntity="RoomMO"/>
</entity>
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
@@ -67,4 +68,12 @@
<attribute name="wateringScheduleData" attributeType="Binary"/>
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
</entity>
<entity name="RoomMO" representedClassName="RoomMO" syncable="YES">
<attribute name="icon" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
<attribute name="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>

View File

@@ -65,7 +65,7 @@ struct PredictionToPlantMapper {
notes: nil,
isFavorite: false,
customName: nil,
location: nil
roomID: nil
)
}

View 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)
}
}

View File

@@ -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

View 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),
]
}

View File

@@ -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
}

View 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)
}
}
}

View 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)
}
}
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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 {

View File

@@ -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
}
}
}

View 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)")
}
}

View 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))
}
}

View 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()
}

View 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
}
}

View File

@@ -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 {

View File

@@ -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 {