focus-flow/lib/src/models.dart

528 lines
18 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';
/// 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 {
const Task({
required this.id,
required this.title,
required this.projectId,
required this.type,
required this.status,
required this.createdAt,
required this.updatedAt,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.scheduledStart,
this.scheduledEnd,
this.parentTaskId,
this.backlogTags = const <BacklogTag>{},
this.stats = const TaskStatistics(),
});
/// 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,
}) {
if (title.trim().isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
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;
/// 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;
/// 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.
Task copyWith({
String? id,
String? title,
String? projectId,
TaskType? type,
TaskStatus? status,
PriorityLevel? priority,
RewardLevel? reward,
DifficultyLevel? difficulty,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
String? parentTaskId,
Set<BacklogTag>? backlogTags,
DateTime? createdAt,
DateTime? updatedAt,
TaskStatistics? stats,
bool clearSchedule = false,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
projectId: projectId ?? this.projectId,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
reward: reward ?? this.reward,
difficulty: difficulty ?? this.difficulty,
durationMinutes: durationMinutes ?? this.durationMinutes,
scheduledStart:
clearSchedule ? null : (scheduledStart ?? this.scheduledStart),
scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd),
parentTaskId: parentTaskId ?? this.parentTaskId,
backlogTags: backlogTags ?? this.backlogTags,
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 {
const ProjectProfile({
required this.id,
required this.name,
required this.colorKey,
this.defaultPriority = PriorityLevel.medium,
this.defaultReward = RewardLevel.notSet,
this.defaultDifficulty = DifficultyLevel.notSet,
this.defaultReminderProfile = ReminderProfile.gentle,
this.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,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt,
}) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: trimmedTitle,
projectId: this.id,
type: type,
status: status,
priority: priority ?? defaultPriority,
reward: reward ?? defaultReward,
difficulty: difficulty ?? defaultDifficulty,
durationMinutes: durationMinutes ?? defaultDurationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
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,
}) {
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:
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 {
const TimeInterval({
required this.start,
required this.end,
this.label,
});
/// 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);
}
}