feat: add past task push menu

This commit is contained in:
Ashley Venn 2026-07-02 12:39:05 -07:00
parent f3795e8be1
commit 4d373ce250
16 changed files with 608 additions and 8 deletions

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 03 — Push Button Card UI # 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. **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 button beside the reward/difficulty icons. The menu may still be stubbed until
Block 04. 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 ## Chunk 3.1 — Place the Push button in trailing controls

View file

@ -4,7 +4,7 @@
# Task Pushing 01 Block 04 — Push Menu Destinations # 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 **Goal:** Open a destination menu from the Push button and route each choice to a
callback. 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 The choices may call stubbed/controller callbacks until Block 05 wires real
commands. 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 ## Chunk 4.1 — Add destination menu UI

View file

@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
return TodayScreenController( return TodayScreenController(
selectedDates: selectedDates, selectedDates: selectedDates,
dateLabelFor: dateLabelFor, dateLabelFor: dateLabelFor,
now: () => readAt,
read: (date) { read: (date) {
return todayQuery.execute( return todayQuery.execute(
GetTodayStateRequest( GetTodayStateRequest(

View file

@ -66,6 +66,30 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
await commandController.addGenericFlexibleTask(); 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. /// Moves the visible planning date backward by one day.
void _selectPreviousDate() { void _selectPreviousDate() {
selectedDateController.selectPreviousDay(); selectedDateController.selectPreviousDay();
@ -114,6 +138,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion, onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,
@ -127,6 +154,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion, onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,

View file

@ -13,6 +13,9 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.selectedCard, required this.selectedCard,
required this.onSelectCard, required this.onSelectCard,
required this.onCompleteCard, required this.onCompleteCard,
required this.onPushToNext,
required this.onPushToTomorrow,
required this.onPushToBacklog,
required this.onAddTask, required this.onAddTask,
required this.onClearSelection, required this.onClearSelection,
required this.onPreviousDate, 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. /// 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; 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function() onAddTask; final Future<void> Function() onAddTask;

View file

@ -84,6 +84,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onCardSelected: widget.onSelectCard, onCardSelected: widget.onSelectCard,
onAddTask: widget.onAddTask, onAddTask: widget.onAddTask,
onCardCompleted: widget.onCompleteCard, onCardCompleted: widget.onCompleteCard,
onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
), ),
), ),
], ],

View file

@ -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. /// 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. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _run({ Future<void> _run({
@ -238,4 +300,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Runs the `_utcNow` helper used inside this library. /// 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. /// 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(); 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);
}
} }

View file

@ -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. /// 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; 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. /// 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. /// 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); double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale);
@ -158,10 +170,23 @@ class _CardMetrics {
double get expandedTrailingReserve => double get expandedTrailingReserve =>
indicatorWidth + math.max(8, 12 * scale); 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. /// 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. /// 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); 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. /// 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. /// 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); Size get difficultySize => Size(26, 14);

View file

@ -88,7 +88,11 @@ class _CompactCardText extends StatelessWidget {
style: metaStyle, style: metaStyle,
), ),
), ),
SizedBox(width: metrics.compactTrailingReserve), SizedBox(
width: card.showPastTaskPushButton
? metrics.compactPushTrailingReserve
: metrics.compactTrailingReserve,
),
], ],
); );
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {

View file

@ -48,7 +48,11 @@ class _ExpandedCardContent extends StatelessWidget {
], ],
Expanded( Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), padding: EdgeInsets.only(
right: card.showPastTaskPushButton
? metrics.expandedPushTrailingReserve
: metrics.expandedTrailingReserve,
),
child: _CardText( child: _CardText(
card: card, card: card,
color: colors.accent, color: colors.accent,

View file

@ -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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
bool _isHovered = false; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -48,6 +52,16 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
colors: colors, colors: colors,
metrics: metrics, metrics: metrics,
actionsVisible: card.showsQuickActions && _isHovered, 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;
});
}
}
}
} }

View file

