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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user