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