From f41c77876a9fbb864265486342914d3f22c9021b Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 23 Jan 2026 15:19:21 -0600 Subject: [PATCH] Add batch actions for multi-task completion (Phase 7) Implement batch task completion feature allowing users to select and complete multiple care tasks at once. Adds edit mode to Today View with selection checkmarks, floating BatchActionBar, and confirmation dialog for completing more than 3 tasks. Co-Authored-By: Claude Opus 4.5 --- PlantGuide/Core/DI/DIContainer.swift | 15 +++ .../CoreDataCareScheduleStorage.swift | 19 +++ .../InMemoryCareScheduleRepository.swift | 15 +++ .../CareScheduleRepositoryProtocol.swift | 5 + .../PlantCare/BatchCompleteTasksUseCase.swift | 71 ++++++++++++ .../TodayView/Components/BatchActionBar.swift | 108 ++++++++++++++++++ .../TodayView/Components/RoomTaskGroup.swift | 35 +++++- .../Scenes/TodayView/TodayView.swift | 34 ++++++ .../Scenes/TodayView/TodayViewModel.swift | 69 +++++++++++ .../CreateCareScheduleUseCaseTests.swift | 4 +- .../Mocks/MockCareScheduleRepository.swift | 35 ++++++ 11 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 PlantGuide/Domain/UseCases/PlantCare/BatchCompleteTasksUseCase.swift create mode 100644 PlantGuide/Presentation/Scenes/TodayView/Components/BatchActionBar.swift 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)