focus-flow/lib/src/models.dart

764 lines
24 KiB
Dart

// Core domain model for the ADHD scheduling starter project.
//
// This file intentionally contains small immutable value objects and enums. It
// should stay free of UI, persistence, notification, and platform code. Keeping
// this layer plain makes the scheduler easy to test and easy to move between a
// command-line prototype, Flutter UI, or future backend service.
//
// Reading order for humans:
// 1. `TaskType` and `TaskStatus` explain the two main axes of a task.
// 2. `Task` shows the actual data carried through the scheduling engine.
// 3. `ProjectProfile` explains how project defaults create tasks.
// 4. `TimeInterval` is the shared time-span helper used by scheduling logic.
import 'task_statistics.dart';
/// Stable validation failure categories for domain model boundaries.
///
/// Callers should branch on these codes instead of parsing exception text. The
/// explanatory messages may change, but these enum values are part of the V1
/// backend contract.
enum DomainValidationCode {
blankStableId,
blankTitle,
blankProjectId,
blankName,
blankColorKey,
blankParentTaskId,
selfParent,
nonPositiveDuration,
partialScheduledInterval,
invalidScheduledInterval,
partialActualInterval,
invalidActualInterval,
invalidTimeInterval,
invalidSchedulingWindow,
invalidClockTime,
emptyRecurrence,
invalidLockedBlock,
invalidLockedOverride,
}
/// Typed validation error thrown by domain model constructors.
class DomainValidationException extends ArgumentError {
DomainValidationException({
required this.code,
required Object? invalidValue,
required String name,
required String message,
}) : super.value(invalidValue, name, message);
/// Stable category for application and persistence layers.
final DomainValidationCode code;
}
/// Scheduling behavior category.
///
/// This enum is one of the central concepts in the planner. The type answers
/// the question: "how should the scheduler treat this item?" It is separate
/// from [TaskStatus], which answers "where is this item in its lifecycle?"
///
/// For example, a flexible task can be planned, completed, missed, or moved to
/// backlog. A locked item, by contrast, acts more like a calendar constraint
/// than a normal task card. Keeping behavior and lifecycle separate makes later
/// UI and persistence logic easier to reason about.
enum TaskType {
/// Movable planned work.
flexible,
/// Required visible block that should not be moved automatically.
inflexible,
/// Required visible task that remains actionable if missed.
critical,
/// Hidden scheduling constraint, not a task card.
locked,
/// Unplanned completed/logged task.
surprise,
/// Intentional rest time.
freeSlot,
}
/// Current lifecycle state of a task.
///
/// Status is intentionally data-oriented: it describes what happened to a task,
/// not how important it is or how the scheduler should move it. Scheduler rules
/// combine [TaskStatus] with [TaskType]. For instance, a planned flexible task
/// can be pushed, while a planned critical task should remain visible and become
/// backlog if missed.
enum TaskStatus {
/// Scheduled or queued work that has not started.
planned,
/// Work currently in progress.
active,
/// Work finished by the user.
completed,
/// Work that was not completed in its intended time.
missed,
/// Work intentionally removed from the plan.
cancelled,
/// Work intentionally dismissed because it no longer applies.
noLongerRelevant,
/// Unscheduled work kept for later planning.
backlog,
}
/// User-facing importance level.
///
/// Priority is a relative ordering hint. It should help decide what rises to the
/// top of a queue, but it should not be treated as an absolute promise that the
/// task must happen at a specific time. The scheduling engine currently ranks
/// this with simple numeric helpers in `backlog.dart`; more advanced heuristics
/// can build on the same enum later.
enum PriorityLevel {
/// Lowest priority.
veryLow,
/// Low priority.
low,
/// Default middle priority.
medium,
/// High priority.
high,
/// Highest priority.
veryHigh,
}
/// Expected reward or payoff from completing a task.
///
/// Reward is meant to capture motivational payoff, not objective value. In this
/// app design it supports ADHD-friendly planning: a small, easy, high-reward task
/// may be a better momentum starter than a large low-reward task.
enum RewardLevel {
/// No reward level has been captured; this is not equivalent to low reward.
notSet,
/// Very low expected reward.
veryLow,
/// Low expected reward.
low,
/// Medium expected reward.
medium,
/// High expected reward.
high,
/// Very high expected reward.
veryHigh,
}
/// Expected effort or activation difficulty for a task.
///
/// Difficulty is not the same as duration. A five-minute phone call might be
/// very hard to start, while an hour of familiar maintenance may be easy. The
/// backlog view uses this with [RewardLevel] to expose a simple
/// reward-versus-effort sort.
enum DifficultyLevel {
/// No difficulty has been captured yet.
notSet,
/// Very easy to start or complete.
veryEasy,
/// Easy to start or complete.
easy,
/// Medium effort.
medium,
/// Hard to start or complete.
hard,
/// Very hard to start or complete.
veryHard,
}
/// Reminder intensity preference.
///
/// This is currently stored as project metadata rather than enforced by the core
/// scheduler. Future notification/UI layers can use it to decide how aggressive
/// reminders should be without adding reminder-specific logic to [Task].
enum ReminderProfile {
/// No reminder nudges.
silent,
/// Low-friction reminder nudges.
gentle,
/// Repeated reminder nudges.
persistent,
/// Strong reminder behavior for required items.
strict,
}
/// Lightweight backlog-only metadata.
///
/// Tags here are deliberately narrow. They are not meant to replace a general
/// tagging system. They identify special backlog behavior that the planner needs
/// to understand, such as "wishlist/someday" items.
enum BacklogTag {
/// Task is intentionally saved as a someday/wishlist item.
wishlist,
}
/// Starter task model for the scheduling core.
///
/// [Task] is the main domain object passed through the scheduler. It is written
/// as an immutable value object: operations do not mutate an existing task, they
/// return a copied task with changed fields. That approach makes scheduling
/// actions easier to test because every function receives an input list and
/// returns a new output list.
///
/// Important modeling choices:
/// - [type] controls scheduling behavior: flexible, critical, locked, etc.
/// - [status] controls lifecycle state: planned, completed, backlog, etc.
/// - [scheduledStart] and [scheduledEnd] are optional because backlog items and
/// unscheduled captures do not have timeline placement yet.
/// - [stats] records quiet metadata for future reports. It should not clutter
/// the everyday UI unless a report or filter specifically needs it.
/// - [parentTaskId] links child tasks to a larger parent task without requiring
/// a nested object graph. That keeps persistence simple and avoids recursive
/// scheduling structures.
///
/// This is still a starter V1 model, so behavior changes should be explicit and
/// backed by tests as the product rules settle.
class Task {
Task({
required String id,
required String title,
required String projectId,
required this.type,
required this.status,
required this.createdAt,
required this.updatedAt,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
int? durationMinutes,
this.scheduledStart,
this.scheduledEnd,
this.actualStart,
this.actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
this.reminderOverride,
this.stats = const TaskStatistics(),
}) : id = _requiredTrimmed(
id,
'id',
DomainValidationCode.blankStableId,
'Stable id is required.',
),
title = _requiredTrimmed(
title,
'title',
DomainValidationCode.blankTitle,
'Title is required.',
),
projectId = _requiredTrimmed(
projectId,
'projectId',
DomainValidationCode.blankProjectId,
'Project id is required.',
),
durationMinutes = durationMinutes,
parentTaskId = _nullableTrimmed(
parentTaskId,
'parentTaskId',
DomainValidationCode.blankParentTaskId,
'Parent task id cannot be blank.',
),
backlogTags = Set<BacklogTag>.unmodifiable(backlogTags) {
_validatePositiveDuration(durationMinutes, 'durationMinutes');
_validateOptionalInterval(
start: scheduledStart,
end: scheduledEnd,
partialCode: DomainValidationCode.partialScheduledInterval,
invalidCode: DomainValidationCode.invalidScheduledInterval,
name: 'scheduledStart/scheduledEnd',
partialMessage:
'Scheduled start and scheduled end must both be present or absent.',
invalidMessage: 'Scheduled end must be after scheduled start.',
);
_validateOptionalInterval(
start: actualStart,
end: actualEnd,
partialCode: DomainValidationCode.partialActualInterval,
invalidCode: DomainValidationCode.invalidActualInterval,
name: 'actualStart/actualEnd',
partialMessage:
'Actual start and actual end must both be present or absent.',
invalidMessage: 'Actual end must be after actual start.',
);
if (this.parentTaskId == this.id) {
throw DomainValidationException(
code: DomainValidationCode.selfParent,
invalidValue: parentTaskId,
name: 'parentTaskId',
message: 'A task cannot be its own parent.',
);
}
}
/// Create a minimal captured task without requiring planning details.
///
/// Quick capture is intentionally forgiving: the user can enter only a title
/// and the system can still create a valid backlog item. Defaults route the
/// item into the inbox project as a medium-priority flexible backlog task.
///
/// The only hard validation here is that [title] must contain non-whitespace
/// text. Scheduling validation, such as requiring a positive duration, happens
/// in `quick_capture.dart` because that depends on the requested capture flow.
factory Task.quickCapture({
required String id,
required String title,
required DateTime createdAt,
String projectId = 'inbox',
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.backlog,
PriorityLevel priority = PriorityLevel.medium,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt,
}) {
return Task(
id: id,
title: title.trim(),
projectId: projectId,
type: type,
status: status,
priority: priority,
reward: reward,
difficulty: difficulty,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
backlogTags: backlogTags,
);
}
/// Stable identifier used by persistence, UI selection, and scheduler changes.
final String id;
/// User-facing task title. The model expects this to already be trimmed.
final String title;
/// Owning project/profile id. `inbox` is used for uncategorized captures.
final String projectId;
/// Scheduling behavior category. See [TaskType] for rule-level meaning.
final TaskType type;
/// Current lifecycle state. See [TaskStatus] for state-level meaning.
final TaskStatus status;
/// Optional importance. Most creation paths default this to medium, but it is
/// nullable to leave room for imports or legacy data that have not set it yet.
final PriorityLevel? priority;
/// Motivational payoff used by backlog sorting and future planning hints.
final RewardLevel reward;
/// Activation/effort estimate used by backlog sorting and future planning hints.
final DifficultyLevel difficulty;
/// Estimated task length. Required for most scheduling operations, optional for
/// backlog capture because not every captured thought has an estimate yet.
final int? durationMinutes;
/// Inclusive scheduled start time. Null means the task is not currently placed.
final DateTime? scheduledStart;
/// Exclusive scheduled end time. Null means the task is not currently placed.
final DateTime? scheduledEnd;
/// Actual work start time, when known after the fact.
final DateTime? actualStart;
/// Actual work end time, when known after the fact.
final DateTime? actualEnd;
/// Parent task id when this task is a child/subtask. Null means top-level task.
final String? parentTaskId;
/// Backlog-specific flags, such as wishlist/someday behavior.
final Set<BacklogTag> backlogTags;
/// Optional task-level reminder override.
final ReminderProfile? reminderOverride;
/// Creation timestamp used for age/staleness sorting.
final DateTime createdAt;
/// Last domain-level update timestamp. Scheduling actions set this when moving
/// or changing tasks so persistence and reports can detect recent activity.
final DateTime updatedAt;
/// Quiet counters for reporting and later heuristics.
final TaskStatistics stats;
/// Convenience predicate for the task type most scheduler movement operates on.
bool get isFlexible => type == TaskType.flexible;
/// Critical and inflexible tasks are both visible to the user and treated as
/// blocked time by flexible scheduling.
bool get isRequiredVisible =>
type == TaskType.critical || type == TaskType.inflexible;
/// Locked tasks behave as timeline constraints, not normal interactive cards.
bool get isLocked => type == TaskType.locked;
/// Backlog status means the task is stored for later and has no active slot.
bool get isBacklog => status == TaskStatus.backlog;
/// Return a copy with selected fields changed.
///
/// The core uses this instead of mutation. That matters because scheduling
/// operations often need to produce an auditable before/after result, including
/// [SchedulingChange]-style records elsewhere.
///
/// [clearSchedule] is a deliberate escape hatch for nullable schedule fields.
/// Without it, passing null would be ambiguous: it could mean "do not change"
/// or "clear this value." When [clearSchedule] is true, both schedule fields
/// are removed even if [scheduledStart] or [scheduledEnd] are omitted.
///
/// The other `clear*` flags provide the same explicit semantics for nullable
/// fields that the product can remove.
Task copyWith({
String? id,
String? title,
String? projectId,
TaskType? type,
TaskStatus? status,
PriorityLevel? priority,
RewardLevel? reward,
DifficultyLevel? difficulty,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId,
Set<BacklogTag>? backlogTags,
ReminderProfile? reminderOverride,
DateTime? createdAt,
DateTime? updatedAt,
TaskStatistics? stats,
bool clearPriority = false,
bool clearDuration = false,
bool clearSchedule = false,
bool clearActualInterval = false,
bool clearParentTask = false,
bool clearReminderOverride = false,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
projectId: projectId ?? this.projectId,
type: type ?? this.type,
status: status ?? this.status,
priority: clearPriority ? null : (priority ?? this.priority),
reward: reward ?? this.reward,
difficulty: difficulty ?? this.difficulty,
durationMinutes:
clearDuration ? null : (durationMinutes ?? this.durationMinutes),
scheduledStart:
clearSchedule ? null : (scheduledStart ?? this.scheduledStart),
scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd),
actualStart:
clearActualInterval ? null : (actualStart ?? this.actualStart),
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
parentTaskId:
clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
backlogTags: backlogTags ?? this.backlogTags,
reminderOverride: clearReminderOverride
? null
: (reminderOverride ?? this.reminderOverride),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
stats: stats ?? this.stats,
);
}
}
/// Starter project defaults used when creating or scheduling tasks.
///
/// A project profile represents reusable defaults for a group of tasks. UI code
/// can let the user pick a project, then call [createTask] so new tasks inherit
/// a color, default priority, reward, difficulty, reminder profile, and duration.
///
/// The scheduler itself mostly cares about the resulting [Task] fields. Keeping
/// project defaults separate prevents every scheduling function from needing to
/// know project configuration details.
class ProjectProfile {
ProjectProfile({
required String id,
required String name,
required String colorKey,
this.defaultPriority = PriorityLevel.medium,
this.defaultReward = RewardLevel.notSet,
this.defaultDifficulty = DifficultyLevel.notSet,
this.defaultReminderProfile = ReminderProfile.gentle,
int? defaultDurationMinutes,
}) : id = _requiredTrimmed(
id,
'id',
DomainValidationCode.blankStableId,
'Stable id is required.',
),
name = _requiredTrimmed(
name,
'name',
DomainValidationCode.blankName,
'Project name is required.',
),
colorKey = _requiredTrimmed(
colorKey,
'colorKey',
DomainValidationCode.blankColorKey,
'Project color key is required.',
),
defaultDurationMinutes = defaultDurationMinutes {
_validatePositiveDuration(
defaultDurationMinutes,
'defaultDurationMinutes',
);
}
/// Stable project id stored on tasks.
final String id;
/// User-facing project name.
final String name;
/// Theme/color token for UI rendering. This is a key, not a raw color value.
final String colorKey;
/// Default importance assigned when a task does not override priority.
final PriorityLevel defaultPriority;
/// Default motivational payoff assigned when a task does not override reward.
final RewardLevel defaultReward;
/// Default activation difficulty assigned when a task does not override effort.
final DifficultyLevel defaultDifficulty;
/// Default reminder behavior for future notification/UI layers.
final ReminderProfile defaultReminderProfile;
/// Optional duration estimate used for newly created tasks.
final int? defaultDurationMinutes;
/// Create a task using project defaults while allowing explicit overrides.
///
/// This keeps capture and project-default behavior in one place. Callers can
/// pass only the fields the user explicitly set; everything else falls back to
/// this profile. The created task receives this profile's [id] as [Task.projectId].
Task createTask({
required String id,
required String title,
required DateTime createdAt,
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.backlog,
PriorityLevel? priority,
RewardLevel? reward,
DifficultyLevel? difficulty,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
ReminderProfile? reminderOverride,
DateTime? updatedAt,
}) {
return Task(
id: id,
title: title,
projectId: this.id,
type: type,
status: status,
priority: priority ?? defaultPriority,
reward: reward ?? defaultReward,
difficulty: difficulty ?? defaultDifficulty,
durationMinutes: durationMinutes ?? defaultDurationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
actualStart: actualStart,
actualEnd: actualEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
reminderOverride: reminderOverride,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
/// Return a copy with selected project defaults changed.
ProjectProfile copyWith({
String? id,
String? name,
String? colorKey,
PriorityLevel? defaultPriority,
RewardLevel? defaultReward,
DifficultyLevel? defaultDifficulty,
ReminderProfile? defaultReminderProfile,
int? defaultDurationMinutes,
bool clearDefaultDuration = false,
}) {
return ProjectProfile(
id: id ?? this.id,
name: name ?? this.name,
colorKey: colorKey ?? this.colorKey,
defaultPriority: defaultPriority ?? this.defaultPriority,
defaultReward: defaultReward ?? this.defaultReward,
defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty,
defaultReminderProfile:
defaultReminderProfile ?? this.defaultReminderProfile,
defaultDurationMinutes: clearDefaultDuration
? null
: (defaultDurationMinutes ?? this.defaultDurationMinutes),
);
}
}
/// Starter time range value used by scheduling helpers.
///
/// [TimeInterval] is the scheduler's neutral representation of a time span. It
/// is used for scheduled task slots, locked blocks, required visible blocks, and
/// candidate placements. The interval convention is start-inclusive and
/// end-exclusive, which avoids treating two back-to-back blocks as overlapping.
class TimeInterval {
TimeInterval({
required this.start,
required this.end,
this.label,
}) {
if (!start.isBefore(end)) {
throw DomainValidationException(
code: DomainValidationCode.invalidTimeInterval,
invalidValue: {'start': start, 'end': end},
name: 'TimeInterval',
message: 'Interval end must be after interval start.',
);
}
}
/// Inclusive beginning of the interval.
final DateTime start;
/// Exclusive ending of the interval.
final DateTime end;
/// Optional debug/UI label, often a task id or locked block name.
final String? label;
/// Raw duration between [start] and [end]. Callers are responsible for only
/// constructing meaningful positive intervals when required by a rule.
Duration get duration => end.difference(start);
/// Whether this interval shares any actual time with [other].
///
/// Adjacent intervals do not overlap: `9:00-10:00` and `10:00-11:00` are safe
/// to place back-to-back because the first interval's end is the second
/// interval's start.
bool overlaps(TimeInterval other) {
return start.isBefore(other.end) && end.isAfter(other.start);
}
}
String _requiredTrimmed(
String value,
String name,
DomainValidationCode code,
String message,
) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: message,
);
}
return trimmed;
}
String? _nullableTrimmed(
String? value,
String name,
DomainValidationCode code,
String message,
) {
if (value == null) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: message,
);
}
return trimmed;
}
void _validatePositiveDuration(int? durationMinutes, String name) {
if (durationMinutes == null) {
return;
}
if (durationMinutes <= 0) {
throw DomainValidationException(
code: DomainValidationCode.nonPositiveDuration,
invalidValue: durationMinutes,
name: name,
message: 'Duration must be positive when present.',
);
}
}
void _validateOptionalInterval({
required DateTime? start,
required DateTime? end,
required DomainValidationCode partialCode,
required DomainValidationCode invalidCode,
required String name,
required String partialMessage,
required String invalidMessage,
}) {
if ((start == null) != (end == null)) {
throw DomainValidationException(
code: partialCode,
invalidValue: {'start': start, 'end': end},
name: name,
message: partialMessage,
);
}
if (start != null && end != null && !start.isBefore(end)) {
throw DomainValidationException(
code: invalidCode,
invalidValue: {'start': start, 'end': end},
name: name,
message: invalidMessage,
);
}
}