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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 15:19:21 -06:00
parent efd935568a
commit f41c77876a
11 changed files with 403 additions and 7 deletions

View File

@@ -174,6 +174,15 @@ final class DIContainer: DIContainerProtocol, ObservableObject {
}
}()
private lazy var _batchCompleteTasksUseCase: LazyService<BatchCompleteTasksUseCase> = {
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<ImageCache> = {
@@ -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()

View File

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

View File

@@ -57,6 +57,21 @@ actor InMemoryCareScheduleRepository: CareScheduleRepositoryProtocol {
schedules.removeValue(forKey: plantID)
}
func batchCompleteTasks(_ taskIDs: Set<UUID>) 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() {

View File

@@ -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<UUID>) async throws
}

View File

@@ -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<UUID>) 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<UUID>) async throws -> Int {
guard !taskIDs.isEmpty else {
return 0
}
try await careScheduleRepository.batchCompleteTasks(taskIDs)
return taskIDs.count
}
}

View File

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

View File

@@ -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<UUID> = []
/// 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)
}
}
}
}
}

View File

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

View File

@@ -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<UUID> = []
/// 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
}
}

View File

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

View File

@@ -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<UUID>?
// MARK: - CareScheduleRepositoryProtocol
@@ -111,6 +114,35 @@ final class MockCareScheduleRepository: CareScheduleRepositoryProtocol, @uncheck
schedules.removeValue(forKey: plantID)
}
func batchCompleteTasks(_ taskIDs: Set<UUID>) 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)