// 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'; import 'time_contracts.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 quickActions, required this.category, this.start, this.end, this.durationMinutes, }) : quickActions = List.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 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 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 projectColorTokensById = const {}, }) : projectColorTokensById = Map.unmodifiable(projectColorTokensById); /// Project id to border color token lookup. final Map projectColorTokensById; /// Build a mapper from project profiles. factory TimelineItemMapper.fromProjects(Iterable 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, CivilDate? occurrenceDate, }) { if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { return null; } return TimelineItem( id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), 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 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() .firstWhere( (task) => task != null, orElse: () => null, ); final nextRequiredTask = timelineTasks .where((task) { return task.isRequiredVisible && _startsAtOrAfter(task, now); }) .cast() .firstWhere( (task) => task != null, orElse: () => null, ); final nextFlexibleTask = includeNextFlexibleTask ? timelineTasks .where((task) { return task.isFlexible && task.id != currentTask?.id && _startsAtOrAfter(task, now); }) .cast() .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, { CivilDate? occurrenceDate, }) { final datePart = occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}'; final lockedBlockId = occurrence.lockedBlockId; if (lockedBlockId != null) { return 'locked-block:$lockedBlockId${datePart ?? ''}'; } final overrideId = occurrence.overrideId; if (overrideId != null) { return 'locked-override:$overrideId${datePart ?? ''}'; } 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 _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; } } }