forked from eva/focus-flow
752 lines
23 KiB
Dart
752 lines
23 KiB
Dart
// Parent/child task ownership helpers.
|
|
//
|
|
// Child tasks are owned by one parent through `Task.parentTaskId`. This file
|
|
// keeps that relationship queryable without adding dependency scheduling, DAG
|
|
// traversal, or UI-specific state.
|
|
|
|
import 'models.dart';
|
|
import 'task_lifecycle.dart';
|
|
import 'time_contracts.dart';
|
|
|
|
/// Row-style input for creating one child task.
|
|
///
|
|
/// Priority is nullable on purpose. A null priority means the user did not set
|
|
/// one, so callers should preserve row insertion order instead of treating the
|
|
/// child as medium priority.
|
|
class ChildTaskEntry {
|
|
const ChildTaskEntry({
|
|
required this.id,
|
|
required this.title,
|
|
required this.createdAt,
|
|
this.priority,
|
|
this.reward = RewardLevel.notSet,
|
|
this.difficulty = DifficultyLevel.notSet,
|
|
this.durationMinutes,
|
|
this.projectId,
|
|
this.updatedAt,
|
|
});
|
|
|
|
/// Caller-generated child id.
|
|
final String id;
|
|
|
|
/// User-entered child title.
|
|
final String title;
|
|
|
|
/// Creation timestamp supplied by the caller.
|
|
final DateTime createdAt;
|
|
|
|
/// Optional explicit child priority.
|
|
final PriorityLevel? priority;
|
|
|
|
/// Optional explicit child reward. Missing reward stays `notSet`.
|
|
final RewardLevel reward;
|
|
|
|
/// Optional explicit child difficulty.
|
|
final DifficultyLevel difficulty;
|
|
|
|
/// Optional child duration estimate.
|
|
final int? durationMinutes;
|
|
|
|
/// Optional project override. Null means inherit the parent project.
|
|
final String? projectId;
|
|
|
|
/// Optional update timestamp.
|
|
final DateTime? updatedAt;
|
|
|
|
/// Convert this entry into a child [Task] owned by [parent].
|
|
Task toTask({
|
|
required Task parent,
|
|
}) {
|
|
final trimmedTitle = title.trim();
|
|
if (trimmedTitle.isEmpty) {
|
|
throw ArgumentError.value(title, 'title', 'Title is required.');
|
|
}
|
|
|
|
return Task(
|
|
id: id,
|
|
title: trimmedTitle,
|
|
projectId: projectId ?? parent.projectId,
|
|
type: TaskType.flexible,
|
|
status: TaskStatus.backlog,
|
|
priority: priority,
|
|
reward: reward,
|
|
difficulty: difficulty,
|
|
durationMinutes: durationMinutes,
|
|
parentTaskId: parent.id,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt ?? createdAt,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Command input for breaking one parent task into direct children.
|
|
class ChildTaskBreakUpRequest {
|
|
ChildTaskBreakUpRequest({
|
|
required String parentTaskId,
|
|
required List<ChildTaskEntry> entries,
|
|
required String operationId,
|
|
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
|
|
entries = List<ChildTaskEntry>.unmodifiable(entries),
|
|
operationId = _requiredTrimmed(operationId, 'operationId');
|
|
|
|
/// Parent task that will own every created child.
|
|
final String parentTaskId;
|
|
|
|
/// Ordered child rows supplied by the caller.
|
|
final List<ChildTaskEntry> entries;
|
|
|
|
/// Idempotency key for the application command that creates the children.
|
|
final String operationId;
|
|
}
|
|
|
|
/// Result from breaking a parent task into direct children.
|
|
class ChildTaskBreakUpResult {
|
|
ChildTaskBreakUpResult({
|
|
required List<Task> tasks,
|
|
required this.parentTask,
|
|
required List<Task> childTasks,
|
|
required this.operationId,
|
|
List<String> createdChildTaskIds = const <String>[],
|
|
List<TaskActivity> activities = const <TaskActivity>[],
|
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
|
childTasks = List<Task>.unmodifiable(childTasks),
|
|
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
|
|
activities = List<TaskActivity>.unmodifiable(activities);
|
|
|
|
/// Replacement task list including newly created children.
|
|
final List<Task> tasks;
|
|
|
|
/// Parent that owns the created children.
|
|
final Task parentTask;
|
|
|
|
/// Created children in request order.
|
|
final List<Task> childTasks;
|
|
|
|
/// Idempotency key for the command.
|
|
final String operationId;
|
|
|
|
/// Child ids created by this command.
|
|
final List<String> createdChildTaskIds;
|
|
|
|
/// Activity records produced by this command.
|
|
///
|
|
/// V1 child creation is represented by task inserts, so this is currently
|
|
/// empty while still giving application use cases one atomic mutation shape.
|
|
final List<TaskActivity> activities;
|
|
}
|
|
|
|
/// Creates direct child tasks from ordered child-entry rows.
|
|
class ChildTaskBreakUpService {
|
|
const ChildTaskBreakUpService();
|
|
|
|
/// Break [request.parentTaskId] into direct child tasks.
|
|
///
|
|
/// This service validates the full requested set before returning any
|
|
/// mutations. Persistence and transaction wiring are handled by later blocks.
|
|
ChildTaskBreakUpResult breakUp({
|
|
required List<Task> tasks,
|
|
required ChildTaskBreakUpRequest request,
|
|
}) {
|
|
final parent = _taskById(tasks, request.parentTaskId);
|
|
if (parent == null) {
|
|
throw ArgumentError.value(
|
|
request.parentTaskId,
|
|
'parentTaskId',
|
|
'Parent not found.',
|
|
);
|
|
}
|
|
if (request.entries.isEmpty) {
|
|
throw ArgumentError.value(
|
|
request.entries,
|
|
'entries',
|
|
'At least one child task is required.',
|
|
);
|
|
}
|
|
|
|
final existingIds = tasks.map((task) => task.id).toSet();
|
|
final requestedIds = <String>{};
|
|
for (final entry in request.entries) {
|
|
final childId = entry.id.trim();
|
|
if (childId.isEmpty) {
|
|
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
|
|
}
|
|
if (childId == parent.id) {
|
|
throw ArgumentError.value(
|
|
entry.id,
|
|
'id',
|
|
'Child id cannot match the parent id.',
|
|
);
|
|
}
|
|
if (!requestedIds.add(childId)) {
|
|
throw ArgumentError.value(
|
|
entry.id,
|
|
'id',
|
|
'Child ids must be unique within one break-up command.',
|
|
);
|
|
}
|
|
if (existingIds.contains(childId)) {
|
|
throw ArgumentError.value(
|
|
entry.id,
|
|
'id',
|
|
'Child id is already used by another task.',
|
|
);
|
|
}
|
|
}
|
|
|
|
final childTasks = request.entries
|
|
.map((entry) => entry.toTask(parent: parent))
|
|
.toList(growable: false);
|
|
|
|
return ChildTaskBreakUpResult(
|
|
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
|
|
parentTask: parent,
|
|
childTasks: childTasks,
|
|
operationId: request.operationId,
|
|
createdChildTaskIds:
|
|
childTasks.map((child) => child.id).toList(growable: false),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Read-only parent/child projection over a task list.
|
|
///
|
|
/// This is intentionally not a scheduler. It answers ownership questions such
|
|
/// as "which tasks belong to this parent?" while leaving task placement and
|
|
/// completion rules to other domain services.
|
|
class ChildTaskView {
|
|
ChildTaskView({
|
|
required List<Task> tasks,
|
|
}) : tasks = List<Task>.unmodifiable(tasks);
|
|
|
|
/// Source task list supplied by the caller.
|
|
final List<Task> tasks;
|
|
|
|
/// Direct children owned by [parent], preserving source-list order.
|
|
List<Task> childrenOf(Task parent) {
|
|
return List<Task>.unmodifiable(
|
|
tasks.where((task) => task.parentTaskId == parent.id),
|
|
);
|
|
}
|
|
|
|
/// Direct children sorted by explicit priority while preserving row order ties.
|
|
///
|
|
/// Children without priority sort after explicit priorities. Within each
|
|
/// priority group, including the no-priority group, source-list order is kept.
|
|
List<Task> childrenOfSortedByPriority(Task parent) {
|
|
final indexedChildren = childrenOf(parent)
|
|
.asMap()
|
|
.entries
|
|
.map(
|
|
(entry) => _IndexedChild(
|
|
task: entry.value,
|
|
originalIndex: entry.key,
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
|
|
indexedChildren.sort((a, b) {
|
|
final priorityComparison = _priorityRank(b.task.priority)
|
|
.compareTo(_priorityRank(a.task.priority));
|
|
if (priorityComparison != 0) {
|
|
return priorityComparison;
|
|
}
|
|
|
|
return a.originalIndex.compareTo(b.originalIndex);
|
|
});
|
|
|
|
return List<Task>.unmodifiable(
|
|
indexedChildren.map((child) => child.task),
|
|
);
|
|
}
|
|
|
|
/// Parent task for [child], or null when the parent is not in [tasks].
|
|
Task? parentOf(Task child) {
|
|
final parentId = child.parentTaskId;
|
|
if (parentId == null) {
|
|
return null;
|
|
}
|
|
|
|
for (final task in tasks) {
|
|
if (task.id == parentId) {
|
|
return task;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Whether [child] is directly owned by [parent].
|
|
bool isChildOf({
|
|
required Task child,
|
|
required Task parent,
|
|
}) {
|
|
return child.parentTaskId == parent.id;
|
|
}
|
|
|
|
/// Aggregate direct child status counts for [parent].
|
|
ChildTaskSummary summaryFor(Task parent) {
|
|
return ChildTaskSummary.fromChildren(childrenOf(parent));
|
|
}
|
|
|
|
/// Whether this helper would auto-complete [parent].
|
|
bool parentShouldAutoComplete(Task parent) {
|
|
return summaryFor(parent).allChildrenCompleted;
|
|
}
|
|
}
|
|
|
|
/// Result from child/parent completion propagation.
|
|
class ChildTaskCompletionResult {
|
|
ChildTaskCompletionResult({
|
|
required List<Task> tasks,
|
|
required List<String> changedTaskIds,
|
|
this.completedChildId,
|
|
this.completedParentId,
|
|
List<String> forceCompletedChildIds = const <String>[],
|
|
List<TaskActivity> activities = const <TaskActivity>[],
|
|
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
|
|
const <TaskTransitionOutcomeCode>[],
|
|
this.autoCompletedParent = false,
|
|
this.canCompleteParentExplicitly = false,
|
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
|
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
|
|
forceCompletedChildIds =
|
|
List<String>.unmodifiable(forceCompletedChildIds),
|
|
activities = List<TaskActivity>.unmodifiable(activities),
|
|
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
|
|
transitionOutcomeCodes);
|
|
|
|
/// Replacement task list after the completion action.
|
|
final List<Task> tasks;
|
|
|
|
/// Task ids whose completion state changed.
|
|
final List<String> changedTaskIds;
|
|
|
|
/// Child id completed by the initial child-complete action, when applicable.
|
|
final String? completedChildId;
|
|
|
|
/// Parent id completed by the action, when applicable.
|
|
final String? completedParentId;
|
|
|
|
/// Direct child ids completed by explicit parent-complete behavior.
|
|
final List<String> forceCompletedChildIds;
|
|
|
|
/// Internal activities produced by parent/child completion propagation.
|
|
final List<TaskActivity> activities;
|
|
|
|
/// Transition outcomes returned while applying completion propagation.
|
|
final List<TaskTransitionOutcomeCode> transitionOutcomeCodes;
|
|
|
|
/// Whether the parent was completed because all direct children are complete.
|
|
final bool autoCompletedParent;
|
|
|
|
/// Whether callers may offer an explicit parent-complete action.
|
|
final bool canCompleteParentExplicitly;
|
|
}
|
|
|
|
/// Applies direct parent/child completion rules.
|
|
///
|
|
/// This service intentionally handles only one ownership level. It does not walk
|
|
/// dependency graphs, and it only completes sibling children when the caller
|
|
/// explicitly completes the parent.
|
|
class ChildTaskCompletionService {
|
|
const ChildTaskCompletionService({
|
|
this.clock = const SystemClock(),
|
|
this.transitionService = const TaskTransitionService(),
|
|
});
|
|
|
|
/// Clock boundary used when a completion timestamp is not supplied.
|
|
final Clock clock;
|
|
|
|
/// Canonical lifecycle transition dependency.
|
|
final TaskTransitionService transitionService;
|
|
|
|
/// Complete one child and auto-complete its parent only when all children are complete.
|
|
ChildTaskCompletionResult completeChild({
|
|
required List<Task> tasks,
|
|
required String childTaskId,
|
|
DateTime? updatedAt,
|
|
String? operationId,
|
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
|
Iterable<String> appliedActivityIds = const <String>[],
|
|
}) {
|
|
final now = updatedAt ?? clock.now();
|
|
final child = _taskById(tasks, childTaskId);
|
|
if (child == null) {
|
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
|
}
|
|
|
|
final parentId = child.parentTaskId;
|
|
if (parentId == null) {
|
|
throw ArgumentError.value(
|
|
childTaskId, 'childTaskId', 'Task is not a child.');
|
|
}
|
|
|
|
final parent = _taskById(tasks, parentId);
|
|
if (parent == null) {
|
|
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
|
|
}
|
|
|
|
final opId =
|
|
operationId ?? _defaultOperationId('complete-child', child.id, now);
|
|
final childTransition = _completeWithTransition(
|
|
task: child,
|
|
operationId: opId,
|
|
occurredAt: now,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
metadata: {
|
|
'completionSource': 'child',
|
|
'parentTaskId': parent.id,
|
|
},
|
|
);
|
|
final withChildCompleted = _replaceTask(tasks, childTransition.task);
|
|
final view = ChildTaskView(tasks: withChildCompleted);
|
|
final shouldCompleteParent = parent.status != TaskStatus.completed &&
|
|
view.parentShouldAutoComplete(parent);
|
|
final activities = <TaskActivity>[...childTransition.activities];
|
|
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
|
|
|
|
if (!shouldCompleteParent) {
|
|
return ChildTaskCompletionResult(
|
|
tasks: List<Task>.unmodifiable(withChildCompleted),
|
|
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
|
|
completedChildId: child.id,
|
|
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
|
|
activities: activities,
|
|
transitionOutcomeCodes: outcomes,
|
|
);
|
|
}
|
|
|
|
final parentTransition = _completeWithTransition(
|
|
task: parent,
|
|
operationId: opId,
|
|
occurredAt: now,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
metadata: {
|
|
'completionSource': 'autoParentAfterLastChild',
|
|
'completedChildTaskId': child.id,
|
|
},
|
|
);
|
|
activities.addAll(parentTransition.activities);
|
|
outcomes.add(parentTransition.outcomeCode);
|
|
|
|
final updatedParent = parentTransition.task;
|
|
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
|
|
final changedTaskIds = <String>[
|
|
if (childTransition.applied) child.id,
|
|
if (parentTransition.applied) parent.id,
|
|
];
|
|
|
|
return ChildTaskCompletionResult(
|
|
tasks: List<Task>.unmodifiable(completedTasks),
|
|
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
|
completedChildId: child.id,
|
|
completedParentId: parent.id,
|
|
autoCompletedParent: true,
|
|
activities: activities,
|
|
transitionOutcomeCodes: outcomes,
|
|
);
|
|
}
|
|
|
|
/// Complete a parent from one of its direct children and force-complete siblings.
|
|
ChildTaskCompletionResult completeParentFromChild({
|
|
required List<Task> tasks,
|
|
required String childTaskId,
|
|
DateTime? updatedAt,
|
|
String? operationId,
|
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
|
Iterable<String> appliedActivityIds = const <String>[],
|
|
}) {
|
|
final child = _taskById(tasks, childTaskId);
|
|
if (child == null) {
|
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
|
}
|
|
final parentId = child.parentTaskId;
|
|
if (parentId == null) {
|
|
throw ArgumentError.value(
|
|
childTaskId,
|
|
'childTaskId',
|
|
'Task is not a child.',
|
|
);
|
|
}
|
|
|
|
return completeParent(
|
|
tasks: tasks,
|
|
parentTaskId: parentId,
|
|
updatedAt: updatedAt,
|
|
operationId: operationId,
|
|
initiatingChildTaskId: child.id,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
);
|
|
}
|
|
|
|
/// Explicitly complete a parent and any direct children not already completed.
|
|
ChildTaskCompletionResult completeParent({
|
|
required List<Task> tasks,
|
|
required String parentTaskId,
|
|
DateTime? updatedAt,
|
|
String? operationId,
|
|
String? initiatingChildTaskId,
|
|
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
|
Iterable<String> appliedActivityIds = const <String>[],
|
|
}) {
|
|
final now = updatedAt ?? clock.now();
|
|
final parent = _taskById(tasks, parentTaskId);
|
|
if (parent == null) {
|
|
throw ArgumentError.value(
|
|
parentTaskId, 'parentTaskId', 'Parent not found.');
|
|
}
|
|
|
|
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
|
|
if (initiatingChildTaskId != null &&
|
|
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
|
|
throw ArgumentError.value(
|
|
initiatingChildTaskId,
|
|
'initiatingChildTaskId',
|
|
'Initiating task must be a direct child of the parent.',
|
|
);
|
|
}
|
|
|
|
final opId =
|
|
operationId ?? _defaultOperationId('complete-parent', parent.id, now);
|
|
final forceCompletedChildIds = <String>[];
|
|
final changedTaskIds = <String>[];
|
|
final activities = <TaskActivity>[];
|
|
final outcomes = <TaskTransitionOutcomeCode>[];
|
|
final updatedTasks = <Task>[];
|
|
|
|
for (final task in tasks) {
|
|
if (task.id == parent.id) {
|
|
final transition = _completeWithTransition(
|
|
task: task,
|
|
operationId: opId,
|
|
occurredAt: now,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
metadata: {
|
|
'completionSource':
|
|
initiatingChildTaskId == null ? 'parent' : 'parentFromChild',
|
|
if (initiatingChildTaskId != null)
|
|
'initiatingChildTaskId': initiatingChildTaskId,
|
|
},
|
|
);
|
|
final completedParent = transition.task;
|
|
updatedTasks.add(completedParent);
|
|
activities.addAll(transition.activities);
|
|
outcomes.add(transition.outcomeCode);
|
|
if (transition.applied) {
|
|
changedTaskIds.add(task.id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
final isDirectChild = directChildren.any((child) => child.id == task.id);
|
|
if (isDirectChild && task.status != TaskStatus.completed) {
|
|
final transition = _completeWithTransition(
|
|
task: task,
|
|
operationId: opId,
|
|
occurredAt: now,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
metadata: {
|
|
'completionSource': initiatingChildTaskId == null
|
|
? 'parentForce'
|
|
: 'childForceParent',
|
|
'parentTaskId': parent.id,
|
|
if (initiatingChildTaskId != null)
|
|
'initiatingChildTaskId': initiatingChildTaskId,
|
|
},
|
|
);
|
|
updatedTasks.add(transition.task);
|
|
activities.addAll(transition.activities);
|
|
outcomes.add(transition.outcomeCode);
|
|
if (transition.applied) {
|
|
forceCompletedChildIds.add(task.id);
|
|
changedTaskIds.add(task.id);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
updatedTasks.add(task);
|
|
}
|
|
|
|
return ChildTaskCompletionResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
|
|
completedParentId: parent.id,
|
|
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
|
|
activities: List<TaskActivity>.unmodifiable(activities),
|
|
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
|
|
outcomes,
|
|
),
|
|
);
|
|
}
|
|
|
|
TaskTransitionResult _completeWithTransition({
|
|
required Task task,
|
|
required String operationId,
|
|
required DateTime occurredAt,
|
|
required Iterable<TaskActivity> existingActivities,
|
|
required Iterable<String> appliedActivityIds,
|
|
required Map<String, Object?> metadata,
|
|
}) {
|
|
return transitionService.apply(
|
|
task: task,
|
|
transitionCode: TaskTransitionCode.complete,
|
|
operationId: operationId,
|
|
activityId: _activityId(
|
|
operationId,
|
|
task.id,
|
|
TaskActivityCode.completed,
|
|
),
|
|
occurredAt: occurredAt,
|
|
existingActivities: existingActivities,
|
|
appliedActivityIds: appliedActivityIds,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Status counts for a parent's direct children.
|
|
class ChildTaskSummary {
|
|
const ChildTaskSummary({
|
|
required this.totalCount,
|
|
required this.plannedCount,
|
|
required this.activeCount,
|
|
required this.completedCount,
|
|
required this.missedCount,
|
|
required this.cancelledCount,
|
|
required this.noLongerRelevantCount,
|
|
required this.backlogCount,
|
|
});
|
|
|
|
/// Build counts from [children].
|
|
factory ChildTaskSummary.fromChildren(List<Task> children) {
|
|
var plannedCount = 0;
|
|
var activeCount = 0;
|
|
var completedCount = 0;
|
|
var missedCount = 0;
|
|
var cancelledCount = 0;
|
|
var noLongerRelevantCount = 0;
|
|
var backlogCount = 0;
|
|
|
|
for (final child in children) {
|
|
switch (child.status) {
|
|
case TaskStatus.planned:
|
|
plannedCount += 1;
|
|
case TaskStatus.active:
|
|
activeCount += 1;
|
|
case TaskStatus.completed:
|
|
completedCount += 1;
|
|
case TaskStatus.missed:
|
|
missedCount += 1;
|
|
case TaskStatus.cancelled:
|
|
cancelledCount += 1;
|
|
case TaskStatus.noLongerRelevant:
|
|
noLongerRelevantCount += 1;
|
|
case TaskStatus.backlog:
|
|
backlogCount += 1;
|
|
}
|
|
}
|
|
|
|
return ChildTaskSummary(
|
|
totalCount: children.length,
|
|
plannedCount: plannedCount,
|
|
activeCount: activeCount,
|
|
completedCount: completedCount,
|
|
missedCount: missedCount,
|
|
cancelledCount: cancelledCount,
|
|
noLongerRelevantCount: noLongerRelevantCount,
|
|
backlogCount: backlogCount,
|
|
);
|
|
}
|
|
|
|
/// Total direct children counted.
|
|
final int totalCount;
|
|
|
|
/// Children in planned status.
|
|
final int plannedCount;
|
|
|
|
/// Children in active status.
|
|
final int activeCount;
|
|
|
|
/// Children in completed status.
|
|
final int completedCount;
|
|
|
|
/// Children in missed status.
|
|
final int missedCount;
|
|
|
|
/// Children in cancelled status.
|
|
final int cancelledCount;
|
|
|
|
/// Children marked no longer relevant.
|
|
final int noLongerRelevantCount;
|
|
|
|
/// Children currently in backlog.
|
|
final int backlogCount;
|
|
|
|
/// Whether at least one direct child exists.
|
|
bool get hasChildren => totalCount > 0;
|
|
|
|
/// Whether every direct child is completed.
|
|
bool get allChildrenCompleted => hasChildren && completedCount == totalCount;
|
|
}
|
|
|
|
class _IndexedChild {
|
|
const _IndexedChild({
|
|
required this.task,
|
|
required this.originalIndex,
|
|
});
|
|
|
|
final Task task;
|
|
final int originalIndex;
|
|
}
|
|
|
|
int _priorityRank(PriorityLevel? priority) {
|
|
return switch (priority) {
|
|
PriorityLevel.veryLow => 0,
|
|
PriorityLevel.low => 1,
|
|
PriorityLevel.medium => 2,
|
|
PriorityLevel.high => 3,
|
|
PriorityLevel.veryHigh => 4,
|
|
null => -1,
|
|
};
|
|
}
|
|
|
|
Task? _taskById(List<Task> tasks, String taskId) {
|
|
for (final task in tasks) {
|
|
if (task.id == taskId) {
|
|
return task;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
List<Task> _replaceTask(List<Task> tasks, Task replacement) {
|
|
return tasks
|
|
.map((task) => task.id == replacement.id ? replacement : task)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String _defaultOperationId(String actionName, String taskId, DateTime at) {
|
|
return '$actionName:$taskId:${at.toIso8601String()}';
|
|
}
|
|
|
|
String _activityId(
|
|
String operationId,
|
|
String taskId,
|
|
TaskActivityCode activityCode,
|
|
) {
|
|
return '$operationId:$taskId:${activityCode.name}';
|
|
}
|
|
|
|
String _requiredTrimmed(String value, String name) {
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) {
|
|
throw ArgumentError.value(value, name, 'Value is required.');
|
|
}
|
|
return trimmed;
|
|
}
|