forked from eva/focus-flow
feat: add past task push menu
This commit is contained in:
parent
f3795e8be1
commit
4d373ce250
16 changed files with 608 additions and 8 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 03 — Push Button Card UI
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Render the full right-side Push button on past incomplete cards.
|
||||
|
||||
---
|
||||
|
|
@ -15,6 +15,16 @@ After this block, past incomplete planned flexible cards visibly show a `Push`
|
|||
button beside the reward/difficulty icons. The menu may still be stubbed until
|
||||
Block 04.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
1. `TaskTimelineCard` renders a full Push button beside reward/difficulty
|
||||
controls when `TimelineCardModel.showPastTaskPushButton` is true.
|
||||
2. Text reserves additional right-side space while Push is visible, and trailing
|
||||
controls remain above the text layer.
|
||||
3. Hover quick actions are not built while the full Push button is visible.
|
||||
4. Widget tests cover render state, completed/future exclusions, hover
|
||||
suppression, and callback activation.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.1 — Place the Push button in trailing controls
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# Task Pushing 01 Block 04 — Push Menu Destinations
|
||||
|
||||
**Status:** Planned.
|
||||
**Status:** Complete.
|
||||
**Goal:** Open a destination menu from the Push button and route each choice to a
|
||||
callback.
|
||||
|
||||
|
|
@ -16,6 +16,17 @@ After this block, clicking `Push` opens a menu with the three requested choices.
|
|||
The choices may call stubbed/controller callbacks until Block 05 wires real
|
||||
commands.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
1. The Push button opens a compact popup menu with exactly `Push to next`,
|
||||
`Push to tomorrow`, and `Push to backlog`.
|
||||
2. Destination-specific callbacks are threaded from the home scaffold through
|
||||
`TimelineView` to `TaskTimelineCard`.
|
||||
3. The card prevents duplicate destination submission while a push callback is
|
||||
still running.
|
||||
4. Widget tests cover menu labels, destination callbacks, outside-click
|
||||
dismissal, and one-shot running behavior.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.1 — Add destination menu UI
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
return TodayScreenController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: dateLabelFor,
|
||||
now: () => readAt,
|
||||
read: (date) {
|
||||
return todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
|
|
|
|||
|
|
@ -66,6 +66,30 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
await commandController.addGenericFlexibleTask();
|
||||
}
|
||||
|
||||
/// Pushes [card] to the next available slot after the current clock time.
|
||||
Future<void> _pushCardToNext(TimelineCardModel card) async {
|
||||
await commandController.pushTaskToNextAvailableSlot(
|
||||
taskId: card.id,
|
||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes [card] to tomorrow's first available slot.
|
||||
Future<void> _pushCardToTomorrow(TimelineCardModel card) async {
|
||||
await commandController.pushTaskToTomorrow(
|
||||
taskId: card.id,
|
||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes [card] back to Backlog.
|
||||
Future<void> _pushCardToBacklog(TimelineCardModel card) async {
|
||||
await commandController.pushTaskToBacklog(
|
||||
taskId: card.id,
|
||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Moves the visible planning date backward by one day.
|
||||
void _selectPreviousDate() {
|
||||
selectedDateController.selectPreviousDay();
|
||||
|
|
@ -114,6 +138,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
@ -127,6 +154,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
required this.selectedCard,
|
||||
required this.onSelectCard,
|
||||
required this.onCompleteCard,
|
||||
required this.onPushToNext,
|
||||
required this.onPushToTomorrow,
|
||||
required this.onPushToBacklog,
|
||||
required this.onAddTask,
|
||||
required this.onClearSelection,
|
||||
required this.onPreviousDate,
|
||||
|
|
@ -36,6 +39,18 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function(TimelineCardModel card) onCompleteCard;
|
||||
|
||||
/// Stores the `onPushToNext` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function(TimelineCardModel card) onPushToNext;
|
||||
|
||||
/// Stores the `onPushToTomorrow` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function(TimelineCardModel card) onPushToTomorrow;
|
||||
|
||||
/// Stores the `onPushToBacklog` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function(TimelineCardModel card) onPushToBacklog;
|
||||
|
||||
/// Stores the `onAddTask` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function() onAddTask;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onCardSelected: widget.onSelectCard,
|
||||
onAddTask: widget.onAddTask,
|
||||
onCardCompleted: widget.onCompleteCard,
|
||||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -192,6 +192,68 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
/// Pushes a past flexible task to the next available slot after now.
|
||||
Future<void> pushTaskToNextAvailableSlot({
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final commandAt = now();
|
||||
await _run(
|
||||
label: 'Pushing task',
|
||||
successMessage: 'Task pushed',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.pushFlexibleToNextAvailableSlot(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
localDate: _civilDateFor(commandAt),
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes a past flexible task to the first available slot tomorrow.
|
||||
Future<void> pushTaskToTomorrow({
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final commandAt = now();
|
||||
await _run(
|
||||
label: 'Pushing task',
|
||||
successMessage: 'Task moved to tomorrow',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.pushFlexibleToTomorrowTopOfQueue(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
targetDate: _civilDateFor(commandAt).addDays(1),
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes a past flexible task back to Backlog.
|
||||
Future<void> pushTaskToBacklog({
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final commandAt = now();
|
||||
await _run(
|
||||
label: 'Moving task to backlog',
|
||||
successMessage: 'Task moved to backlog',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.moveFlexibleToBacklog(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs the `_run` operation and completes after its asynchronous work finishes.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
Future<void> _run({
|
||||
|
|
@ -238,4 +300,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// 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();
|
||||
|
||||
/// Runs the `_civilDateFor` 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 CivilDate _civilDateFor(DateTime instant) {
|
||||
final utc = instant.toUtc();
|
||||
return CivilDate(utc.year, utc.month, utc.day);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,18 @@ class _CardMetrics {
|
|||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
||||
|
||||
/// Returns the derived `pushButtonHeight` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get pushButtonHeight => (28 * scale).clamp(18.0, 24.0).toDouble();
|
||||
|
||||
/// Returns the derived `pushButtonWidth` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get pushButtonWidth => (62 * scale).clamp(42.0, 58.0).toDouble();
|
||||
|
||||
/// Returns the derived `pushButtonGap` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get pushButtonGap => math.max(5, 8 * scale);
|
||||
|
||||
/// Returns the derived `indicatorTop` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
|
||||
|
|
@ -158,10 +170,23 @@ class _CardMetrics {
|
|||
double get expandedTrailingReserve =>
|
||||
indicatorWidth + math.max(8, 12 * scale);
|
||||
|
||||
/// Returns the derived `expandedPushTrailingReserve` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get expandedPushTrailingReserve =>
|
||||
indicatorWidth +
|
||||
pushButtonGap +
|
||||
pushButtonWidth +
|
||||
math.max(8, 12 * scale);
|
||||
|
||||
/// Returns the derived `compactTrailingReserve` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale);
|
||||
|
||||
/// Returns the derived `compactPushTrailingReserve` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get compactPushTrailingReserve =>
|
||||
indicatorWidth + pushButtonGap + pushButtonWidth + math.max(6, 8 * scale);
|
||||
|
||||
/// Returns the derived `difficultySize` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
Size get difficultySize => Size(26, 14);
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget {
|
|||
style: metaStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: metrics.compactTrailingReserve),
|
||||
SizedBox(
|
||||
width: card.showPastTaskPushButton
|
||||
? metrics.compactPushTrailingReserve
|
||||
: metrics.compactTrailingReserve,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget {
|
|||
],
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve),
|
||||
padding: EdgeInsets.only(
|
||||
right: card.showPastTaskPushButton
|
||||
? metrics.expandedPushTrailingReserve
|
||||
: metrics.expandedTrailingReserve,
|
||||
),
|
||||
child: _CardText(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
bool _isHovered = false;
|
||||
|
||||
/// Private state stored as `_isPushRunning` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
bool _isPushRunning = false;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
colors: colors,
|
||||
metrics: metrics,
|
||||
actionsVisible: card.showsQuickActions && _isHovered,
|
||||
pushRunning: _isPushRunning,
|
||||
onPushToNext: widget.onPushToNext == null
|
||||
? null
|
||||
: () => _runPush(widget.onPushToNext!),
|
||||
onPushToTomorrow: widget.onPushToTomorrow == null
|
||||
? null
|
||||
: () => _runPush(widget.onPushToTomorrow!),
|
||||
onPushToBacklog: widget.onPushToBacklog == null
|
||||
? null
|
||||
: () => _runPush(widget.onPushToBacklog!),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -96,4 +110,26 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs the `_runPush` operation and completes after its asynchronous work finishes.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
Future<void> _runPush(
|
||||
Future<void> Function(TimelineCardModel card) callback,
|
||||
) async {
|
||||
if (_isPushRunning) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isPushRunning = true;
|
||||
});
|
||||
try {
|
||||
await callback(widget.card);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPushRunning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ class _TrailingControls extends StatelessWidget {
|
|||
required this.colors,
|
||||
required this.metrics,
|
||||
required this.actionsVisible,
|
||||
required this.pushRunning,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
});
|
||||
|
||||
/// Stores the `card` value for this object.
|
||||
|
|
@ -31,6 +35,19 @@ class _TrailingControls extends StatelessWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final bool actionsVisible;
|
||||
|
||||
/// Stores the `pushRunning` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final bool pushRunning;
|
||||
|
||||
/// Callback invoked when Push to next is selected.
|
||||
final Future<void> Function()? onPushToNext;
|
||||
|
||||
/// Callback invoked when Push to tomorrow is selected.
|
||||
final Future<void> Function()? onPushToTomorrow;
|
||||
|
||||
/// Callback invoked when Push to backlog is selected.
|
||||
final Future<void> Function()? onPushToBacklog;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -45,6 +62,19 @@ class _TrailingControls extends StatelessWidget {
|
|||
metrics: metrics,
|
||||
visible: actionsVisible,
|
||||
),
|
||||
if (card.showPastTaskPushButton) ...[
|
||||
_PastTaskPushButton(
|
||||
card: card,
|
||||
color: colors.accent,
|
||||
backgroundColor: colors.background,
|
||||
metrics: metrics,
|
||||
isRunning: pushRunning,
|
||||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
SizedBox(width: metrics.pushButtonGap),
|
||||
],
|
||||
RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
|
||||
SizedBox(width: metrics.indicatorGap),
|
||||
DifficultyBars(
|
||||
|
|
@ -56,3 +86,145 @@ class _TrailingControls extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu destinations exposed by the overdue-task Push button.
|
||||
enum _PastTaskPushDestination {
|
||||
/// Pushes the task to the next available slot.
|
||||
next,
|
||||
|
||||
/// Pushes the task to tomorrow's first available slot.
|
||||
tomorrow,
|
||||
|
||||
/// Pushes the task to Backlog.
|
||||
backlog,
|
||||
}
|
||||
|
||||
/// Private implementation type for `_PastTaskPushButton` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _PastTaskPushButton extends StatelessWidget {
|
||||
/// Creates a `_PastTaskPushButton` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const _PastTaskPushButton({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.backgroundColor,
|
||||
required this.metrics,
|
||||
required this.isRunning,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
});
|
||||
|
||||
/// Stores the `card` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final TimelineCardModel card;
|
||||
|
||||
/// Stores the `color` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Color color;
|
||||
|
||||
/// Stores the `backgroundColor` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Color backgroundColor;
|
||||
|
||||
/// Stores the `metrics` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final _CardMetrics metrics;
|
||||
|
||||
/// Stores the `isRunning` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final bool isRunning;
|
||||
|
||||
/// Callback invoked when Push to next is selected.
|
||||
final Future<void> Function()? onPushToNext;
|
||||
|
||||
/// Callback invoked when Push to tomorrow is selected.
|
||||
final Future<void> Function()? onPushToTomorrow;
|
||||
|
||||
/// Callback invoked when Push to backlog is selected.
|
||||
final Future<void> Function()? onPushToBacklog;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled =
|
||||
!isRunning &&
|
||||
(onPushToNext != null ||
|
||||
onPushToTomorrow != null ||
|
||||
onPushToBacklog != null);
|
||||
return PopupMenuButton<_PastTaskPushDestination>(
|
||||
key: ValueKey('${card.id}-push-button'),
|
||||
tooltip: card.pastTaskPushSemanticLabel,
|
||||
enabled: enabled,
|
||||
color: const Color(0xFF122018),
|
||||
elevation: 8,
|
||||
position: PopupMenuPosition.under,
|
||||
onSelected: (destination) {
|
||||
switch (destination) {
|
||||
case _PastTaskPushDestination.next:
|
||||
final callback = onPushToNext;
|
||||
if (callback != null) {
|
||||
unawaited(callback());
|
||||
}
|
||||
break;
|
||||
case _PastTaskPushDestination.tomorrow:
|
||||
final callback = onPushToTomorrow;
|
||||
if (callback != null) {
|
||||
unawaited(callback());
|
||||
}
|
||||
break;
|
||||
case _PastTaskPushDestination.backlog:
|
||||
final callback = onPushToBacklog;
|
||||
if (callback != null) {
|
||||
unawaited(callback());
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<_PastTaskPushDestination>(
|
||||
value: _PastTaskPushDestination.next,
|
||||
enabled: onPushToNext != null,
|
||||
child: const Text('Push to next'),
|
||||
),
|
||||
PopupMenuItem<_PastTaskPushDestination>(
|
||||
value: _PastTaskPushDestination.tomorrow,
|
||||
enabled: onPushToTomorrow != null,
|
||||
child: const Text('Push to tomorrow'),
|
||||
),
|
||||
PopupMenuItem<_PastTaskPushDestination>(
|
||||
value: _PastTaskPushDestination.backlog,
|
||||
enabled: onPushToBacklog != null,
|
||||
child: const Text('Push to backlog'),
|
||||
),
|
||||
],
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: card.pastTaskPushSemanticLabel,
|
||||
child: Container(
|
||||
width: metrics.pushButtonWidth,
|
||||
height: metrics.pushButtonHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withValues(alpha: 0.96),
|
||||
border: Border.all(color: color, width: 1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Push',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: (11 * metrics.scale).clamp(8.0, 11.0).toDouble(),
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -31,6 +32,9 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
required this.card,
|
||||
required this.onTap,
|
||||
this.onStatusPressed,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -43,6 +47,15 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
/// Callback invoked when the left status control is clicked.
|
||||
final VoidCallback? onStatusPressed;
|
||||
|
||||
/// Callback invoked when the card requests Push to next.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||
|
||||
/// Callback invoked when the card requests Push to tomorrow.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||
|
||||
/// Callback invoked when the card requests Push to backlog.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ class TimelineView extends StatefulWidget {
|
|||
required this.onCardSelected,
|
||||
this.onAddTask,
|
||||
this.onCardCompleted,
|
||||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.now,
|
||||
this.scrollTargetCardId,
|
||||
this.scrollRequest = 0,
|
||||
|
|
@ -47,6 +50,15 @@ class TimelineView extends StatefulWidget {
|
|||
/// Callback invoked when a task card status control requests completion.
|
||||
final ValueChanged<TimelineCardModel>? onCardCompleted;
|
||||
|
||||
/// Callback invoked when a task card requests Push to next.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||
|
||||
/// Callback invoked when a task card requests Push to tomorrow.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||
|
||||
/// Callback invoked when a task card requests Push to backlog.
|
||||
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||
|
||||
/// Clock used to center the initial scroll position.
|
||||
final DateTime Function()? now;
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
: () => widget.onCardCompleted!(
|
||||
placement.card,
|
||||
),
|
||||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
/// Tests Widget behavior in the FocusFlow Flutter app.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
|
@ -110,7 +112,7 @@ void main() {
|
|||
expect(find.text('Reward level 2'), findsOneWidget);
|
||||
expect(find.text('Medium effort'), findsOneWidget);
|
||||
expect(find.text('Done'), findsOneWidget);
|
||||
expect(find.text('Push'), findsOneWidget);
|
||||
expect(find.text('Push'), findsWidgets);
|
||||
expect(find.text('Move to Backlog'), findsOneWidget);
|
||||
expect(find.text('Break up'), findsOneWidget);
|
||||
|
||||
|
|
@ -546,6 +548,185 @@ void main() {
|
|||
await gesture.removePointer();
|
||||
});
|
||||
|
||||
testWidgets('past incomplete flexible card renders Push', (tester) async {
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'past-flexible',
|
||||
title: 'Past flexible task',
|
||||
showPastTaskPushButton: true,
|
||||
),
|
||||
size: const Size(360, 88),
|
||||
onPushToNext: (_) async {},
|
||||
);
|
||||
|
||||
expect(find.text('Push'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('past-flexible-push-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('completed and future cards do not render Push', (tester) async {
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'completed-flexible',
|
||||
title: 'Completed flexible task',
|
||||
isCompleted: true,
|
||||
showsQuickActions: false,
|
||||
),
|
||||
size: const Size(360, 88),
|
||||
);
|
||||
|
||||
expect(find.text('Push'), findsNothing);
|
||||
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(id: 'future-flexible', title: 'Future flexible task'),
|
||||
size: const Size(360, 88),
|
||||
);
|
||||
|
||||
expect(find.text('Push'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('past incomplete flexible card suppresses hover quick actions', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'past-no-hover',
|
||||
title: 'Past task',
|
||||
showPastTaskPushButton: true,
|
||||
),
|
||||
size: const Size(360, 88),
|
||||
onPushToNext: (_) async {},
|
||||
);
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await gesture.addPointer(
|
||||
location: tester.getCenter(find.byKey(const ValueKey('past-no-hover'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AnimatedOpacity), findsNothing);
|
||||
expect(find.byIcon(Icons.arrow_forward), findsNothing);
|
||||
|
||||
await gesture.removePointer();
|
||||
});
|
||||
|
||||
testWidgets('Push menu exposes destination labels and callbacks', (
|
||||
tester,
|
||||
) async {
|
||||
var nextCount = 0;
|
||||
var tomorrowCount = 0;
|
||||
var backlogCount = 0;
|
||||
|
||||
Future<void> openMenu() async {
|
||||
await tester.tap(find.byKey(const ValueKey('push-menu-push-button')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'push-menu',
|
||||
title: 'Past task',
|
||||
showPastTaskPushButton: true,
|
||||
),
|
||||
size: const Size(420, 88),
|
||||
onPushToNext: (_) async {
|
||||
nextCount += 1;
|
||||
},
|
||||
onPushToTomorrow: (_) async {
|
||||
tomorrowCount += 1;
|
||||
},
|
||||
onPushToBacklog: (_) async {
|
||||
backlogCount += 1;
|
||||
},
|
||||
);
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(find.text('Push to next'), findsOneWidget);
|
||||
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||
expect(find.text('Push to backlog'), findsOneWidget);
|
||||
expect(find.textContaining('Push to '), findsNWidgets(3));
|
||||
|
||||
await tester.tap(find.text('Push to next'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(nextCount, 1);
|
||||
|
||||
await openMenu();
|
||||
await tester.tap(find.text('Push to tomorrow'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tomorrowCount, 1);
|
||||
|
||||
await openMenu();
|
||||
await tester.tap(find.text('Push to backlog'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(backlogCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('Push menu dismisses on outside click without callback', (
|
||||
tester,
|
||||
) async {
|
||||
var count = 0;
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'push-dismiss',
|
||||
title: 'Past task',
|
||||
showPastTaskPushButton: true,
|
||||
),
|
||||
size: const Size(420, 88),
|
||||
onPushToNext: (_) async {
|
||||
count += 1;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('push-dismiss-push-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(Offset.zero);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Push to next'), findsNothing);
|
||||
expect(count, 0);
|
||||
});
|
||||
|
||||
testWidgets('Push selection is one shot while command is running', (
|
||||
tester,
|
||||
) async {
|
||||
final completer = Completer<void>();
|
||||
var count = 0;
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
card: _timelineCard(
|
||||
id: 'push-running',
|
||||
title: 'Past task',
|
||||
showPastTaskPushButton: true,
|
||||
),
|
||||
size: const Size(420, 88),
|
||||
onPushToNext: (_) async {
|
||||
count += 1;
|
||||
await completer.future;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('push-running-push-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Push to next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('push-running-push-button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(count, 1);
|
||||
|
||||
completer.complete();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('short task card keeps time inline with title', (tester) async {
|
||||
await _pumpTimelineCard(
|
||||
tester,
|
||||
|
|
@ -745,8 +926,13 @@ Future<void> _pumpTimelineCard(
|
|||
WidgetTester tester, {
|
||||
required TimelineCardModel card,
|
||||
required Size size,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToNext,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40));
|
||||
await tester.binding.setSurfaceSize(
|
||||
Size(size.width + 120, size.height + 220),
|
||||
);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
|
@ -759,6 +945,9 @@ Future<void> _pumpTimelineCard(
|
|||
card: card,
|
||||
onTap: () {},
|
||||
onStatusPressed: () {},
|
||||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -802,6 +991,8 @@ TimelineCardModel _timelineCard({
|
|||
TaskVisualKind visualKind = TaskVisualKind.flexible,
|
||||
TaskType? taskType,
|
||||
bool showsQuickActions = true,
|
||||
bool showPastTaskPushButton = false,
|
||||
bool isCompleted = false,
|
||||
String? completedTimeText,
|
||||
}) {
|
||||
final resolvedTaskType =
|
||||
|
|
@ -826,9 +1017,10 @@ TimelineCardModel _timelineCard({
|
|||
rewardIconToken: TimelineRewardIconToken.medium,
|
||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
||||
completedTimeText: completedTimeText,
|
||||
isCompleted: false,
|
||||
isCompleted: isCompleted,
|
||||
isSelectable: true,
|
||||
showsQuickActions: showsQuickActions,
|
||||
showsQuickActions: showPastTaskPushButton ? false : showsQuickActions,
|
||||
showPastTaskPushButton: showPastTaskPushButton,
|
||||
durationMinutes: endMinutes - startMinutes,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue