forked from eva/focus-flow
feat(timeline): add timeline item view model
This commit is contained in:
parent
89faecdee9
commit
6f31290397
5 changed files with 427 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 08 — Today Timeline State
|
||||
|
||||
Status: Planned
|
||||
Status: In progress — Chunk 8.1 complete
|
||||
|
||||
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
|
||||
|
||||
|
|
@ -40,6 +40,13 @@ Acceptance criteria:
|
|||
- Tests cover flexible duration display.
|
||||
- Tests cover explicit time display for inflexible, critical, and locked items.
|
||||
|
||||
Completed:
|
||||
|
||||
- Added UI-independent timeline item and mapper models in `lib/src/timeline_state.dart`.
|
||||
- Exported timeline state through `lib/scheduler_core.dart`.
|
||||
- Added tests for task field/token mapping, flexible duration display, explicit time display, and locked overlay category/no-actions behavior.
|
||||
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
|
||||
|
||||
## Chunk 8.2 — Hidden locked block overlay state
|
||||
|
||||
Recommended Codex level: medium
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 08 — Today Timeline State
|
||||
|
||||
Status: Planned
|
||||
Status: In progress — Chunk 8.1 complete
|
||||
|
||||
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
|
||||
|
||||
|
|
@ -40,6 +40,13 @@ Acceptance criteria:
|
|||
- Tests cover flexible duration display.
|
||||
- Tests cover explicit time display for inflexible, critical, and locked items.
|
||||
|
||||
Completed:
|
||||
|
||||
- Added UI-independent timeline item and mapper models in `lib/src/timeline_state.dart`.
|
||||
- Exported timeline state through `lib/scheduler_core.dart`.
|
||||
- Added tests for task field/token mapping, flexible duration display, explicit time display, and locked overlay category/no-actions behavior.
|
||||
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
|
||||
|
||||
## Chunk 8.2 — Hidden locked block overlay state
|
||||
|
||||
Recommended Codex level: medium
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export 'src/quick_capture.dart';
|
|||
export 'src/scheduling_engine.dart';
|
||||
export 'src/task_actions.dart';
|
||||
export 'src/task_statistics.dart';
|
||||
export 'src/timeline_state.dart';
|
||||
|
|
|
|||
254
lib/src/timeline_state.dart
Normal file
254
lib/src/timeline_state.dart
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// 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 '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 {
|
||||
const 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 this.quickActions,
|
||||
required this.category,
|
||||
this.start,
|
||||
this.end,
|
||||
this.durationMinutes,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Converts domain tasks into Today timeline items.
|
||||
class TimelineItemMapper {
|
||||
const TimelineItemMapper({
|
||||
this.projectColorTokensById = const <String, String>{},
|
||||
});
|
||||
|
||||
/// 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:
|
||||
projectColorTokensById[task.projectId] ?? 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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
test/timeline_state_test.dart
Normal file
156
test/timeline_state_test.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Timeline item view model', () {
|
||||
final createdAt = DateTime(2026, 6, 20, 8);
|
||||
final start = DateTime(2026, 6, 20, 9);
|
||||
final end = DateTime(2026, 6, 20, 9, 30);
|
||||
const project = ProjectProfile(
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
colorKey: 'project-home',
|
||||
);
|
||||
final mapper = TimelineItemMapper.fromProjects(const [project]);
|
||||
|
||||
test('maps task fields to display tokens', () {
|
||||
final task = scheduledTask(
|
||||
id: 'dishes',
|
||||
title: 'Wash dishes',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
createdAt: createdAt,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
final item = mapper.fromTask(task);
|
||||
|
||||
expect(item.id, 'dishes');
|
||||
expect(item.displayTitle, 'Wash dishes');
|
||||
expect(item.taskType, TaskType.flexible);
|
||||
expect(item.projectColorToken, 'project-home');
|
||||
expect(item.backgroundToken, TimelineBackgroundToken.flexible);
|
||||
expect(item.rewardIconToken, TimelineRewardIconToken.high);
|
||||
expect(item.difficultyIconToken, TimelineDifficultyIconToken.hard);
|
||||
expect(item.category, TimelineItemCategory.taskCard);
|
||||
expect(item.quickActions, [
|
||||
TimelineQuickAction.done,
|
||||
TimelineQuickAction.push,
|
||||
TimelineQuickAction.backlog,
|
||||
TimelineQuickAction.breakUp,
|
||||
]);
|
||||
});
|
||||
|
||||
test('flexible task cards display duration while retaining position', () {
|
||||
final task = scheduledTask(
|
||||
id: 'laundry',
|
||||
title: 'Fold laundry',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
durationMinutes: 25,
|
||||
createdAt: createdAt,
|
||||
start: start,
|
||||
end: start.add(const Duration(minutes: 25)),
|
||||
);
|
||||
|
||||
final item = mapper.fromTask(task);
|
||||
|
||||
expect(item.showsExplicitTime, isFalse);
|
||||
expect(item.durationMinutes, 25);
|
||||
expect(item.start, start);
|
||||
expect(item.end, start.add(const Duration(minutes: 25)));
|
||||
});
|
||||
|
||||
test('flexible duration can fall back to scheduled interval', () {
|
||||
final task = scheduledTask(
|
||||
id: 'admin',
|
||||
title: 'Handle admin',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
createdAt: createdAt,
|
||||
start: start,
|
||||
end: start.add(const Duration(minutes: 45)),
|
||||
);
|
||||
|
||||
final item = mapper.fromTask(task);
|
||||
|
||||
expect(item.showsExplicitTime, isFalse);
|
||||
expect(item.durationMinutes, 45);
|
||||
});
|
||||
|
||||
test('inflexible critical and locked items show explicit times', () {
|
||||
for (final type in [
|
||||
TaskType.inflexible,
|
||||
TaskType.critical,
|
||||
TaskType.locked,
|
||||
]) {
|
||||
final task = scheduledTask(
|
||||
id: type.name,
|
||||
title: type.name,
|
||||
projectId: project.id,
|
||||
type: type,
|
||||
createdAt: createdAt,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
final item = mapper.fromTask(task);
|
||||
|
||||
expect(item.showsExplicitTime, isTrue, reason: type.name);
|
||||
expect(item.start, start, reason: type.name);
|
||||
expect(item.end, end, reason: type.name);
|
||||
expect(item.durationMinutes, isNull, reason: type.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('locked item is an overlay without normal task quick actions', () {
|
||||
final task = scheduledTask(
|
||||
id: 'sleep',
|
||||
title: 'Sleep',
|
||||
projectId: 'locked',
|
||||
type: TaskType.locked,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 0),
|
||||
end: DateTime(2026, 6, 20, 7),
|
||||
);
|
||||
|
||||
final item = mapper.fromTask(task);
|
||||
|
||||
expect(item.category, TimelineItemCategory.overlay);
|
||||
expect(item.projectColorToken, 'locked');
|
||||
expect(item.backgroundToken, TimelineBackgroundToken.locked);
|
||||
expect(item.quickActions, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Task scheduledTask({
|
||||
required String id,
|
||||
required String title,
|
||||
required String projectId,
|
||||
required TaskType type,
|
||||
required DateTime createdAt,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
RewardLevel reward = RewardLevel.notSet,
|
||||
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
||||
int? durationMinutes,
|
||||
}) {
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: projectId,
|
||||
type: type,
|
||||
status: TaskStatus.planned,
|
||||
reward: reward,
|
||||
difficulty: difficulty,
|
||||
durationMinutes: durationMinutes,
|
||||
scheduledStart: start,
|
||||
scheduledEnd: end,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue