forked from eva/focus-flow
feat(timeline): add manual compact mode state
This commit is contained in:
parent
9c19b85f03
commit
88434e3805
4 changed files with 306 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 08 — Today Timeline State
|
||||
|
||||
Status: In progress — Chunks 8.1 and 8.2 complete
|
||||
Status: In progress — Chunks 8.1, 8.2, and 8.3 complete
|
||||
|
||||
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
|
||||
|
||||
|
|
@ -105,6 +105,14 @@ Acceptance criteria:
|
|||
- Tests cover compact mode not activating automatically after pushed/missed tasks.
|
||||
- Tests cover compact mode showing the next required item when one exists.
|
||||
|
||||
Completed:
|
||||
|
||||
- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state.
|
||||
- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping.
|
||||
- Kept locked items out of compact selection unless separately revealed through overlay state.
|
||||
- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion.
|
||||
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
|
||||
|
||||
## Chunk 8.4 — Timeline state regression test suite
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 08 — Today Timeline State
|
||||
|
||||
Status: In progress — Chunks 8.1 and 8.2 complete
|
||||
Status: In progress — Chunks 8.1, 8.2, and 8.3 complete
|
||||
|
||||
Purpose: Prepare domain/view-state data for the Today timeline without building full Flutter UI yet unless explicitly requested.
|
||||
|
||||
|
|
@ -105,6 +105,14 @@ Acceptance criteria:
|
|||
- Tests cover compact mode not activating automatically after pushed/missed tasks.
|
||||
- Tests cover compact mode showing the next required item when one exists.
|
||||
|
||||
Completed:
|
||||
|
||||
- Added `CompactTimelineState` with a manual compact-mode flag and full-timeline expansion state.
|
||||
- Added compact selection for current item, next required item, and optional next flexible item using existing timeline item mapping.
|
||||
- Kept locked items out of compact selection unless separately revealed through overlay state.
|
||||
- Added tests for manual toggle behavior, pushed/missed tasks not auto-enabling compact mode, next required selection, optional next flexible selection, and locked exclusion.
|
||||
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
|
||||
|
||||
## Chunk 8.4 — Timeline state regression test suite
|
||||
|
|
|
|||
|
|
@ -115,6 +115,53 @@ class TimelineItem {
|
|||
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 {
|
||||
const TimelineItemMapper({
|
||||
|
|
@ -183,6 +230,103 @@ class TimelineItemMapper {
|
|||
);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,150 @@ void main() {
|
|||
expect(item.showsExplicitTime, isTrue);
|
||||
expect(item.quickActions, isEmpty);
|
||||
});
|
||||
|
||||
test('manual compact mode flag controls compact output selection', () {
|
||||
final current = scheduledTask(
|
||||
id: 'current',
|
||||
title: 'Current task',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 9),
|
||||
end: DateTime(2026, 6, 20, 10),
|
||||
);
|
||||
final nextRequired = scheduledTask(
|
||||
id: 'next-required',
|
||||
title: 'Next appointment',
|
||||
projectId: project.id,
|
||||
type: TaskType.inflexible,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 11),
|
||||
end: DateTime(2026, 6, 20, 11, 30),
|
||||
);
|
||||
|
||||
final disabled = mapper.compactStateForTasks(
|
||||
tasks: [current, nextRequired],
|
||||
now: DateTime(2026, 6, 20, 9, 15),
|
||||
manualCompactModeEnabled: false,
|
||||
);
|
||||
final enabled = mapper.compactStateForTasks(
|
||||
tasks: [current, nextRequired],
|
||||
now: DateTime(2026, 6, 20, 9, 15),
|
||||
manualCompactModeEnabled: true,
|
||||
);
|
||||
|
||||
expect(disabled.manualCompactModeEnabled, isFalse);
|
||||
expect(disabled.compactItems, isEmpty);
|
||||
expect(disabled.fullTimelineVisible, isTrue);
|
||||
|
||||
expect(enabled.manualCompactModeEnabled, isTrue);
|
||||
expect(enabled.currentItem!.id, 'current');
|
||||
expect(enabled.nextRequiredItem!.id, 'next-required');
|
||||
expect(enabled.compactItems.map((item) => item.id), [
|
||||
'current',
|
||||
'next-required',
|
||||
]);
|
||||
expect(enabled.fullTimelineVisible, isFalse);
|
||||
});
|
||||
|
||||
test('compact mode can include the next flexible task when requested', () {
|
||||
final required = scheduledTask(
|
||||
id: 'required',
|
||||
title: 'Required',
|
||||
projectId: project.id,
|
||||
type: TaskType.critical,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 10),
|
||||
end: DateTime(2026, 6, 20, 10, 30),
|
||||
);
|
||||
final flexible = scheduledTask(
|
||||
id: 'flexible',
|
||||
title: 'Flexible',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 11),
|
||||
end: DateTime(2026, 6, 20, 11, 30),
|
||||
);
|
||||
|
||||
final state = mapper.compactStateForTasks(
|
||||
tasks: [flexible, required],
|
||||
now: DateTime(2026, 6, 20, 9),
|
||||
manualCompactModeEnabled: true,
|
||||
includeNextFlexibleTask: true,
|
||||
);
|
||||
|
||||
expect(state.currentItem, isNull);
|
||||
expect(state.nextRequiredItem!.id, 'required');
|
||||
expect(state.nextFlexibleItem!.id, 'flexible');
|
||||
expect(state.compactItems.map((item) => item.id), [
|
||||
'required',
|
||||
'flexible',
|
||||
]);
|
||||
});
|
||||
|
||||
test('compact mode does not activate after pushed or missed tasks', () {
|
||||
final pushed = scheduledTask(
|
||||
id: 'pushed',
|
||||
title: 'Pushed task',
|
||||
projectId: project.id,
|
||||
type: TaskType.flexible,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 14),
|
||||
end: DateTime(2026, 6, 20, 14, 30),
|
||||
);
|
||||
final missed = scheduledTask(
|
||||
id: 'missed',
|
||||
title: 'Missed appointment',
|
||||
projectId: project.id,
|
||||
type: TaskType.inflexible,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 8),
|
||||
end: DateTime(2026, 6, 20, 8, 30),
|
||||
).copyWith(status: TaskStatus.missed);
|
||||
|
||||
final state = mapper.compactStateForTasks(
|
||||
tasks: [pushed, missed],
|
||||
now: DateTime(2026, 6, 20, 9),
|
||||
manualCompactModeEnabled: false,
|
||||
);
|
||||
|
||||
expect(state.manualCompactModeEnabled, isFalse);
|
||||
expect(state.compactItems, isEmpty);
|
||||
expect(state.fullTimelineVisible, isTrue);
|
||||
});
|
||||
|
||||
test('compact mode ignores locked items unless overlay reveal is separate',
|
||||
() {
|
||||
final locked = scheduledTask(
|
||||
id: 'locked',
|
||||
title: 'Hidden blocked time',
|
||||
projectId: 'locked',
|
||||
type: TaskType.locked,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 9),
|
||||
end: DateTime(2026, 6, 20, 10),
|
||||
);
|
||||
final required = scheduledTask(
|
||||
id: 'required',
|
||||
title: 'Visible required task',
|
||||
projectId: project.id,
|
||||
type: TaskType.critical,
|
||||
createdAt: createdAt,
|
||||
start: DateTime(2026, 6, 20, 10),
|
||||
end: DateTime(2026, 6, 20, 10, 30),
|
||||
);
|
||||
|
||||
final state = mapper.compactStateForTasks(
|
||||
tasks: [locked, required],
|
||||
now: DateTime(2026, 6, 20, 9, 15),
|
||||
manualCompactModeEnabled: true,
|
||||
);
|
||||
|
||||
expect(state.currentItem, isNull);
|
||||
expect(state.nextRequiredItem!.id, 'required');
|
||||
expect(state.compactItems.map((item) => item.id), ['required']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue