focus-flow/lib/src/quick_capture.dart

288 lines
9.4 KiB
Dart

// Low-friction capture flow.
//
// Quick capture is the "dump the thought before it disappears" path. The goal
// is to accept minimal data, preserve the user's input, and only ask for more
// structure when the user wants immediate scheduling.
import 'models.dart';
import 'scheduling_engine.dart';
import 'time_contracts.dart';
/// Outcome of a quick-capture request.
///
/// The UI can use this status to decide whether to show a passive success, draw
/// a scheduled card on the timeline, or display validation messages. It is not
/// an exception-based flow because quick capture should fail gently and keep the
/// user's typed task available.
enum QuickCaptureStatus {
/// Capture succeeded and the task remains unscheduled in backlog.
addedToBacklog,
/// Capture succeeded and the task was placed on the timeline.
scheduled,
/// Capture could not complete the requested flow; see result messages.
validationError,
}
/// Input for low-friction task capture.
///
/// This object represents what the UI knows at the moment of capture. It mirrors
/// the product goal: adding a thought should require as little structure as
/// possible, but the user can optionally provide enough detail to immediately
/// schedule it into the next open flexible slot.
class QuickCaptureRequest {
QuickCaptureRequest({
required this.id,
required this.title,
required this.createdAt,
this.addToNextAvailableSlot = false,
this.projectId = 'inbox',
this.priority = PriorityLevel.medium,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.type = TaskType.flexible,
this.durationMinutes,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
}) : backlogTags = Set<BacklogTag>.unmodifiable(backlogTags);
/// Caller-generated id. Keeping id generation outside this service makes the
/// domain layer independent from persistence/database choices.
final String id;
/// Raw user-entered title. The [Task.quickCapture] factory trims it.
final String title;
/// Capture timestamp supplied by the caller for testability.
final DateTime createdAt;
/// Whether capture should attempt immediate timeline placement.
final bool addToNextAvailableSlot;
/// Project id to assign; defaults to the inbox for uncategorized thoughts.
final String projectId;
/// Initial priority used by backlog/scheduler heuristics.
final PriorityLevel priority;
/// Initial reward estimate.
final RewardLevel reward;
/// Initial difficulty estimate.
final DifficultyLevel difficulty;
/// Captured task type. Immediate scheduling currently requires flexible tasks.
final TaskType type;
/// Optional duration estimate. Required only when scheduling immediately.
final int? durationMinutes;
/// Optional backlog flags such as wishlist/someday.
final Set<BacklogTag> backlogTags;
}
/// Result of a quick-capture request.
///
/// The result always carries a [task], even on validation failure, so the UI can
/// preserve the user's input and show what needs to be fixed. When scheduling
/// was attempted, [schedulingResult] exposes the lower-level engine notices and
/// changes for debugging or timeline updates.
class QuickCaptureResult {
QuickCaptureResult({
required this.task,
required this.status,
this.schedulingResult,
List<String> messages = const <String>[],
}) : messages = List<String>.unmodifiable(messages);
/// Captured task, scheduled or unscheduled depending on [status].
final Task task;
/// High-level outcome of the capture attempt.
final QuickCaptureStatus status;
/// Detailed scheduling output when immediate placement was attempted.
final SchedulingResult? schedulingResult;
/// Human-readable validation or scheduling messages.
final List<String> messages;
/// Convenience check for UI branches that only care whether capture succeeded.
bool get isValid => status != QuickCaptureStatus.validationError;
}
/// Coordinates quick capture defaults and optional scheduling.
///
/// This service is intentionally thin: it builds a [Task], validates the extra
/// requirements for immediate scheduling, and delegates placement to
/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand
/// every scheduler precondition.
class QuickCaptureService {
const QuickCaptureService({
this.engine = const SchedulingEngine(),
this.clock = const SystemClock(),
this.idGenerator,
});
/// Scheduling dependency. Defaults to the starter engine but can be swapped in
/// tests or future implementations.
final SchedulingEngine engine;
/// Clock boundary used by convenience capture entry points.
final Clock clock;
/// Optional ID boundary for convenience capture entry points.
final IdGenerator? idGenerator;
/// Capture a task and optionally place it into the next available slot.
///
/// Flow:
/// 1. Build the task using lightweight defaults.
/// 2. If immediate scheduling was not requested, return a backlog success.
/// 3. Validate the requirements for immediate scheduling.
/// 4. Call [SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot].
/// 5. Convert the lower-level scheduling result into a capture result.
QuickCaptureResult capture(
QuickCaptureRequest request, {
SchedulingInput? schedulingInput,
DateTime? updatedAt,
}) {
final capturedTaskWithoutDuration = Task.quickCapture(
id: request.id,
title: request.title,
createdAt: request.createdAt,
projectId: request.projectId,
type: request.type,
priority: request.priority,
reward: request.reward,
difficulty: request.difficulty,
backlogTags: request.backlogTags,
updatedAt: updatedAt,
);
if (request.durationMinutes != null && request.durationMinutes! <= 0) {
return QuickCaptureResult(
task: capturedTaskWithoutDuration,
status: QuickCaptureStatus.validationError,
messages: const ['Duration must be positive when provided.'],
);
}
final capturedTask = capturedTaskWithoutDuration.copyWith(
durationMinutes: request.durationMinutes,
);
if (!request.addToNextAvailableSlot) {
return QuickCaptureResult(
task: capturedTask,
status: QuickCaptureStatus.addedToBacklog,
);
}
if (request.durationMinutes == null || request.durationMinutes! <= 0) {
return QuickCaptureResult(
task: capturedTask,
status: QuickCaptureStatus.validationError,
messages: const ['Duration is required to schedule quick capture.'],
);
}
if (request.type != TaskType.flexible) {
return QuickCaptureResult(
task: capturedTask,
status: QuickCaptureStatus.validationError,
messages: const ['Scheduled quick capture must be flexible.'],
);
}
if (schedulingInput == null) {
return QuickCaptureResult(
task: capturedTask,
status: QuickCaptureStatus.validationError,
messages: const ['Scheduling input is required.'],
);
}
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [capturedTask, ...schedulingInput.tasks],
window: schedulingInput.window,
lockedIntervals: schedulingInput.lockedIntervals,
requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals,
),
taskId: capturedTask.id,
updatedAt: updatedAt,
);
final scheduledTask = _taskById(result.tasks, capturedTask.id);
if (scheduledTask == null || scheduledTask.isBacklog) {
return QuickCaptureResult(
task: capturedTask,
status: QuickCaptureStatus.validationError,
schedulingResult: result,
messages: result.notices.map((notice) => notice.message).toList(),
);
}
return QuickCaptureResult(
task: scheduledTask,
status: QuickCaptureStatus.scheduled,
schedulingResult: result,
);
}
/// Convenience outer-boundary wrapper that supplies id and creation time.
QuickCaptureResult captureNew({
required String title,
bool addToNextAvailableSlot = false,
String projectId = 'inbox',
PriorityLevel priority = PriorityLevel.medium,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
TaskType type = TaskType.flexible,
int? durationMinutes,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
SchedulingInput? schedulingInput,
DateTime? createdAt,
DateTime? updatedAt,
}) {
final generator = idGenerator;
if (generator == null) {
throw StateError('Quick capture needs an id generator.');
}
final now = createdAt ?? updatedAt ?? clock.now();
return capture(
QuickCaptureRequest(
id: generator.nextId(),
title: title,
createdAt: now,
addToNextAvailableSlot: addToNextAvailableSlot,
projectId: projectId,
priority: priority,
reward: reward,
difficulty: difficulty,
type: type,
durationMinutes: durationMinutes,
backlogTags: backlogTags,
),
schedulingInput: schedulingInput,
updatedAt: updatedAt ?? now,
);
}
}
/// Find a task in a returned scheduling result.
///
/// This duplicates a small helper rather than exposing scheduler internals. The
/// capture service only needs to retrieve the newly created task after placement.
Task? _taskById(List<Task> tasks, String id) {
for (final task in tasks) {
if (task.id == id) {
return task;
}
}
return null;
}