focus-flow/lib/src/task_actions.dart

277 lines
8.4 KiB
Dart

// Flexible task card actions.
//
// The main scheduling engine moves tasks through time. This file models the
// small user actions that appear on a flexible task card, such as done, push,
// backlog, and break-up. Keeping these in a service makes UI button handlers
// thin and keeps task-type safety checks in one place.
import 'models.dart';
import 'scheduling_engine.dart';
/// Quick actions available from a flexible task card.
///
/// These are the low-friction card controls the UI can expose directly on a
/// planned flexible task. The service below translates each button into either a
/// direct task update, a scheduling operation, or a follow-up flow.
enum FlexibleTaskQuickAction {
/// Mark the task completed.
done,
/// Ask the user where the task should be pushed.
push,
/// Move the task out of today's plan and into backlog.
backlog,
/// Start a flow that splits the task into child tasks.
breakUp,
}
/// Explicit push destinations shown after choosing the push quick action.
///
/// Push starts as a simple quick action, but the actual destination requires one
/// more choice. Keeping destinations as a separate enum prevents the initial card
/// action list from becoming too crowded.
enum PushDestination {
/// Move the task later within the current planning window.
nextAvailableSlot,
/// Move the task to the beginning of the supplied tomorrow/future window.
tomorrowTopOfQueue,
/// Remove the task from the active timeline and store it for later.
backlog,
}
/// Domain result for a flexible task quick action.
///
/// This result deliberately supports three outcomes: the task changed, the user
/// must choose a push destination, or the UI should start the child-task flow.
/// That keeps card code from guessing how to interpret each action.
class FlexibleTaskActionResult {
const FlexibleTaskActionResult({
required this.action,
required this.task,
this.pushDestinations = const <PushDestination>[],
this.startsChildTaskFlow = false,
});
/// Action the user selected.
final FlexibleTaskQuickAction action;
/// Current or updated task, depending on the action.
final Task task;
/// Destination choices to show after `push`; empty for direct actions.
final List<PushDestination> pushDestinations;
/// Whether the UI should open a child-task creation flow.
final bool startsChildTaskFlow;
/// True when the action directly produced an updated [task].
bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty;
}
/// Result from applying a selected push destination.
///
/// The selected destination is included next to the [SchedulingResult] so UI and
/// tests can distinguish "moved later today" from "moved to tomorrow" even if
/// the low-level scheduling change shape is similar.
class PushDestinationResult {
const PushDestinationResult({
required this.destination,
required this.schedulingResult,
});
/// Destination that was applied.
final PushDestination destination;
/// Full scheduler output: updated tasks, notices, changes, and overlaps.
final SchedulingResult schedulingResult;
/// Convenience flag for UI copy or persistence behavior that cares about the
/// tomorrow queue specifically.
bool get placesAtTomorrowTopOfQueue {
return destination == PushDestination.tomorrowTopOfQueue;
}
}
/// Applies low-friction quick actions for flexible task cards.
///
/// This service is the adapter between small UI button presses and domain logic.
/// It intentionally only accepts flexible tasks; required/locked/surprise items
/// should have their own action rules so the UI cannot accidentally apply a
/// flexible-only behavior to a fixed commitment.
class FlexibleTaskActionService {
const FlexibleTaskActionService({
this.schedulingEngine = const SchedulingEngine(),
});
/// Scheduling dependency used for actions that need timeline changes.
final SchedulingEngine schedulingEngine;
/// Apply the first-stage quick action.
///
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
/// list of destinations the UI should present. `breakUp` signals that the UI
/// should start a child-task flow rather than changing the task immediately.
FlexibleTaskActionResult apply({
required Task task,
required FlexibleTaskQuickAction action,
DateTime? updatedAt,
}) {
if (!task.isFlexible) {
throw ArgumentError.value(
task.type, 'task.type', 'Task must be flexible.');
}
switch (action) {
case FlexibleTaskQuickAction.done:
return FlexibleTaskActionResult(
action: action,
task: task.copyWith(
status: TaskStatus.completed,
updatedAt: updatedAt ?? DateTime.now(),
),
);
case FlexibleTaskQuickAction.push:
return FlexibleTaskActionResult(
action: action,
task: task,
pushDestinations: const [
PushDestination.nextAvailableSlot,
PushDestination.tomorrowTopOfQueue,
PushDestination.backlog,
],
);
case FlexibleTaskQuickAction.backlog:
return FlexibleTaskActionResult(
action: action,
task: schedulingEngine.moveToBacklog(task, updatedAt: updatedAt),
);
case FlexibleTaskQuickAction.breakUp:
return FlexibleTaskActionResult(
action: action,
task: task,
startsChildTaskFlow: true,
);
}
}
/// Apply the second-stage destination selected after the `push` action.
///
/// This needs the full [SchedulingInput] because pushing can shift other
/// flexible tasks and must avoid locked/required intervals.
PushDestinationResult applyPushDestination({
required PushDestination destination,
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
final result = switch (destination) {
PushDestination.nextAvailableSlot =>
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
PushDestination.tomorrowTopOfQueue =>
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
PushDestination.backlog => _moveTaskToBacklog(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
};
return PushDestinationResult(
destination: destination,
schedulingResult: result,
);
}
/// Move one planned flexible task to backlog inside a scheduling result.
///
/// This mirrors the shape of other push destination results so callers can
/// handle every destination through the same `SchedulingResult` interface.
SchedulingResult _moveTaskToBacklog({
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
final task = _taskById(input.tasks, taskId);
if (task == null) {
return SchedulingResult(
tasks: input.tasks,
notices: [
SchedulingNotice(
'Task was not found.',
type: SchedulingNoticeType.noFit,
taskId: taskId,
),
],
);
}
if (!task.isFlexible || task.status != TaskStatus.planned) {
return SchedulingResult(
tasks: input.tasks,
notices: [
SchedulingNotice(
'Only planned flexible tasks can be moved to backlog.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
],
);
}
final movedTask = schedulingEngine.moveToBacklog(
task,
updatedAt: updatedAt,
);
return SchedulingResult(
tasks: List<Task>.unmodifiable(
input.tasks.map((current) {
if (current.id == task.id) {
return movedTask;
}
return current;
}),
),
notices: [
SchedulingNotice(
'Flexible task moved to backlog.',
type: SchedulingNoticeType.moved,
taskId: task.id,
),
],
changes: [
SchedulingChange(
taskId: task.id,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: null,
nextEnd: null,
),
],
);
}
}
/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}