forked from eva/focus-flow
feat: add past task push presentation state
This commit is contained in:
parent
d4c85494c0
commit
f3795e8be1
11 changed files with 232 additions and 9 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 02 — Past Task Read Model
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Add presentation-model state that tells widgets when to show the full
|
||||
Push button.
|
||||
|
||||
|
|
@ -15,6 +15,17 @@ Push button.
|
|||
After this block, `TimelineCardModel` or its successor exposes a clear push-state
|
||||
field. Leaf widgets should not recompute past/incomplete scheduling conditions.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
1. `TimelineItem` carries the source task `updatedAt` timestamp for stale-write
|
||||
protection.
|
||||
2. `TodayScreenData` and `TimelineCardModel` receive an injected clock for
|
||||
deterministic past/incomplete detection.
|
||||
3. `TimelineCardModel.showPastTaskPushButton` identifies past incomplete planned
|
||||
flexible cards and suppresses hover quick actions while true.
|
||||
4. Mapper tests cover earlier today, previous day, future day, completed,
|
||||
unsupported task types, and missing schedule placement.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.1 — Add push-state presentation fields
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ class TodayScreenController extends ChangeNotifier {
|
|||
required this.read,
|
||||
required this.selectedDates,
|
||||
required this.dateLabelFor,
|
||||
}) : _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? _utcNow,
|
||||
_state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
|
||||
selectedDates.addListener(_handleSelectedDateChanged);
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +86,9 @@ class TodayScreenController extends ChangeNotifier {
|
|||
/// Formats dates before a backend read has returned screen data.
|
||||
final TodayDateLabelFormatter dateLabelFor;
|
||||
|
||||
/// Clock used while mapping time-sensitive presentation state.
|
||||
final DateTime Function() now;
|
||||
|
||||
/// Private state stored as `_state` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
TodayScreenState _state;
|
||||
|
|
@ -130,7 +135,10 @@ class TodayScreenController extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
|
||||
final data = TodayScreenData.fromTodayState(result.requireValue);
|
||||
final data = TodayScreenData.fromTodayState(
|
||||
result.requireValue,
|
||||
now: now(),
|
||||
);
|
||||
_syncSelectedCard(data);
|
||||
_state = data.cards.isEmpty
|
||||
? TodayScreenEmpty(data)
|
||||
|
|
@ -215,4 +223,8 @@ class TodayScreenController extends ChangeNotifier {
|
|||
}
|
||||
_selectedCard = null;
|
||||
}
|
||||
|
||||
/// Runs the `_utcNow` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,17 +21,23 @@ class TimelineCardModel {
|
|||
required this.isCompleted,
|
||||
required this.isSelectable,
|
||||
required this.showsQuickActions,
|
||||
this.showPastTaskPushButton = false,
|
||||
this.completionDateContextRequired = false,
|
||||
this.completedTimeText,
|
||||
this.expectedUpdatedAt,
|
||||
this.durationMinutes,
|
||||
});
|
||||
|
||||
/// Maps a scheduler timeline item into card presentation data.
|
||||
factory TimelineCardModel.fromItem(TodayTimelineItem item) {
|
||||
factory TimelineCardModel.fromItem(
|
||||
TodayTimelineItem item, {
|
||||
required DateTime now,
|
||||
}) {
|
||||
final start = item.start;
|
||||
final end = item.end;
|
||||
final visualKind = _visualKindFor(item);
|
||||
final isCompleted = item.taskStatus == TaskStatus.completed;
|
||||
final showPastTaskPushButton = _showPastTaskPushButton(item, now: now);
|
||||
final title = item.item.displayTitle;
|
||||
final duration = item.item.durationMinutes;
|
||||
final timeText = start == null || end == null
|
||||
|
|
@ -66,9 +72,14 @@ class TimelineCardModel {
|
|||
includeDate: completionDateContextRequired,
|
||||
)
|
||||
: null,
|
||||
expectedUpdatedAt: item.item.updatedAt,
|
||||
isCompleted: isCompleted,
|
||||
isSelectable: true,
|
||||
showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot,
|
||||
showPastTaskPushButton: showPastTaskPushButton,
|
||||
showsQuickActions:
|
||||
!showPastTaskPushButton &&
|
||||
!isCompleted &&
|
||||
visualKind != TaskVisualKind.freeSlot,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +101,9 @@ class TimelineCardModel {
|
|||
/// Display-ready completion time or date-time, if the task has been completed.
|
||||
final String? completedTimeText;
|
||||
|
||||
/// Expected update timestamp for stale-write protection.
|
||||
final DateTime? expectedUpdatedAt;
|
||||
|
||||
/// Whether completion text includes date context because completion crossed dates.
|
||||
final bool completionDateContextRequired;
|
||||
|
||||
|
|
@ -123,6 +137,12 @@ class TimelineCardModel {
|
|||
/// Whether the card should show compact quick-action controls.
|
||||
final bool showsQuickActions;
|
||||
|
||||
/// Whether the card should show the full overdue-task push control.
|
||||
final bool showPastTaskPushButton;
|
||||
|
||||
/// Accessible label for the full overdue-task push control.
|
||||
String get pastTaskPushSemanticLabel => 'Push overdue task';
|
||||
|
||||
/// Whether the left status control can toggle this task's completion.
|
||||
bool get canToggleCompletion {
|
||||
return switch (taskType) {
|
||||
|
|
|
|||
|
|
@ -101,3 +101,19 @@ bool _completionDateContextRequired(TodayTimelineItem item) {
|
|||
actualStartDate != null && actualStartDate != scheduledDate ||
|
||||
actualEndDate != null && actualEndDate != scheduledDate;
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_showPastTaskPushButton` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
bool _showPastTaskPushButton(TodayTimelineItem item, {required DateTime now}) {
|
||||
if (item.source != TodayTimelineItemSource.task) {
|
||||
return false;
|
||||
}
|
||||
if (item.taskType != TaskType.flexible) {
|
||||
return false;
|
||||
}
|
||||
if (item.taskStatus != TaskStatus.planned) {
|
||||
return false;
|
||||
}
|
||||
final end = item.end;
|
||||
return end != null && end.isBefore(now);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ class TodayScreenData {
|
|||
}
|
||||
|
||||
/// Maps scheduler core [TodayState] into UI presentation data.
|
||||
factory TodayScreenData.fromTodayState(TodayState state) {
|
||||
factory TodayScreenData.fromTodayState(
|
||||
TodayState state, {
|
||||
required DateTime now,
|
||||
}) {
|
||||
final cards = [
|
||||
for (final item in state.timelineItems) TimelineCardModel.fromItem(item),
|
||||
for (final item in state.timelineItems)
|
||||
TimelineCardModel.fromItem(item, now: now),
|
||||
];
|
||||
final nextRequired = state.nextRequiredItem;
|
||||
return TodayScreenData(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ void main() {
|
|||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
completedAt: DateTime.utc(2026, 1, 3, 9, 25),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 3, 10),
|
||||
);
|
||||
|
||||
expect(card.completionDateContextRequired, isFalse);
|
||||
|
|
@ -32,6 +33,7 @@ void main() {
|
|||
scheduledEnd: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
completedAt: DateTime.utc(2026, 1, 4, 8, 5),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 4, 9),
|
||||
);
|
||||
|
||||
expect(card.completionDateContextRequired, isTrue);
|
||||
|
|
@ -48,6 +50,7 @@ void main() {
|
|||
actualStart: DateTime.utc(2026, 1, 4, 0, 10),
|
||||
actualEnd: DateTime.utc(2026, 1, 4, 0, 20),
|
||||
),
|
||||
now: DateTime.utc(2026, 1, 4, 1),
|
||||
);
|
||||
|
||||
expect(card.completionDateContextRequired, isTrue);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests past-task push presentation mapping.
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
||||
|
||||
/// Runs past-task push presentation tests.
|
||||
void main() {
|
||||
final now = DateTime.utc(2026, 1, 3, 12);
|
||||
|
||||
test('planned flexible task earlier today shows Push state', () {
|
||||
final updatedAt = DateTime.utc(2026, 1, 3, 8, 45);
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 3, 9),
|
||||
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isTrue);
|
||||
expect(card.showsQuickActions, isFalse);
|
||||
expect(card.expectedUpdatedAt, updatedAt);
|
||||
expect(card.pastTaskPushSemanticLabel, 'Push overdue task');
|
||||
});
|
||||
|
||||
test('planned flexible task later today does not show Push state', () {
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 3, 14),
|
||||
end: DateTime.utc(2026, 1, 3, 14, 30),
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isFalse);
|
||||
expect(card.showsQuickActions, isTrue);
|
||||
});
|
||||
|
||||
test('planned flexible task on a previous date shows Push state', () {
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 2, 14),
|
||||
end: DateTime.utc(2026, 1, 2, 14, 30),
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isTrue);
|
||||
expect(card.showsQuickActions, isFalse);
|
||||
});
|
||||
|
||||
test('planned flexible task on a future date does not show Push state', () {
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 4, 9),
|
||||
end: DateTime.utc(2026, 1, 4, 9, 30),
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isFalse);
|
||||
expect(card.showsQuickActions, isTrue);
|
||||
});
|
||||
|
||||
test('completed flexible task in the past does not show Push state', () {
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 3, 9),
|
||||
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
status: TaskStatus.completed,
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isFalse);
|
||||
expect(card.showsQuickActions, isFalse);
|
||||
});
|
||||
|
||||
test('unsupported task types do not show Push state', () {
|
||||
for (final type in [
|
||||
TaskType.critical,
|
||||
TaskType.inflexible,
|
||||
TaskType.locked,
|
||||
TaskType.surprise,
|
||||
TaskType.freeSlot,
|
||||
]) {
|
||||
final card = TimelineCardModel.fromItem(
|
||||
_item(
|
||||
start: DateTime.utc(2026, 1, 3, 9),
|
||||
end: DateTime.utc(2026, 1, 3, 9, 30),
|
||||
type: type,
|
||||
),
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(card.showPastTaskPushButton, isFalse, reason: type.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('task without schedule placement does not show Push state', () {
|
||||
final card = TimelineCardModel.fromItem(_item(), now: now);
|
||||
|
||||
expect(card.showPastTaskPushButton, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a timeline item for push-state presentation tests.
|
||||
TodayTimelineItem _item({
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
DateTime? updatedAt,
|
||||
TaskType type = TaskType.flexible,
|
||||
TaskStatus status = TaskStatus.planned,
|
||||
}) {
|
||||
return TodayTimelineItem(
|
||||
item: TimelineItem(
|
||||
id: 'task-${type.name}-${status.name}',
|
||||
displayTitle: 'Task',
|
||||
taskType: type,
|
||||
projectColorToken: 'project-home',
|
||||
backgroundToken: TimelineBackgroundToken.flexible,
|
||||
rewardIconToken: TimelineRewardIconToken.medium,
|
||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
||||
showsExplicitTime: true,
|
||||
quickActions: const [],
|
||||
category: TimelineItemCategory.taskCard,
|
||||
start: start,
|
||||
end: end,
|
||||
updatedAt: updatedAt,
|
||||
durationMinutes: start == null || end == null
|
||||
? null
|
||||
: end.difference(start).inMinutes,
|
||||
),
|
||||
source: TodayTimelineItemSource.task,
|
||||
taskStatus: status,
|
||||
taskId: 'task-${type.name}-${status.name}',
|
||||
);
|
||||
}
|
||||
|
|
@ -169,6 +169,7 @@ void main() {
|
|||
expect(completed.item.completedAt, isNotNull);
|
||||
final completedData = TodayScreenData.fromTodayState(
|
||||
await _readToday(tester, composition, tomorrow),
|
||||
now: DateTime.utc(2026, 1, 4, 8, 20),
|
||||
);
|
||||
final completedCard = completedData.cards.singleWhere(
|
||||
(card) => card.title == title,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,10 @@ void main() {
|
|||
'visual token adapter maps seeded items to expected visual kinds',
|
||||
() async {
|
||||
final state = await _readSeededToday();
|
||||
final data = TodayScreenData.fromTodayState(state);
|
||||
final data = TodayScreenData.fromTodayState(
|
||||
state,
|
||||
now: DateTime.utc(2025, 5, 20, 15, 20),
|
||||
);
|
||||
|
||||
expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible);
|
||||
expect(_kindFor(data, 'sort-mail'), TaskVisualKind.flexible);
|
||||
|
|
@ -357,7 +360,10 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
final state = await _readSeededToday();
|
||||
final data = TodayScreenData.fromTodayState(state);
|
||||
final data = TodayScreenData.fromTodayState(
|
||||
state,
|
||||
now: DateTime.utc(2025, 5, 20, 15, 20),
|
||||
);
|
||||
|
||||
await _pumpConstrainedWidget(
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class TimelineItemMapper {
|
|||
actualStart: task.actualStart,
|
||||
actualEnd: task.actualEnd,
|
||||
completedAt: task.completedAt,
|
||||
updatedAt: task.updatedAt,
|
||||
durationMinutes: _durationMinutesFor(task),
|
||||
quickActions: _quickActionsFor(task.type),
|
||||
category: task.isLocked
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class TimelineItem {
|
|||
this.actualStart,
|
||||
this.actualEnd,
|
||||
this.completedAt,
|
||||
this.updatedAt,
|
||||
this.durationMinutes,
|
||||
}) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
|
||||
|
||||
|
|
@ -65,6 +66,9 @@ class TimelineItem {
|
|||
/// Completion instant, if the source task has been completed.
|
||||
final DateTime? completedAt;
|
||||
|
||||
/// Source task update timestamp, when the item was mapped from a task.
|
||||
final DateTime? updatedAt;
|
||||
|
||||
/// Duration display value for flexible task cards.
|
||||
final int? durationMinutes;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue