From 4d373ce250467c2ff24b2ba33eed42788b7f54d5 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 2 Jul 2026 12:39:05 -0700 Subject: [PATCH] feat: add past task push menu --- ...PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md | 12 +- ...HING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md | 13 +- .../lib/app/demo_scheduler_composition.dart | 1 + .../lib/app/home/focus_flow_home_state.dart | 30 +++ .../lib/app/home/today_screen_scaffold.dart | 15 ++ .../app/home/today_screen_scaffold_state.dart | 3 + .../scheduler_command_controller_impl.dart | 69 ++++++ .../timeline/task_card/card_metrics.dart | 25 +++ .../timeline/task_card/compact_card_text.dart | 6 +- .../task_card/expanded_card_content.dart | 6 +- .../task_card/task_timeline_card_state.dart | 36 ++++ .../timeline/task_card/trailing_controls.dart | 172 +++++++++++++++ .../widgets/timeline/task_timeline_card.dart | 13 ++ .../lib/widgets/timeline/timeline_view.dart | 12 ++ .../timeline_view/timeline_view_state.dart | 3 + apps/focus_flow_flutter/test/widget_test.dart | 200 +++++++++++++++++- 16 files changed, 608 insertions(+), 8 deletions(-) diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md index 77bd30c..dc00f48 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md +++ b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_03_PUSH_BUTTON_CARD_UI.md @@ -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 diff --git a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md index 9a9e717..1147940 100644 --- a/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md +++ b/Codex Documentation/Current Software Plan/Task Pushing 01 - Past Task Push Controls/TASK_PUSHING_01_BLOCK_04_PUSH_MENU_DESTINATIONS.md @@ -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 diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index efef249..60a5a51 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -145,6 +145,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { return TodayScreenController( selectedDates: selectedDates, dateLabelFor: dateLabelFor, + now: () => readAt, read: (date) { return todayQuery.execute( GetTodayStateRequest( diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 6e48d31..8b05692 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -66,6 +66,30 @@ class _FocusFlowHomeState extends State { await commandController.addGenericFlexibleTask(); } + /// Pushes [card] to the next available slot after the current clock time. + Future _pushCardToNext(TimelineCardModel card) async { + await commandController.pushTaskToNextAvailableSlot( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Pushes [card] to tomorrow's first available slot. + Future _pushCardToTomorrow(TimelineCardModel card) async { + await commandController.pushTaskToTomorrow( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Pushes [card] back to Backlog. + Future _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 { 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 { selectedCard: controller.selectedCard, onSelectCard: controller.selectCard, onCompleteCard: _toggleCardCompletion, + onPushToNext: _pushCardToNext, + onPushToTomorrow: _pushCardToTomorrow, + onPushToBacklog: _pushCardToBacklog, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, onPreviousDate: _selectPreviousDate, diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index 0c24d1a..856f967 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -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 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 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 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 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 Function() onAddTask; diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 5441351..5327ce5 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -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, ), ), ], diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 7579371..2632f93 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -192,6 +192,68 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Pushes a past flexible task to the next available slot after now. + Future 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 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 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 _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); + } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart index 1e3a927..af82fe0 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -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); diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart index e9a7f58..eed8bf3 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -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) { diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart index 4dede80..7b2f224 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -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, diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart index dac36c3..c445903 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -10,6 +10,10 @@ class _TaskTimelineCardState extends State { /// 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 { 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 { }, ); } + + /// 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 _runPush( + Future Function(TimelineCardModel card) callback, + ) async { + if (_isPushRunning) { + return; + } + setState(() { + _isPushRunning = true; + }); + try { + await callback(widget.card); + } finally { + if (mounted) { + setState(() { + _isPushRunning = false; + }); + } + } + } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart index 79c59d7..44933df 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -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 Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future 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 Function()? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function()? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future 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, + ), + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index e75a043..02c005d 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -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 Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when the card requests Push to tomorrow. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when the card requests Push to backlog. + final Future 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 diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index f8cb022..9e24fc4 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -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? onCardCompleted; + /// Callback invoked when a task card requests Push to next. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when a task card requests Push to tomorrow. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when a task card requests Push to backlog. + final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Clock used to center the initial scroll position. final DateTime Function()? now; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart index 83e87a5..fd47884 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -140,6 +140,9 @@ class _TimelineViewState extends State { : () => widget.onCardCompleted!( placement.card, ), + onPushToNext: widget.onPushToNext, + onPushToTomorrow: widget.onPushToTomorrow, + onPushToBacklog: widget.onPushToBacklog, ), ), ), diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index aa0aef9..333de8b 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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 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(); + 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 _pumpTimelineCard( WidgetTester tester, { required TimelineCardModel card, required Size size, + Future Function(TimelineCardModel card)? onPushToNext, + Future Function(TimelineCardModel card)? onPushToTomorrow, + Future 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 _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, ); }