288 lines
9.4 KiB
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;
|
|
}
|