diff --git a/PlantGuide/Core/DI/DIContainer.swift b/PlantGuide/Core/DI/DIContainer.swift index c8b9c8d..9ebbe42 100644 --- a/PlantGuide/Core/DI/DIContainer.swift +++ b/PlantGuide/Core/DI/DIContainer.swift @@ -174,6 +174,15 @@ final class DIContainer: DIContainerProtocol, ObservableObject { } }() + private lazy var _batchCompleteTasksUseCase: LazyService = { + LazyService { [weak self] in + guard let self else { + fatalError("DIContainer deallocated unexpectedly") + } + return BatchCompleteTasksUseCase(careScheduleRepository: self.careScheduleRepository) + } + }() + // MARK: - Phase 5 Services private lazy var _imageCache: LazyService = { @@ -375,6 +384,11 @@ final class DIContainer: DIContainerProtocol, ObservableObject { _createCareScheduleUseCase.value } + /// Use case for batch completing multiple care tasks + var batchCompleteTasksUseCase: BatchCompleteTasksUseCaseProtocol { + _batchCompleteTasksUseCase.value + } + // MARK: - Phase 5 Services Accessors /// Thread-safe image cache with memory and disk storage @@ -608,6 +622,7 @@ final class DIContainer: DIContainerProtocol, ObservableObject { _notificationService.reset() _fetchPlantCareUseCase.reset() _createCareScheduleUseCase.reset() + _batchCompleteTasksUseCase.reset() // Phase 5 services _imageCache.reset() _localImageStorage.reset() diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataCareScheduleStorage.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataCareScheduleStorage.swift index 74141bd..0818d4f 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataCareScheduleStorage.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataCareScheduleStorage.swift @@ -244,6 +244,25 @@ final class CoreDataCareScheduleStorage: CareScheduleRepositoryProtocol, @unchec } } + /// Batch updates multiple tasks to completed status + /// - Parameter taskIDs: Set of task UUIDs to mark as complete + /// - Throws: CareScheduleStorageError if any task update fails + func batchCompleteTasks(_ taskIDs: Set) async throws { + try await coreDataStack.performBackgroundTask { context in + let fetchRequest = CareTaskMO.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id IN %@", taskIDs as CVarArg) + + let results = try context.fetch(fetchRequest) + let now = Date() + + for taskMO in results { + taskMO.completedDate = now + } + + return () + } + } + /// Fetches overdue care tasks (past scheduled date and not completed) /// - Returns: An array of overdue care tasks /// - Throws: CareScheduleStorageError if the fetch operation fails diff --git a/PlantGuide/Data/Repositories/InMemoryCareScheduleRepository.swift b/PlantGuide/Data/Repositories/InMemoryCareScheduleRepository.swift index 43356f2..e0d789c 100644 --- a/PlantGuide/Data/Repositories/InMemoryCareScheduleRepository.swift +++ b/PlantGuide/Data/Repositories/InMemoryCareScheduleRepository.swift @@ -57,6 +57,21 @@ actor InMemoryCareScheduleRepository: CareScheduleRepositoryProtocol { schedules.removeValue(forKey: plantID) } + func batchCompleteTasks(_ taskIDs: Set) async throws { + for (plantID, var schedule) in schedules { + var modified = false + for (index, task) in schedule.tasks.enumerated() { + if taskIDs.contains(task.id) && !task.isCompleted { + schedule.tasks[index] = task.completed() + modified = true + } + } + if modified { + schedules[plantID] = schedule + } + } + } + // MARK: - Testing Support func reset() { diff --git a/PlantGuide/Domain/RepositoryInterfaces/CareScheduleRepositoryProtocol.swift b/PlantGuide/Domain/RepositoryInterfaces/CareScheduleRepositoryProtocol.swift index 2c9f88a..e945049 100644 --- a/PlantGuide/Domain/RepositoryInterfaces/CareScheduleRepositoryProtocol.swift +++ b/PlantGuide/Domain/RepositoryInterfaces/CareScheduleRepositoryProtocol.swift @@ -41,4 +41,9 @@ protocol CareScheduleRepositoryProtocol: Sendable { /// - Parameter plantID: The unique identifier of the plant whose schedule to delete. /// - Throws: An error if the delete operation fails. func delete(for plantID: UUID) async throws + + /// Batch updates multiple tasks to completed status + /// - Parameter taskIDs: Set of task UUIDs to mark as complete + /// - Throws: An error if any task update fails + func batchCompleteTasks(_ taskIDs: Set) async throws } diff --git a/PlantGuide/Domain/UseCases/PlantCare/BatchCompleteTasksUseCase.swift b/PlantGuide/Domain/UseCases/PlantCare/BatchCompleteTasksUseCase.swift new file mode 100644 index 0000000..160d7bc --- /dev/null +++ b/PlantGuide/Domain/UseCases/PlantCare/BatchCompleteTasksUseCase.swift @@ -0,0 +1,71 @@ +// +// BatchCompleteTasksUseCase.swift +// PlantGuide +// +// Created for PlantGuide plant identification app. +// + +import Foundation + +// MARK: - BatchCompleteTasksUseCaseProtocol + +/// Protocol defining the contract for batch completing care tasks. +/// +/// This use case enables efficient batch completion of multiple care tasks +/// in a single operation, reducing database round trips and providing +/// better performance for bulk task management. +protocol BatchCompleteTasksUseCaseProtocol: Sendable { + /// Completes multiple care tasks in a batch operation. + /// + /// - Parameter taskIDs: The set of task IDs to complete. + /// - Returns: The count of tasks successfully completed. + /// - Throws: An error if the batch operation fails. + /// + /// Example usage: + /// ```swift + /// let useCase = BatchCompleteTasksUseCase(careScheduleRepository: repository) + /// let completedCount = try await useCase.execute(taskIDs: [task1.id, task2.id, task3.id]) + /// print("Completed \(completedCount) tasks") + /// ``` + func execute(taskIDs: Set) async throws -> Int +} + +// MARK: - BatchCompleteTasksUseCase + +/// Use case for batch completing multiple care tasks. +/// +/// This implementation delegates to the care schedule repository's +/// batch completion method, providing a clean abstraction for the +/// presentation layer while maintaining single responsibility. +/// +/// ## Example Usage +/// ```swift +/// let useCase = BatchCompleteTasksUseCase(careScheduleRepository: repository) +/// let completedCount = try await useCase.execute(taskIDs: selectedTaskIDs) +/// ``` +final class BatchCompleteTasksUseCase: BatchCompleteTasksUseCaseProtocol { + + // MARK: - Dependencies + + private let careScheduleRepository: CareScheduleRepositoryProtocol + + // MARK: - Initialization + + /// Creates a new instance of the use case. + /// + /// - Parameter careScheduleRepository: The repository for care schedule operations. + init(careScheduleRepository: CareScheduleRepositoryProtocol) { + self.careScheduleRepository = careScheduleRepository + } + + // MARK: - BatchCompleteTasksUseCaseProtocol + + func execute(taskIDs: Set) async throws -> Int { + guard !taskIDs.isEmpty else { + return 0 + } + + try await careScheduleRepository.batchCompleteTasks(taskIDs) + return taskIDs.count + } +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/Components/BatchActionBar.swift b/PlantGuide/Presentation/Scenes/TodayView/Components/BatchActionBar.swift new file mode 100644 index 0000000..c4567f3 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/TodayView/Components/BatchActionBar.swift @@ -0,0 +1,108 @@ +// +// BatchActionBar.swift +// PlantGuide +// +// Component for batch actions when tasks are selected in edit mode. +// + +import SwiftUI + +// MARK: - BatchActionBar + +/// A floating action bar that appears when tasks are selected for batch operations +struct BatchActionBar: View { + // MARK: - Properties + + /// Number of currently selected tasks + let selectedCount: Int + + /// Closure called when the complete action is triggered + let onComplete: () -> Void + + /// Closure called when the cancel/clear action is triggered + let onCancel: () -> Void + + // MARK: - Body + + var body: some View { + HStack(spacing: DesignSystem.Spacing.md) { + // Selection count label + Text("\(selectedCount) selected") + .font(DesignSystem.Typography.headline) + .foregroundStyle(DesignSystem.Colors.textPrimary) + + Spacer() + + // Cancel button + Button { + onCancel() + } label: { + Text("Clear") + .font(DesignSystem.Typography.body) + .foregroundStyle(DesignSystem.Colors.textSecondary) + } + .buttonStyle(.plain) + + // Complete button + Button { + onComplete() + } label: { + HStack(spacing: DesignSystem.Spacing.xs) { + Image(systemName: "checkmark.circle.fill") + Text("Complete") + } + .font(DesignSystem.Typography.headline) + .foregroundStyle(.white) + .padding(.horizontal, DesignSystem.Spacing.md) + .padding(.vertical, DesignSystem.Spacing.sm) + .background(DesignSystem.Colors.accent) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, DesignSystem.Spacing.lg) + .padding(.vertical, DesignSystem.Spacing.md) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.lg)) + .shadow(DesignSystem.Shadow.md) + .accessibilityElement(children: .contain) + .accessibilityLabel("Batch actions") + .accessibilityHint("\(selectedCount) tasks selected") + } +} + +// MARK: - Preview + +#Preview("3 Tasks Selected") { + ZStack { + Color.gray.opacity(0.2) + .ignoresSafeArea() + + VStack { + Spacer() + BatchActionBar( + selectedCount: 3, + onComplete: {}, + onCancel: {} + ) + .padding() + } + } +} + +#Preview("1 Task Selected") { + ZStack { + Color.gray.opacity(0.2) + .ignoresSafeArea() + + VStack { + Spacer() + BatchActionBar( + selectedCount: 1, + onComplete: {}, + onCancel: {} + ) + .padding() + } + } +} diff --git a/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift b/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift index 0555730..e30a64f 100644 --- a/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift +++ b/PlantGuide/Presentation/Scenes/TodayView/Components/RoomTaskGroup.swift @@ -15,9 +15,18 @@ struct RoomTaskGroup: View { /// Closure to get the plant name for a given task let plantName: (CareTask) -> String + /// Whether the view is in edit/selection mode + var isEditMode: Bool = false + + /// Set of currently selected task IDs + var selectedTaskIDs: Set = [] + /// Closure called when a task is marked as complete let onComplete: (CareTask) -> Void + /// Closure called when a task's selection is toggled + var onToggleSelection: ((UUID) -> Void)? + // MARK: - Computed Properties /// Whether all tasks in this group are watering tasks @@ -37,11 +46,27 @@ struct RoomTaskGroup: View { roomHeader ForEach(tasks) { task in - CareTaskRow( - task: task, - plantName: plantName(task), - onComplete: { onComplete(task) } - ) + HStack { + if isEditMode { + Image(systemName: selectedTaskIDs.contains(task.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedTaskIDs.contains(task.id) ? DesignSystem.Colors.accent : .secondary) + .onTapGesture { + onToggleSelection?(task.id) + } + } + + CareTaskRow( + task: task, + plantName: plantName(task), + onComplete: isEditMode ? nil : { onComplete(task) } + ) + } + .contentShape(Rectangle()) + .onTapGesture { + if isEditMode { + onToggleSelection?(task.id) + } + } } } } diff --git a/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift b/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift index 84cb4ec..da7aa88 100644 --- a/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift +++ b/PlantGuide/Presentation/Scenes/TodayView/TodayView.swift @@ -42,9 +42,38 @@ struct TodayView: View { } } .navigationTitle(viewModel.greeting) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(viewModel.isEditMode ? "Done" : "Edit") { + viewModel.toggleEditMode() + } + } + } .overlay { emptyStateOverlay } + .overlay(alignment: .bottom) { + if viewModel.isEditMode && viewModel.hasSelection { + BatchActionBar( + selectedCount: viewModel.selectedCount, + onComplete: { + Task { await viewModel.batchCompleteSelected() } + }, + onCancel: { + viewModel.clearSelection() + } + ) + .padding() + } + } + .alert("Complete \(viewModel.selectedCount) tasks?", isPresented: $viewModel.showBatchConfirmation) { + Button("Complete All", role: .destructive) { + Task { await viewModel.performBatchComplete() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will mark all selected tasks as completed.") + } .task { await viewModel.loadTasks() } @@ -107,8 +136,13 @@ struct TodayView: View { room: room, tasks: tasks, plantName: viewModel.plantName, + isEditMode: viewModel.isEditMode, + selectedTaskIDs: viewModel.selectedTaskIDs, onComplete: { task in Task { await viewModel.markComplete(task) } + }, + onToggleSelection: { taskID in + viewModel.toggleSelection(for: taskID) } ) } diff --git a/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift b/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift index f49fb73..9848407 100644 --- a/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift +++ b/PlantGuide/Presentation/Scenes/TodayView/TodayViewModel.swift @@ -34,6 +34,15 @@ final class TodayViewModel { /// Indicates whether tasks are currently being loaded private(set) var isLoading = false + /// Set of currently selected task IDs for batch operations + private(set) var selectedTaskIDs: Set = [] + + /// Whether the view is in edit/selection mode + private(set) var isEditMode = false + + /// Whether to show confirmation dialog for batch complete + var showBatchConfirmation = false + /// A sentinel Room used for plants without an assigned room static let unassignedRoom = Room( id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, @@ -161,6 +170,12 @@ final class TodayViewModel { overdueTasks.isEmpty && todayTasks.isEmpty && completedTodayCount == 0 } + /// Number of selected tasks + var selectedCount: Int { selectedTaskIDs.count } + + /// Whether any tasks are selected + var hasSelection: Bool { !selectedTaskIDs.isEmpty } + // MARK: - Methods /// Loads all care tasks, plants, and rooms from the data source @@ -226,4 +241,58 @@ final class TodayViewModel { } return rooms[roomID] } + + // MARK: - Selection Methods + + /// Toggles edit/selection mode + func toggleEditMode() { + isEditMode.toggle() + if !isEditMode { + selectedTaskIDs.removeAll() + } + } + + /// Toggles selection state for a task + func toggleSelection(for taskID: UUID) { + if selectedTaskIDs.contains(taskID) { + selectedTaskIDs.remove(taskID) + } else { + selectedTaskIDs.insert(taskID) + } + } + + /// Selects all tasks in a room + func selectAllInRoom(_ room: Room) { + guard let tasks = todayTasksByRoom[room] else { return } + for task in tasks where !task.isCompleted { + selectedTaskIDs.insert(task.id) + } + } + + /// Clears all selections + func clearSelection() { + selectedTaskIDs.removeAll() + } + + /// Completes all selected tasks + func batchCompleteSelected() async { + // If more than 3 tasks, show confirmation first + if selectedCount > 3 { + showBatchConfirmation = true + return + } + await performBatchComplete() + } + + /// Actually performs the batch complete after confirmation + func performBatchComplete() async { + let tasksToComplete = selectedTaskIDs + for taskID in tasksToComplete { + if let task = allTasks.first(where: { $0.id == taskID }) { + await markComplete(task) + } + } + selectedTaskIDs.removeAll() + isEditMode = false + } } diff --git a/PlantGuideTests/CreateCareScheduleUseCaseTests.swift b/PlantGuideTests/CreateCareScheduleUseCaseTests.swift index 068eb4c..0ad0391 100644 --- a/PlantGuideTests/CreateCareScheduleUseCaseTests.swift +++ b/PlantGuideTests/CreateCareScheduleUseCaseTests.swift @@ -157,7 +157,7 @@ final class CreateCareScheduleUseCaseTests: XCTestCase { // Then let wateringTasks = result.tasks.filter { $0.type == .watering } - XCTAssertTrue(wateringTasks.allSatisfy { $0.notes?.contains("thorough") ?? false }) + XCTAssertTrue(wateringTasks.allSatisfy { $0.notes.contains("thorough") }) } func testExecute_WateringTasks_HaveCorrectPlantID() async throws { @@ -242,7 +242,7 @@ final class CreateCareScheduleUseCaseTests: XCTestCase { // Then let fertilizerTasks = result.tasks.filter { $0.type == .fertilizing } - XCTAssertTrue(fertilizerTasks.allSatisfy { $0.notes?.contains("highNitrogen") ?? false }) + XCTAssertTrue(fertilizerTasks.allSatisfy { $0.notes.contains("highNitrogen") }) } // MARK: - User Preferences Tests diff --git a/PlantGuideTests/Mocks/MockCareScheduleRepository.swift b/PlantGuideTests/Mocks/MockCareScheduleRepository.swift index ab33ef2..707ef4c 100644 --- a/PlantGuideTests/Mocks/MockCareScheduleRepository.swift +++ b/PlantGuideTests/Mocks/MockCareScheduleRepository.swift @@ -26,6 +26,7 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck var fetchAllTasksCallCount = 0 var updateTaskCallCount = 0 var deleteCallCount = 0 + var batchCompleteCallCount = 0 // MARK: - Error Configuration @@ -35,6 +36,7 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck var shouldThrowOnFetchAllTasks = false var shouldThrowOnUpdateTask = false var shouldThrowOnDelete = false + var shouldThrowOnBatchComplete = false var errorToThrow: Error = NSError( domain: "MockError", @@ -48,6 +50,7 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck var lastFetchedPlantID: UUID? var lastUpdatedTask: CareTask? var lastDeletedPlantID: UUID? + var lastBatchCompletedTaskIDs: Set? // MARK: - CareScheduleRepositoryProtocol @@ -111,6 +114,35 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck schedules.removeValue(forKey: plantID) } + func batchCompleteTasks(_ taskIDs: Set) async throws { + batchCompleteCallCount += 1 + lastBatchCompletedTaskIDs = taskIDs + if shouldThrowOnBatchComplete { + throw errorToThrow + } + + let now = Date() + for (plantID, var schedule) in schedules { + var modified = false + for (index, task) in schedule.tasks.enumerated() { + if taskIDs.contains(task.id) && !task.isCompleted { + schedule.tasks[index] = CareTask( + id: task.id, + plantID: task.plantID, + type: task.type, + scheduledDate: task.scheduledDate, + completedDate: now, + notes: task.notes + ) + modified = true + } + } + if modified { + schedules[plantID] = schedule + } + } + } + // MARK: - Helper Methods /// Resets all state for clean test setup @@ -123,6 +155,7 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck fetchAllTasksCallCount = 0 updateTaskCallCount = 0 deleteCallCount = 0 + batchCompleteCallCount = 0 shouldThrowOnSave = false shouldThrowOnFetch = false @@ -130,11 +163,13 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck shouldThrowOnFetchAllTasks = false shouldThrowOnUpdateTask = false shouldThrowOnDelete = false + shouldThrowOnBatchComplete = false lastSavedSchedule = nil lastFetchedPlantID = nil lastUpdatedTask = nil lastDeletedPlantID = nil + lastBatchCompletedTaskIDs = nil } /// Adds a schedule directly to storage (bypasses save method)