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
|
# 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.
|
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 not activating automatically after pushed/missed tasks.
|
||||||
- Tests cover compact mode showing the next required item when one exists.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
|
||||||
|
|
||||||
## Chunk 8.4 — Timeline state regression test suite
|
## Chunk 8.4 — Timeline state regression test suite
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 08 — Today Timeline State
|
# 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.
|
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 not activating automatically after pushed/missed tasks.
|
||||||
- Tests cover compact mode showing the next required item when one exists.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `medium` mode before implementing the timeline regression test suite.
|
||||||
|
|
||||||
## Chunk 8.4 — Timeline state regression test suite
|
## Chunk 8.4 — Timeline state regression test suite
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,53 @@ class TimelineItem {
|
||||||
final TimelineItemCategory category;
|
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.
|
/// Converts domain tasks into Today timeline items.
|
||||||
class TimelineItemMapper {
|
class TimelineItemMapper {
|
||||||
const 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) {
|
String _lockedOccurrenceId(LockedBlockOccurrence occurrence) {
|
||||||
final lockedBlockId = occurrence.lockedBlockId;
|
final lockedBlockId = occurrence.lockedBlockId;
|
||||||
if (lockedBlockId != null) {
|
if (lockedBlockId != null) {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,150 @@ void main() {
|
||||||
expect(item.showsExplicitTime, isTrue);
|
expect(item.showsExplicitTime, isTrue);
|
||||||
expect(item.quickActions, isEmpty);
|
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