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
|
# 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.
|
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 flexible duration display.
|
||||||
- Tests cover explicit time display for inflexible, critical, and locked items.
|
- 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
|
## Chunk 8.2 — Hidden locked block overlay state
|
||||||
|
|
||||||
Recommended Codex level: medium
|
Recommended Codex level: medium
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 08 — Today Timeline State
|
# 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.
|
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 flexible duration display.
|
||||||
- Tests cover explicit time display for inflexible, critical, and locked items.
|
- 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
|
## Chunk 8.2 — Hidden locked block overlay state
|
||||||
|
|
||||||
Recommended Codex level: medium
|
Recommended Codex level: medium
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,4 @@ export 'src/quick_capture.dart';
|
||||||
export 'src/scheduling_engine.dart';
|
export 'src/scheduling_engine.dart';
|
||||||
export 'src/task_actions.dart';
|
export 'src/task_actions.dart';
|
||||||
export 'src/task_statistics.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