focus-flow/lib/src/timeline_state.dart

448 lines
13 KiB
Dart

// UI-independent Today timeline view state.
//
// This file translates domain tasks into small display-ready value objects
// without importing Flutter or choosing visual assets. UI layers can map these
// tokens to colors, icons, text, or widgets later.
import 'locked_time.dart';
import 'models.dart';
/// Broad display category for an item placed on the Today timeline.
enum TimelineItemCategory {
/// Normal visible task card.
taskCard,
/// Non-task timeline layer such as locked time.
overlay,
}
/// Background style token derived from the scheduling behavior type.
enum TimelineBackgroundToken {
flexible,
inflexible,
critical,
locked,
surprise,
freeSlot,
}
/// Reward icon token for the first timeline card icon.
enum TimelineRewardIconToken {
notSet,
veryLow,
low,
medium,
high,
veryHigh,
}
/// Difficulty icon token for the second timeline card icon.
enum TimelineDifficultyIconToken {
notSet,
veryEasy,
easy,
medium,
hard,
veryHard,
}
/// Quick actions the timeline can expose without depending on a UI framework.
enum TimelineQuickAction {
done,
push,
backlog,
breakUp,
missed,
cancel,
noLongerRelevant,
}
/// Display-ready timeline item derived from a domain [Task].
class TimelineItem {
TimelineItem({
required this.id,
required this.displayTitle,
required this.taskType,
required this.projectColorToken,
required this.backgroundToken,
required this.rewardIconToken,
required this.difficultyIconToken,
required this.showsExplicitTime,
required List<TimelineQuickAction> quickActions,
required this.category,
this.start,
this.end,
this.durationMinutes,
}) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
/// Stable id of the source item.
final String id;
/// Text shown as the item name.
final String displayTitle;
/// Source scheduling behavior type.
final TaskType taskType;
/// Border/project color token. This is a key, not a raw color.
final String projectColorToken;
/// Translucent background token derived from [taskType].
final TimelineBackgroundToken backgroundToken;
/// First icon token.
final TimelineRewardIconToken rewardIconToken;
/// Second icon token.
final TimelineDifficultyIconToken difficultyIconToken;
/// Whether the item should render explicit start/end time text.
final bool showsExplicitTime;
/// Timeline placement start, if the source item has one.
final DateTime? start;
/// Timeline placement end, if the source item has one.
final DateTime? end;
/// Duration display value for flexible task cards.
final int? durationMinutes;
/// Quick actions available from this item.
final List<TimelineQuickAction> quickActions;
/// Whether this is a normal task card or a non-task overlay.
final TimelineItemCategory category;
}
/// Reduced Today timeline state for manual compact mode.
class CompactTimelineState {
const CompactTimelineState({
required this.manualCompactModeEnabled,
required this.fullTimelineExpanded,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
});
/// User-controlled compact mode flag.
final bool manualCompactModeEnabled;
/// Whether the full timeline is expanded while compact mode is active.
final bool fullTimelineExpanded;
/// Current visible task, if one is active or occupies [now].
final TimelineItem? currentItem;
/// Next critical or inflexible task after the current moment.
final TimelineItem? nextRequiredItem;
/// Optional next flexible task after the current moment.
final TimelineItem? nextFlexibleItem;
/// Whether callers should show the full timeline.
bool get fullTimelineVisible {
return !manualCompactModeEnabled || fullTimelineExpanded;
}
/// Items selected for the compact view.
List<TimelineItem> get compactItems {
if (!manualCompactModeEnabled) {
return const [];
}
return [
if (currentItem != null) currentItem!,
if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id)
nextRequiredItem!,
if (nextFlexibleItem != null &&
nextFlexibleItem!.id != currentItem?.id &&
nextFlexibleItem!.id != nextRequiredItem?.id)
nextFlexibleItem!,
];
}
}
/// Converts domain tasks into Today timeline items.
class TimelineItemMapper {
TimelineItemMapper({
Map<String, String> projectColorTokensById = const <String, String>{},
}) : projectColorTokensById =
Map<String, String>.unmodifiable(projectColorTokensById);
/// Project id to border color token lookup.
final Map<String, String> projectColorTokensById;
/// Build a mapper from project profiles.
factory TimelineItemMapper.fromProjects(Iterable<ProjectProfile> projects) {
return TimelineItemMapper(
projectColorTokensById: {
for (final project in projects) project.id: project.colorKey,
},
);
}
/// Convert [task] into framework-neutral timeline view state.
TimelineItem fromTask(Task task) {
return TimelineItem(
id: task.id,
displayTitle: task.title,
taskType: task.type,
projectColorToken: _projectColorTokenFor(task.projectId),
backgroundToken: _backgroundTokenFor(task.type),
rewardIconToken: _rewardIconTokenFor(task.reward),
difficultyIconToken: _difficultyIconTokenFor(task.difficulty),
showsExplicitTime: _showsExplicitTime(task.type),
start: task.scheduledStart,
end: task.scheduledEnd,
durationMinutes: _durationMinutesFor(task),
quickActions: _quickActionsFor(task.type),
category: task.isLocked
? TimelineItemCategory.overlay
: TimelineItemCategory.taskCard,
);
}
/// Convert a concrete locked-time occurrence into an overlay item.
///
/// Hidden locked time returns null unless [revealHiddenLockedBlocks] is true.
/// This keeps locked time available to scheduling while leaving UI visibility
/// controlled by an explicit temporary reveal state.
TimelineItem? fromLockedOccurrence(
LockedBlockOccurrence occurrence, {
required bool revealHiddenLockedBlocks,
}) {
if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) {
return null;
}
return TimelineItem(
id: _lockedOccurrenceId(occurrence),
displayTitle: occurrence.name,
taskType: TaskType.locked,
projectColorToken: _projectColorTokenFor(occurrence.projectId),
backgroundToken: TimelineBackgroundToken.locked,
rewardIconToken: TimelineRewardIconToken.notSet,
difficultyIconToken: TimelineDifficultyIconToken.notSet,
showsExplicitTime: true,
start: occurrence.interval.start,
end: occurrence.interval.end,
quickActions: const [],
category: TimelineItemCategory.overlay,
);
}
/// Build manual compact-mode state from scheduled task data.
CompactTimelineState compactStateForTasks({
required Iterable<Task> tasks,
required DateTime now,
required bool manualCompactModeEnabled,
bool includeNextFlexibleTask = false,
bool fullTimelineExpanded = false,
}) {
if (!manualCompactModeEnabled) {
return CompactTimelineState(
manualCompactModeEnabled: false,
fullTimelineExpanded: fullTimelineExpanded,
);
}
final timelineTasks = tasks.where(_canAppearInCompactMode).toList()
..sort(_compareTasksByStart);
final currentTask = timelineTasks
.where((task) {
return task.status == TaskStatus.active || _containsTime(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
);
final nextRequiredTask = timelineTasks
.where((task) {
return task.isRequiredVisible && _startsAtOrAfter(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
);
final nextFlexibleTask = includeNextFlexibleTask
? timelineTasks
.where((task) {
return task.isFlexible && _startsAtOrAfter(task, now);
})
.cast<Task?>()
.firstWhere(
(task) => task != null,
orElse: () => null,
)
: null;
return CompactTimelineState(
manualCompactModeEnabled: true,
fullTimelineExpanded: fullTimelineExpanded,
currentItem: currentTask == null ? null : fromTask(currentTask),
nextRequiredItem:
nextRequiredTask == null ? null : fromTask(nextRequiredTask),
nextFlexibleItem:
nextFlexibleTask == null ? null : fromTask(nextFlexibleTask),
);
}
bool _canAppearInCompactMode(Task task) {
return !task.isLocked &&
task.status != TaskStatus.backlog &&
task.status != TaskStatus.completed &&
task.status != TaskStatus.cancelled &&
task.status != TaskStatus.noLongerRelevant &&
task.scheduledStart != null &&
task.scheduledEnd != null;
}
bool _containsTime(Task task, DateTime now) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return false;
}
return !now.isBefore(start) && now.isBefore(end);
}
bool _startsAtOrAfter(Task task, DateTime now) {
final start = task.scheduledStart;
return start != null && !start.isBefore(now);
}
int _compareTasksByStart(Task left, Task right) {
final leftStart = left.scheduledStart;
final rightStart = right.scheduledStart;
if (leftStart == null && rightStart == null) {
return 0;
}
if (leftStart == null) {
return 1;
}
if (rightStart == null) {
return -1;
}
return leftStart.compareTo(rightStart);
}
String _lockedOccurrenceId(LockedBlockOccurrence occurrence) {
final lockedBlockId = occurrence.lockedBlockId;
if (lockedBlockId != null) {
return 'locked-block:$lockedBlockId';
}
final overrideId = occurrence.overrideId;
if (overrideId != null) {
return 'locked-override:$overrideId';
}
return 'locked:${occurrence.name}:'
'${occurrence.interval.start.toIso8601String()}';
}
String _projectColorTokenFor(String? projectId) {
if (projectId == null) {
return 'locked';
}
return projectColorTokensById[projectId] ?? projectId;
}
bool _showsExplicitTime(TaskType type) {
return type == TaskType.inflexible ||
type == TaskType.critical ||
type == TaskType.locked;
}
int? _durationMinutesFor(Task task) {
if (!task.isFlexible) {
return null;
}
if (task.durationMinutes != null) {
return task.durationMinutes;
}
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return null;
}
return end.difference(start).inMinutes;
}
List<TimelineQuickAction> _quickActionsFor(TaskType type) {
switch (type) {
case TaskType.flexible:
return const [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
];
case TaskType.inflexible:
case TaskType.critical:
return const [
TimelineQuickAction.done,
TimelineQuickAction.missed,
TimelineQuickAction.cancel,
TimelineQuickAction.noLongerRelevant,
];
case TaskType.locked:
case TaskType.surprise:
case TaskType.freeSlot:
return const [];
}
}
TimelineBackgroundToken _backgroundTokenFor(TaskType type) {
switch (type) {
case TaskType.flexible:
return TimelineBackgroundToken.flexible;
case TaskType.inflexible:
return TimelineBackgroundToken.inflexible;
case TaskType.critical:
return TimelineBackgroundToken.critical;
case TaskType.locked:
return TimelineBackgroundToken.locked;
case TaskType.surprise:
return TimelineBackgroundToken.surprise;
case TaskType.freeSlot:
return TimelineBackgroundToken.freeSlot;
}
}
TimelineRewardIconToken _rewardIconTokenFor(RewardLevel reward) {
switch (reward) {
case RewardLevel.notSet:
return TimelineRewardIconToken.notSet;
case RewardLevel.veryLow:
return TimelineRewardIconToken.veryLow;
case RewardLevel.low:
return TimelineRewardIconToken.low;
case RewardLevel.medium:
return TimelineRewardIconToken.medium;
case RewardLevel.high:
return TimelineRewardIconToken.high;
case RewardLevel.veryHigh:
return TimelineRewardIconToken.veryHigh;
}
}
TimelineDifficultyIconToken _difficultyIconTokenFor(
DifficultyLevel difficulty,
) {
switch (difficulty) {
case DifficultyLevel.notSet:
return TimelineDifficultyIconToken.notSet;
case DifficultyLevel.veryEasy:
return TimelineDifficultyIconToken.veryEasy;
case DifficultyLevel.easy:
return TimelineDifficultyIconToken.easy;
case DifficultyLevel.medium:
return TimelineDifficultyIconToken.medium;
case DifficultyLevel.hard:
return TimelineDifficultyIconToken.hard;
case DifficultyLevel.veryHard:
return TimelineDifficultyIconToken.veryHard;
}
}
}