448 lines
13 KiB
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;
|
|
}
|
|
}
|
|
}
|