@ -13,6 +13,10 @@ class _TrailingControls extends StatelessWidget {
required this.colors, required this.colors,
required this.metrics, required this.metrics,
required this.actionsVisible, required this.actionsVisible,
required this.pushRunning,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
}); });
/// Stores the `card` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool actionsVisible; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -45,6 +62,19 @@ class _TrailingControls extends StatelessWidget {
metrics: metrics, metrics: metrics,
visible: actionsVisible, 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), RewardIcon(color: colors.reward, size: metrics.rewardIconSize),
SizedBox(width: metrics.indicatorGap), SizedBox(width: metrics.indicatorGap),
DifficultyBars( 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,
),
),
),
),
);
}
}

View file

@ -4,6 +4,7 @@
/// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline. /// Renders Task Timeline Card pieces for the FocusFlow Flutter timeline.
library; library;
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -31,6 +32,9 @@ class TaskTimelineCard extends StatefulWidget {
required this.card, required this.card,
required this.onTap, required this.onTap,
this.onStatusPressed, this.onStatusPressed,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
super.key, super.key,
}); });
@ -43,6 +47,15 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the left status control is clicked. /// Callback invoked when the left status control is clicked.
final VoidCallback? onStatusPressed; 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. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -26,6 +26,9 @@ class TimelineView extends StatefulWidget {
required this.onCardSelected, required this.onCardSelected,
this.onAddTask, this.onAddTask,
this.onCardCompleted, this.onCardCompleted,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
@ -47,6 +50,15 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a task card status control requests completion. /// Callback invoked when a task card status control requests completion.
final ValueChanged<TimelineCardModel>? onCardCompleted; 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. /// Clock used to center the initial scroll position.
final DateTime Function()? now; final DateTime Function()? now;

View file

@ -140,6 +140,9 @@ class _TimelineViewState extends State<TimelineView> {
: () => widget.onCardCompleted!( : () => widget.onCardCompleted!(
placement.card, placement.card,
), ),
onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
), ),
), ),
), ),

View file

@ -4,6 +4,8 @@
/// Tests Widget behavior in the FocusFlow Flutter app. /// Tests Widget behavior in the FocusFlow Flutter app.
library; library;
import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -110,7 +112,7 @@ void main() {
expect(find.text('Reward level 2'), findsOneWidget); expect(find.text('Reward level 2'), findsOneWidget);
expect(find.text('Medium effort'), findsOneWidget); expect(find.text('Medium effort'), findsOneWidget);
expect(find.text('Done'), 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('Move to Backlog'), findsOneWidget);
expect(find.text('Break up'), findsOneWidget); expect(find.text('Break up'), findsOneWidget);
@ -546,6 +548,185 @@ void main() {
await gesture.removePointer(); 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 { testWidgets('short task card keeps time inline with title', (tester) async {
await _pumpTimelineCard( await _pumpTimelineCard(
tester, tester,
@ -745,8 +926,13 @@ Future<void> _pumpTimelineCard(
WidgetTester tester, { WidgetTester tester, {
required TimelineCardModel card, required TimelineCardModel card,
required Size size, required Size size,
Future<void> Function(TimelineCardModel card)? onPushToNext,
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
}) async { }) 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)); addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -759,6 +945,9 @@ Future<void> _pumpTimelineCard(
card: card, card: card,
onTap: () {}, onTap: () {},
onStatusPressed: () {}, onStatusPressed: () {},
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
), ),
), ),
), ),
@ -802,6 +991,8 @@ TimelineCardModel _timelineCard({
TaskVisualKind visualKind = TaskVisualKind.flexible, TaskVisualKind visualKind = TaskVisualKind.flexible,
TaskType? taskType, TaskType? taskType,
bool showsQuickActions = true, bool showsQuickActions = true,
bool showPastTaskPushButton = false,
bool isCompleted = false,
String? completedTimeText, String? completedTimeText,
}) { }) {
final resolvedTaskType = final resolvedTaskType =
@ -826,9 +1017,10 @@ TimelineCardModel _timelineCard({
rewardIconToken: TimelineRewardIconToken.medium, rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium,
completedTimeText: completedTimeText, completedTimeText: completedTimeText,
isCompleted: false, isCompleted: isCompleted,
isSelectable: true, isSelectable: true,
showsQuickActions: showsQuickActions, showsQuickActions: showPastTaskPushButton ? false : showsQuickActions,
showPastTaskPushButton: showPastTaskPushButton,
durationMinutes: endMinutes - startMinutes, durationMinutes: endMinutes - startMinutes,
); );
} }