From b156ddfd5e0247d1e11c06e04ac0b50662f94e05 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Sat, 4 Jul 2026 22:16:37 -0700 Subject: [PATCH] feat(ui): refine task modal actions and timeline navigation --- .../lib/app/home/focus_flow_home_state.dart | 12 + .../lib/app/home/today_screen_scaffold.dart | 5 + .../app/home/today_screen_scaffold_state.dart | 22 ++ .../lib/widgets/status_circle_button.dart | 125 +++++++ .../widgets/task_selection/action_button.dart | 326 +++++++++++++++++- .../lib/widgets/task_selection/info_tile.dart | 91 +++-- .../task_selection/modal_status_button.dart | 51 +-- .../lib/widgets/task_selection_modal.dart | 74 ++-- .../timeline/task_card/card_metrics.dart | 2 +- .../timeline/task_card/no_op_icon.dart | 83 ++++- .../timeline/task_card/quick_actions.dart | 39 ++- .../timeline/task_card/status_ring.dart | 51 +-- .../timeline/task_card/trailing_controls.dart | 137 +++++--- .../widgets/timeline/task_timeline_card.dart | 1 + .../lib/widgets/timeline/timeline_view.dart | 4 + .../timeline_view/timeline_view_state.dart | 73 +++- .../lib/widgets/top_bar.dart | 10 +- .../test/app/date_navigation_test.dart | 14 +- apps/focus_flow_flutter/test/widget_test.dart | 325 ++++++++++++++++- 19 files changed, 1206 insertions(+), 239 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/widgets/status_circle_button.dart 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 f42e373..15c23d8 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 @@ -107,6 +107,16 @@ class _FocusFlowHomeState extends State { ); } + /// Permanently removes [card] from persistence. + Future _removeCard(TimelineCardModel card) async { + logger.debug(() => 'Timeline remove requested. cardId=${card.id}'); + await commandController.removeTask( + taskId: card.id, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + controller.clearSelection(); + } + /// Moves the visible planning date backward by one day. void _selectPreviousDate() { logger.finer(() => 'Previous date action requested.'); @@ -165,6 +175,7 @@ class _FocusFlowHomeState extends State { onPushToNext: _pushCardToNext, onPushToTomorrow: _pushCardToTomorrow, onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, onAddTask: _addGenericFlexibleTask, onClearSelection: controller.clearSelection, onPreviousDate: _selectPreviousDate, @@ -181,6 +192,7 @@ class _FocusFlowHomeState extends State { onPushToNext: _pushCardToNext, onPushToTomorrow: _pushCardToTomorrow, onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, 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 856f967..a435cfd 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 @@ -16,6 +16,7 @@ class _TodayScreenScaffold extends StatefulWidget { required this.onPushToNext, required this.onPushToTomorrow, required this.onPushToBacklog, + required this.onRemoveCard, required this.onAddTask, required this.onClearSelection, required this.onPreviousDate, @@ -51,6 +52,10 @@ 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) onPushToBacklog; + /// Stores the `onRemoveCard` 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) onRemoveCard; + /// 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 5327ce5..86d5fb3 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 @@ -18,6 +18,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. var _timelineScrollRequest = 0; + /// Private state stored as `_timelineScrollToNowRequest` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _timelineScrollToNowRequest = 0; + /// Runs the `_showUpcomingRequiredTask` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _showUpcomingRequiredTask() { @@ -31,6 +35,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { }); } + /// Runs the `_scrollTimelineToCurrentTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _scrollTimelineToCurrentTime() { + setState(() { + _timelineScrollTargetCardId = null; + _timelineScrollToNowRequest += 1; + }); + } + /// Performs the `didUpdateWidget` behavior for this scheduler component. /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. @override @@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { } _timelineScrollTargetCardId = null; _timelineScrollRequest = 0; + _timelineScrollToNowRequest = 0; } /// Builds the widget subtree for this component. @@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPreviousDate: widget.onPreviousDate, onNextDate: widget.onNextDate, onDatePressed: widget.onChooseDate, + onDateLabelPressed: _scrollTimelineToCurrentTime, ), if (widget.data.requiredBanner == null) const SizedBox(height: 16) @@ -81,12 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { range: widget.data.timelineRange, scrollTargetCardId: _timelineScrollTargetCardId, scrollRequest: _timelineScrollRequest, + scrollToNowRequest: _timelineScrollToNowRequest, onCardSelected: widget.onSelectCard, onAddTask: widget.onAddTask, onCardCompleted: widget.onCompleteCard, onPushToNext: widget.onPushToNext, onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, + now: () => + DateTime.now().toUtc().add(widget.data.timeZoneOffset), ), ), ], @@ -112,6 +130,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onStatusPressed: widget.selectedCard!.canToggleCompletion ? () => widget.onCompleteCard(widget.selectedCard!) : null, + onPushToNext: widget.onPushToNext, + onPushToTomorrow: widget.onPushToTomorrow, + onPushToBacklog: widget.onPushToBacklog, + onRemove: widget.onRemoveCard, ), ), ], diff --git a/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart b/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart new file mode 100644 index 0000000..cd55ade --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/status_circle_button.dart @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Shared status-circle affordance for timeline and modal task controls. +library; + +import 'package:flutter/material.dart'; + +/// Circular completion control with shared hover feedback. +class StatusCircleButton extends StatefulWidget { + /// Creates a status circle button. + const StatusCircleButton({ + required this.color, + required this.checkColor, + required this.isCompleted, + required this.size, + required this.borderWidth, + required this.iconSize, + required this.semanticLabel, + this.onPressed, + this.circleKey, + this.hoverBlurRadius = 10, + super.key, + }); + + /// Key assigned to the animated circle surface. + final Key? circleKey; + + /// Accent color used for the border, fill, and hover glow. + final Color color; + + /// Color used for the completed check icon. + final Color checkColor; + + /// Whether the circle is in its completed state. + final bool isCompleted; + + /// Square size of the rendered circle. + final double size; + + /// Border width for the circle outline. + final double borderWidth; + + /// Size of the completed check icon. + final double iconSize; + + /// Semantic label used when the control is interactive. + final String semanticLabel; + + /// Callback invoked when the circle is tapped. + final VoidCallback? onPressed; + + /// Blur radius used for the hover shadow. + final double hoverBlurRadius; + + /// 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 + State createState() => _StatusCircleButtonState(); +} + +/// Mutable state for [StatusCircleButton]. +class _StatusCircleButtonState extends State { + /// Whether the pointer is hovering this status circle. + bool _isHovered = 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 + Widget build(BuildContext context) { + final ring = AnimatedContainer( + key: widget.circleKey, + width: widget.size, + height: widget.size, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isCompleted + ? widget.color + : _isHovered + ? widget.color.withValues(alpha: 0.12) + : Colors.transparent, + border: Border.all(color: widget.color, width: widget.borderWidth), + boxShadow: _isHovered && widget.onPressed != null + ? [ + BoxShadow( + color: widget.color.withValues(alpha: 0.38), + blurRadius: widget.hoverBlurRadius, + spreadRadius: 1, + ), + ] + : const [], + ), + child: widget.isCompleted + ? Icon(Icons.check, color: widget.checkColor, size: widget.iconSize) + : null, + ); + if (widget.onPressed == null) { + return ring; + } + return Semantics( + button: true, + label: widget.semanticLabel, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() { + _isHovered = true; + }); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: ring, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart index b2562a3..61df4b5 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -3,12 +3,24 @@ part of '../task_selection_modal.dart'; +/// Menu destinations exposed by the modal Push button. +enum _ModalPushDestination { + /// 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 `_ActionButton` 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 _ActionButton extends StatelessWidget { +class _ActionButton extends StatefulWidget { /// Creates a `_ActionButton` 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 _ActionButton({required this.icon, required this.label}); + const _ActionButton({required this.icon, required this.label, this.onTap}); /// Stores the `icon` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. @@ -18,27 +30,307 @@ class _ActionButton extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final String label; + /// Callback invoked when the action is tapped. + final VoidCallback? onTap; + + /// 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 + State<_ActionButton> createState() => _ActionButtonState(); +} + +/// Private implementation type for `_ActionButtonState` in this library. +/// It owns hover feedback for compact modal action buttons. +class _ActionButtonState extends State<_ActionButton> { + /// Whether the pointer is hovering this action button. + bool _isHovered = 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 Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + return _ModalActionSurface( + icon: widget.icon, + label: widget.label, + isHovered: _isHovered, + onHoverChanged: (value) { + setState(() { + _isHovered = value; + }); + }, + onTap: widget.onTap ?? () {}, + ); + } +} + +/// Private implementation type for `_PushActionButton` in this library. +/// It exposes push destinations from the clicked-task modal. +class _PushActionButton extends StatefulWidget { + /// Creates a `_PushActionButton` 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 _PushActionButton({ + required this.card, + 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; + + /// Callback invoked when Push to next is selected. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + 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 + State<_PushActionButton> createState() => _PushActionButtonState(); +} + +/// Private implementation type for `_PushActionButtonState` in this library. +/// It owns hover and running state for modal push destinations. +class _PushActionButtonState extends State<_PushActionButton> { + /// Whether the pointer is hovering this action button. + bool _isHovered = false; + + /// Whether a push destination command is in flight. + bool _isRunning = 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 + Widget build(BuildContext context) { + final options = _modalPushDestinationOptions(); + final enabled = + !_isRunning && options.any((option) => option.callback != null); + return PopupMenuButton<_ModalPushDestination>( + key: ValueKey('${widget.card.id}-modal-push-button'), + tooltip: '', + enabled: enabled, + color: const Color(0xFF122018), + elevation: 8, + position: PopupMenuPosition.under, + onSelected: (destination) { + for (final option in options) { + if (option.destination != destination) { + continue; + } + final callback = option.callback; + if (callback != null) { + unawaited(_runPush(callback)); + } + return; + } + }, + itemBuilder: (context) => [ + for (final option in options) + PopupMenuItem<_ModalPushDestination>( + value: option.destination, + enabled: option.callback != null, + child: Text(option.label), ), - ), + ], + child: _ModalActionSurface( + icon: Icons.arrow_forward, + label: 'Push', + isHovered: _isHovered, + isEnabled: enabled, + onHoverChanged: (value) { + setState(() { + _isHovered = value; + }); + }, ), - icon: Icon(icon), - label: Text( - label, - style: const TextStyle( - fontSize: FocusFlowTokens.modalButtonSize, - fontWeight: FontWeight.w700, + ); + } + + /// Builds the push destination option list for this button. + List<_ModalPushDestinationOption> _modalPushDestinationOptions() { + return [ + _ModalPushDestinationOption( + destination: _ModalPushDestination.next, + label: 'Push to next', + callback: widget.onPushToNext == null + ? null + : () => widget.onPushToNext!(widget.card), + ), + _ModalPushDestinationOption( + destination: _ModalPushDestination.tomorrow, + label: 'Push to tomorrow', + callback: widget.onPushToTomorrow == null + ? null + : () => widget.onPushToTomorrow!(widget.card), + ), + _ModalPushDestinationOption( + destination: _ModalPushDestination.backlog, + label: 'Push to backlog', + callback: widget.onPushToBacklog == null + ? null + : () => widget.onPushToBacklog!(widget.card), + ), + ]; + } + + /// Runs [callback] while guarding against duplicate push commands. + Future _runPush(Future Function() callback) async { + if (_isRunning) { + return; + } + setState(() { + _isRunning = true; + }); + try { + await callback(); + } finally { + if (mounted) { + setState(() { + _isRunning = false; + }); + } + } + } +} + +/// Private implementation type for `_ModalPushDestinationOption` in this library. +/// It stores one modal push menu destination. +class _ModalPushDestinationOption { + /// Creates a `_ModalPushDestinationOption` 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 _ModalPushDestinationOption({ + required this.destination, + required this.label, + required this.callback, + }); + + /// Stores the `destination` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _ModalPushDestination destination; + + /// Stores the `label` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String label; + + /// Callback invoked when this option is selected. + final Future Function()? callback; +} + +/// Private implementation type for `_ModalActionSurface` in this library. +/// It draws compact modal action button chrome shared by push and placeholder actions. +class _ModalActionSurface extends StatelessWidget { + /// Creates a `_ModalActionSurface` 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 _ModalActionSurface({ + required this.icon, + required this.label, + required this.isHovered, + required this.onHoverChanged, + this.isEnabled = true, + this.onTap, + }); + + /// Stores the `icon` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final IconData icon; + + /// Stores the `label` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String label; + + /// Stores the `isHovered` 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 isHovered; + + /// Stores the `isEnabled` 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 isEnabled; + + /// Callback invoked when the hover state changes. + final ValueChanged onHoverChanged; + + /// Callback invoked when the action surface is tapped. + final VoidCallback? onTap; + + /// 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 foreground = isEnabled + ? FocusFlowTokens.textPrimary + : FocusFlowTokens.textMuted; + final hoverColor = FocusFlowTokens.textPrimary.withValues(alpha: 0.08); + return Semantics( + button: true, + enabled: isEnabled, + label: label, + child: MouseRegion( + cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, + onEnter: (_) => onHoverChanged(true), + onExit: (_) => onHoverChanged(false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: isEnabled ? onTap : null, + child: AnimatedContainer( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 14), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: isHovered && isEnabled + ? hoverColor + : FocusFlowTokens.glassPanelStrong.withValues(alpha: 0.26), + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + border: Border.all( + color: isHovered && isEnabled + ? FocusFlowTokens.textPrimary.withValues(alpha: 0.22) + : FocusFlowTokens.subtleBorder.withValues(alpha: 0.72), + ), + boxShadow: isHovered && isEnabled + ? [ + BoxShadow( + color: FocusFlowTokens.textPrimary.withValues( + alpha: 0.12, + ), + blurRadius: 12, + spreadRadius: 1, + ), + ] + : const [], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + width: 28, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(icon, color: foreground, size: 18), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), ), ), ); diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart index 84ac17d..73328f4 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -3,50 +3,71 @@ part of '../task_selection_modal.dart'; -/// Private implementation type for `_InfoTile` 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 _InfoTile extends StatelessWidget { - /// Creates a `_InfoTile` instance with the values required by its invariants. +/// Private implementation type for `_HeaderMetricIcons` in this library. +/// It keeps task effort and reward metadata near the modal close action. +class _HeaderMetricIcons extends StatelessWidget { + /// Creates a `_HeaderMetricIcons` 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 _InfoTile({required this.child, required this.label}); + const _HeaderMetricIcons({required this.card}); - /// Stores the `child` value for this object. + /// 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 Widget child; - - /// Stores the `label` value for this object. - /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final String label; + final TimelineCardModel card; /// 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) { - return Container( - height: 58, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: [ - child, - const SizedBox(width: 18), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _HeaderMetricIcon( + tooltip: card.effortLabel == 'Effort not set' + ? 'Not Set' + : card.effortLabel, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: FocusFlowTokens.restPurple, + size: const Size(28, 16), ), - ], - ), + ), + const SizedBox(height: 8), + _HeaderMetricIcon( + tooltip: card.rewardLabel == 'Reward not set' + ? 'Not Set' + : card.rewardLabel, + child: const RewardIcon( + color: FocusFlowTokens.accentMagenta, + size: 18, + ), + ), + ], + ); + } +} + +/// Private implementation type for `_HeaderMetricIcon` in this library. +/// It keeps metric tooltips scoped to icon-only modal affordances. +class _HeaderMetricIcon extends StatelessWidget { + /// Creates a `_HeaderMetricIcon` 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 _HeaderMetricIcon({required this.tooltip, required this.child}); + + /// Stores the `tooltip` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final String tooltip; + + /// Stores the `child` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Widget child; + + /// 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) { + return Tooltip( + message: tooltip, + child: SizedBox.square(dimension: 28, child: Center(child: child)), ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart index aed36b8..05383de 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -4,7 +4,7 @@ part of '../task_selection_modal.dart'; /// Private implementation type for `_ModalStatusButton` 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. +/// It adapts modal sizing and labels to the shared status-circle button. class _ModalStatusButton extends StatelessWidget { /// Creates a `_ModalStatusButton` 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. @@ -30,42 +30,19 @@ class _ModalStatusButton extends StatelessWidget { /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - final ring = Container( - key: const ValueKey('task-modal-status'), - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: Border.all(color: color, width: 4), - ), - child: card.isCompleted - ? const Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: 26, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), + return StatusCircleButton( + circleKey: const ValueKey('task-modal-status'), + color: color, + checkColor: FocusFlowTokens.appBackground, + isCompleted: card.isCompleted, + size: 44, + borderWidth: 4, + iconSize: 26, + hoverBlurRadius: 12, + semanticLabel: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + onPressed: onPressed, ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index e2e7f5c..18c29fc 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -4,10 +4,13 @@ /// Renders the Task Selection Modal widget for the FocusFlow Flutter UI. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_tokens.dart'; +import 'status_circle_button.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; @@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget { required this.card, required this.onClose, this.onStatusPressed, + this.onPushToNext, + this.onPushToTomorrow, + this.onPushToBacklog, + this.onRemove, super.key, }); @@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget { /// Callback invoked when the status circle should toggle completion. final VoidCallback? onStatusPressed; + /// Callback invoked when Push to next is selected. + final Future Function(TimelineCardModel card)? onPushToNext; + + /// Callback invoked when Push to tomorrow is selected. + final Future Function(TimelineCardModel card)? onPushToTomorrow; + + /// Callback invoked when Push to backlog is selected. + final Future Function(TimelineCardModel card)? onPushToBacklog; + + /// Callback invoked when Remove is selected. + final Future Function(TimelineCardModel card)? onRemove; + /// 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 @@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget { ], ), ), + const SizedBox(width: 14), + _HeaderMetricIcons(card: card), + const SizedBox(width: 14), IconButton( key: const ValueKey('close-task-modal'), onPressed: onClose, @@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget { ), ], ), - const SizedBox(height: 26), - Row( - children: [ - Expanded( - child: _InfoTile( - label: card.rewardLabel, - child: RewardIcon(color: FocusFlowTokens.accentMagenta), - ), - ), - const SizedBox(width: 28), - Expanded( - child: _InfoTile( - label: card.effortLabel, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: FocusFlowTokens.restPurple, - ), - ), - ), - ], - ), - const SizedBox(height: 30), + const SizedBox(height: 24), GridView.count( crossAxisCount: 2, - crossAxisSpacing: 26, - mainAxisSpacing: 16, - childAspectRatio: 4.1, + crossAxisSpacing: 18, + mainAxisSpacing: 12, + childAspectRatio: 5.3, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - children: const [ - _ActionButton(icon: Icons.check, label: 'Done'), - _ActionButton(icon: Icons.arrow_forward, label: 'Push'), + children: [ + _PushActionButton( + card: card, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ), _ActionButton( icon: Icons.inventory_2_outlined, - label: 'Move to Backlog', + label: 'Backlog', + ), + const _ActionButton(icon: Icons.apps, label: 'Break up'), + _ActionButton( + icon: Icons.delete_outline, + label: 'Remove', + onTap: onRemove == null + ? null + : () { + unawaited(onRemove!(card)); + }, ), - _ActionButton(icon: Icons.apps, label: 'Break up'), ], ), const SizedBox(height: 14), 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 af82fe0..9615d88 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 @@ -114,7 +114,7 @@ class _CardMetrics { /// Returns the derived `actionsWidth` 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 actionsWidth => actionButtonSize * 4 + actionGap; + double get actionsWidth => actionButtonSize * 3 + 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. diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart index 0ad674e..4be5113 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -10,6 +10,7 @@ class _NoOpIcon extends StatelessWidget { /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. const _NoOpIcon({ required this.icon, + required this.tooltip, required this.color, required this.metrics, }); @@ -18,6 +19,9 @@ class _NoOpIcon extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final IconData icon; + /// Tooltip message for this inactive icon affordance. + final String tooltip; + /// 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; @@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Tooltip( - message: 'No-op action', + message: tooltip, child: GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, @@ -43,3 +47,80 @@ class _NoOpIcon extends StatelessWidget { ); } } + +/// Private implementation type for `_QuickPushIcon` in this library. +/// It keeps the timeline hover push action visually compact while reusing the same destination menu as the full overdue Push button. +class _QuickPushIcon extends StatelessWidget { + /// Creates a `_QuickPushIcon` 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 _QuickPushIcon({ + required this.card, + required this.color, + 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 `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 options = _pushDestinationOptions( + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ); + final enabled = !isRunning && _hasEnabledPushDestination(options); + return PopupMenuButton<_PastTaskPushDestination>( + key: ValueKey('${card.id}-quick-push-button'), + tooltip: 'Push task', + enabled: enabled, + color: const Color(0xFF122018), + elevation: 8, + position: PopupMenuPosition.under, + padding: EdgeInsets.zero, + onSelected: (destination) => _selectPushDestination(destination, options), + itemBuilder: (context) => _pushDestinationMenuItems(options), + child: Semantics( + button: true, + enabled: enabled, + label: 'Push ${card.title}', + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon( + Icons.arrow_forward, + color: color, + size: metrics.actionIconSize, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart index 1f09d0f..059f05b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget { /// Creates a `_QuickActions` 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 _QuickActions({ + required this.card, required this.color, required this.backgroundColor, required this.metrics, required this.visible, + required this.pushRunning, + 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; @@ -31,6 +40,19 @@ class _QuickActions extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final bool visible; + /// 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 @@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget { ignoring: !visible, child: ClipRect( child: AnimatedContainer( + key: ValueKey('${card.id}-quick-actions-background'), width: visible ? metrics.actionsWidth : 0, height: metrics.actionButtonSize, decoration: BoxDecoration(color: backgroundColor), @@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget { maxWidth: metrics.actionsWidth, alignment: Alignment.centerRight, child: AnimatedOpacity( + key: ValueKey('${card.id}-quick-actions-opacity'), opacity: visible ? 1 : 0, duration: const Duration(milliseconds: 90), curve: Curves.easeOut, child: Row( mainAxisSize: MainAxisSize.min, children: [ - _NoOpIcon( - icon: Icons.check, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.arrow_forward, + _QuickPushIcon( + card: card, color: color, metrics: metrics, + isRunning: pushRunning, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, ), _NoOpIcon( icon: Icons.calendar_today, + tooltip: 'Schedule', color: color, metrics: metrics, ), _NoOpIcon( icon: Icons.wb_sunny_outlined, + tooltip: 'Protect time', color: color, metrics: metrics, ), diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart index 97f0497..4db1677 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -4,7 +4,7 @@ part of '../task_timeline_card.dart'; /// Private implementation type for `_StatusRing` 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. +/// It adapts timeline card sizing and labels to the shared status-circle button. class _StatusRing extends StatelessWidget { /// Creates a `_StatusRing` 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. @@ -35,43 +35,18 @@ class _StatusRing extends StatelessWidget { /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - final border = Border.all(color: color, width: metrics.statusBorderWidth); - final ring = Container( - key: ValueKey('${card.id}-status'), - width: metrics.statusRingSize, - height: metrics.statusRingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: border, - ), - child: card.isCompleted - ? Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: metrics.statusIconSize, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), + return StatusCircleButton( + circleKey: ValueKey('${card.id}-status'), + color: color, + checkColor: FocusFlowTokens.appBackground, + isCompleted: card.isCompleted, + size: metrics.statusRingSize, + borderWidth: metrics.statusBorderWidth, + iconSize: metrics.statusIconSize, + semanticLabel: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + onPressed: onPressed, ); } } 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 44933df..ba78fd4 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 @@ -57,10 +57,15 @@ class _TrailingControls extends StatelessWidget { children: [ if (card.showsQuickActions) _QuickActions( + card: card, color: colors.accent, backgroundColor: colors.background.withValues(alpha: 1), metrics: metrics, visible: actionsVisible, + pushRunning: pushRunning, + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, ), if (card.showPastTaskPushButton) ...[ _PastTaskPushButton( @@ -99,6 +104,86 @@ enum _PastTaskPushDestination { backlog, } +/// Menu item model for push destination options. +class _PushDestinationOption { + /// Creates one push destination menu option. + const _PushDestinationOption({ + required this.destination, + required this.label, + required this.callback, + }); + + /// Destination selected by this option. + final _PastTaskPushDestination destination; + + /// Visible label for this option. + final String label; + + /// Callback invoked when this option is selected. + final Future Function()? callback; +} + +/// Runs [callback] for [destination] when that destination has a callback. +void _selectPushDestination( + _PastTaskPushDestination destination, + List<_PushDestinationOption> options, +) { + for (final option in options) { + if (option.destination != destination) { + continue; + } + final callback = option.callback; + if (callback != null) { + unawaited(callback()); + } + return; + } +} + +/// Builds push destination menu options. +List<_PushDestinationOption> _pushDestinationOptions({ + required Future Function()? onPushToNext, + required Future Function()? onPushToTomorrow, + required Future Function()? onPushToBacklog, +}) { + return [ + _PushDestinationOption( + destination: _PastTaskPushDestination.next, + label: 'Push to next', + callback: onPushToNext, + ), + _PushDestinationOption( + destination: _PastTaskPushDestination.tomorrow, + label: 'Push to tomorrow', + callback: onPushToTomorrow, + ), + _PushDestinationOption( + destination: _PastTaskPushDestination.backlog, + label: 'Push to backlog', + callback: onPushToBacklog, + ), + ]; +} + +/// Whether any push destination option has an active callback. +bool _hasEnabledPushDestination(List<_PushDestinationOption> options) { + return options.any((option) => option.callback != null); +} + +/// Builds popup menu entries for push destination options. +List> _pushDestinationMenuItems( + List<_PushDestinationOption> options, +) { + return [ + for (final option in options) + PopupMenuItem<_PastTaskPushDestination>( + value: option.destination, + enabled: option.callback != null, + child: Text(option.label), + ), + ]; +} + /// 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 { @@ -148,11 +233,12 @@ class _PastTaskPushButton extends StatelessWidget { /// 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); + final options = _pushDestinationOptions( + onPushToNext: onPushToNext, + onPushToTomorrow: onPushToTomorrow, + onPushToBacklog: onPushToBacklog, + ); + final enabled = !isRunning && _hasEnabledPushDestination(options); return PopupMenuButton<_PastTaskPushDestination>( key: ValueKey('${card.id}-push-button'), tooltip: card.pastTaskPushSemanticLabel, @@ -160,45 +246,8 @@ class _PastTaskPushButton extends StatelessWidget { 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'), - ), - ], + onSelected: (destination) => _selectPushDestination(destination, options), + itemBuilder: (context) => _pushDestinationMenuItems(options), child: Semantics( button: true, enabled: enabled, 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 e4cb781..937d1d2 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 @@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart' show logger; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; +import '../status_circle_button.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; 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 9e24fc4..d9d3fb3 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -32,6 +32,7 @@ class TimelineView extends StatefulWidget { this.now, this.scrollTargetCardId, this.scrollRequest = 0, + this.scrollToNowRequest = 0, super.key, }); @@ -68,6 +69,9 @@ class TimelineView extends StatefulWidget { /// Monotonic request number that allows repeated jumps to the same card. final int scrollRequest; + /// Monotonic request number that allows repeated jumps to current time. + final int scrollToNowRequest; + /// 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/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart index fd47884..b850032 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 @@ -28,6 +28,10 @@ class _TimelineViewState extends State { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. var _handledScrollRequest = 0; + /// Private state stored as `_handledScrollToNowRequest` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _handledScrollToNowRequest = 0; + /// Private state stored as `_geometry` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. late TimelineGeometry _geometry; @@ -86,6 +90,7 @@ class _TimelineViewState extends State { return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(_geometry, constraints.maxHeight); + _scheduleCurrentTimeScroll(_geometry, constraints.maxHeight); _scheduleTargetScroll(_geometry); return ScrollConfiguration( behavior: const _TimelineScrollBehavior(), @@ -237,21 +242,31 @@ class _TimelineViewState extends State { return; } final now = widget.now?.call() ?? DateTime.now(); - final currentMinute = now.hour * 60 + now.minute; - final clampedMinute = currentMinute - .clamp(geometry.startMinutes, geometry.endMinutes) - .toInt(); - final centeredOffset = - geometry.yForMinutesSinceMidnight(clampedMinute) + - _topInset - - viewportHeight / 2; - final maxOffset = _scrollController.position.maxScrollExtent; - final offset = centeredOffset.clamp(0, maxOffset).toDouble(); - _scrollController.jumpTo(offset); + _jumpToCurrentTime(geometry, viewportHeight, now); _didSetInitialScroll = true; }); } + /// Runs the `_scheduleCurrentTimeScroll` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _scheduleCurrentTimeScroll( + TimelineGeometry geometry, + double viewportHeight, + ) { + if (widget.scrollToNowRequest == _handledScrollToNowRequest || + !viewportHeight.isFinite) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + final now = widget.now?.call() ?? DateTime.now(); + _jumpToCurrentTime(geometry, viewportHeight, now); + _handledScrollToNowRequest = widget.scrollToNowRequest; + }); + } + /// Runs the `_scheduleTargetScroll` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _scheduleTargetScroll(TimelineGeometry geometry) { @@ -284,4 +299,40 @@ class _TimelineViewState extends State { _handledScrollRequest = widget.scrollRequest; }); } + + /// Runs the `_jumpToCurrentTime` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _jumpToCurrentTime( + TimelineGeometry geometry, + double viewportHeight, + DateTime now, + ) { + final currentMinute = now.hour * 60 + now.minute; + final clampedMinute = currentMinute + .clamp(geometry.startMinutes, geometry.endMinutes) + .toInt(); + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = _scrollOffsetForMinute( + geometry: geometry, + minute: clampedMinute, + viewportHeight: viewportHeight, + maxOffset: maxOffset, + ); + _scrollController.jumpTo(offset); + } + + /// Runs the `_scrollOffsetForMinute` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + double _scrollOffsetForMinute({ + required TimelineGeometry geometry, + required int minute, + required double viewportHeight, + required double maxOffset, + }) { + final targetOffset = + geometry.yForMinutesSinceMidnight(minute) + + _topInset - + viewportHeight * 0.25; + return targetOffset.clamp(0, maxOffset).toDouble(); + } } diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index bca659c..e5f3acd 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -21,6 +21,7 @@ class TopBar extends StatelessWidget { this.onPreviousDate, this.onNextDate, this.onDatePressed, + this.onDateLabelPressed, super.key, }); @@ -36,6 +37,9 @@ class TopBar extends StatelessWidget { /// Callback invoked when the choose-date control is activated. final VoidCallback? onDatePressed; + /// Callback invoked when the displayed date label is activated. + final VoidCallback? onDateLabelPressed; + /// 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 @@ -78,13 +82,13 @@ class TopBar extends StatelessWidget { width: metrics.dateWidth, height: metrics.controlHeight, child: Tooltip( - message: 'Choose date', + message: 'Scroll to current time', child: Semantics( button: true, - label: 'Choose date, $dateLabel', + label: 'Scroll to current time, $dateLabel', child: TextButton( key: const ValueKey('top-bar-date-button'), - onPressed: onDatePressed, + onPressed: onDateLabelPressed, style: TextButton.styleFrom( foregroundColor: FocusFlowTokens.textPrimary, padding: EdgeInsets.symmetric( diff --git a/apps/focus_flow_flutter/test/app/date_navigation_test.dart b/apps/focus_flow_flutter/test/app/date_navigation_test.dart index 9038355..1d8845c 100644 --- a/apps/focus_flow_flutter/test/app/date_navigation_test.dart +++ b/apps/focus_flow_flutter/test/app/date_navigation_test.dart @@ -35,7 +35,7 @@ void main() { testWidgets('date picker jumps directly to the chosen date', (tester) async { await _pumpPlanApp(tester); - await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.tap(find.byTooltip('Choose date')); await tester.pumpAndSettle(); expect(find.byType(DatePickerDialog), findsOneWidget); @@ -49,6 +49,18 @@ void main() { expect(find.text('Clean coffee maker'), findsNothing); }); + testWidgets('date label scrolls timeline instead of opening picker', ( + tester, + ) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); + await tester.pumpAndSettle(); + + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('May 20, 2025'), findsOneWidget); + }); + testWidgets('open modal closes when changing dates', (tester) async { await _pumpPlanApp(tester); diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 333de8b..3f53e5d 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -16,6 +16,7 @@ import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; import 'package:focus_flow_flutter/widgets/required_banner.dart'; +import 'package:focus_flow_flutter/widgets/task_selection_modal.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; @@ -95,7 +96,7 @@ void main() { expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); }); - testWidgets('task modal opens closes and keeps action buttons inert', ( + testWidgets('task modal opens closes and keeps compact actions inert', ( tester, ) async { await _pumpPlanApp(tester); @@ -109,14 +110,18 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.textContaining('Required'), findsWidgets); - expect(find.text('Reward level 2'), findsOneWidget); - expect(find.text('Medium effort'), findsOneWidget); - expect(find.text('Done'), findsOneWidget); + expect(find.byTooltip('Reward level 2'), findsOneWidget); + expect(find.byTooltip('Medium effort'), findsOneWidget); + expect(find.byTooltip('Mark complete'), findsNothing); + expect(find.text('Reward level 2'), findsNothing); + expect(find.text('Medium effort'), findsNothing); + expect(find.text('Done'), findsNothing); expect(find.text('Push'), findsWidgets); - expect(find.text('Move to Backlog'), findsOneWidget); + expect(find.text('Move to Backlog'), findsNothing); + expect(find.text('Backlog'), findsWidgets); expect(find.text('Break up'), findsOneWidget); - await tester.tap(find.text('Done')); + await tester.tap(find.text('Break up')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); @@ -137,6 +142,91 @@ void main() { expect(find.text('Pay bill'), findsOneWidget); }); + testWidgets('task modal Push 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('modal-push-modal-push-button')), + ); + await tester.pumpAndSettle(); + } + + await _pumpConstrainedWidget( + tester, + size: const Size(640, 380), + child: TaskSelectionModal( + card: _timelineCard( + id: 'modal-push', + title: 'Modal push task', + rewardIconToken: TimelineRewardIconToken.notSet, + difficultyIconToken: TimelineDifficultyIconToken.notSet, + ), + onClose: () {}, + onPushToNext: (_) async { + nextCount += 1; + }, + onPushToTomorrow: (_) async { + tomorrowCount += 1; + }, + onPushToBacklog: (_) async { + backlogCount += 1; + }, + ), + ); + + expect(find.byTooltip('Not Set'), findsNWidgets(2)); + expect(find.byTooltip('Push task'), findsNothing); + expect(find.text('Remove'), findsOneWidget); + + await openMenu(); + expect(find.text('Push to next'), findsOneWidget); + expect(find.text('Push to tomorrow'), findsOneWidget); + expect(find.text('Push to backlog'), findsOneWidget); + + 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('task modal Remove deletes task and closes modal', ( + tester, + ) async { + final composition = _composition(); + await _pumpPlanApp(tester, composition: composition); + + await _scrollIntoView(tester, const ValueKey('water-plants')); + await tester.tap(find.byKey(const ValueKey('water-plants'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Water plants'), findsWidgets); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.text('Water plants'), findsNothing); + expect( + composition.store.currentTasks.any((task) => task.id == 'water-plants'), + isFalse, + ); + }); + test('Today screen timeline range covers the full day', () { expect(TodayScreenData.defaultRange.startMinutes, 0); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); @@ -198,7 +288,7 @@ void main() { }); testWidgets( - 'timeline defaults near the current time while keeping full day', + 'timeline defaults with current time one quarter down the viewport', (tester) async { await _pumpConstrainedWidget( tester, @@ -218,10 +308,48 @@ void main() { final scrollable = tester.state(find.byType(Scrollable)); final position = scrollable.position; expect(position.maxScrollExtent, greaterThan(3000)); - expect(position.pixels, closeTo(1576, 1)); + expect(position.pixels, closeTo(1676, 1)); }, ); + testWidgets('timeline scroll-to-now request reuses initial scroll math', ( + tester, + ) async { + var request = 0; + var currentTime = DateTime(2026, 6, 30, 12); + late StateSetter updateTimeline; + + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: StatefulBuilder( + builder: (context, setState) { + updateTimeline = setState; + return TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => currentTime, + scrollToNowRequest: request, + onCardSelected: (_) {}, + ); + }, + ), + ); + + final scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.position.pixels, closeTo(1676, 1)); + + scrollable.position.jumpTo(0); + await tester.pump(); + updateTimeline(() { + currentTime = DateTime(2026, 6, 30, 18); + request += 1; + }); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, closeTo(2558, 1)); + }); + testWidgets('timeline hour labels stay on one line', (tester) async { await _pumpConstrainedWidget( tester, @@ -430,6 +558,7 @@ void main() { var previousCount = 0; var nextCount = 0; var datePressedCount = 0; + var dateLabelPressedCount = 0; await _pumpConstrainedWidget( tester, @@ -445,18 +574,24 @@ void main() { onDatePressed: () { datePressedCount += 1; }, + onDateLabelPressed: () { + dateLabelPressedCount += 1; + }, ), ); await tester.tap(find.byTooltip('Previous day')); await tester.tap(find.byTooltip('Next day')); + await tester.tap(find.byTooltip('Choose date')); await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); await tester.pumpAndSettle(); expect(previousCount, 1); expect(nextCount, 1); expect(datePressedCount, 1); - expect(find.byTooltip('Choose date'), findsWidgets); + expect(dateLabelPressedCount, 1); + expect(find.byTooltip('Choose date'), findsOneWidget); + expect(find.byTooltip('Scroll to current time'), findsOneWidget); }); testWidgets('required banner scales inside a compact header width', ( @@ -515,7 +650,11 @@ void main() { expect(tester.takeException(), isNull); expect( - tester.widget(find.byType(AnimatedOpacity)).opacity, + tester + .widget( + find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), + ) + .opacity, 0, ); @@ -526,11 +665,15 @@ void main() { await tester.pumpAndSettle(); expect( - tester.widget(find.byType(AnimatedOpacity)).opacity, + tester + .widget( + find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')), + ) + .opacity, 1, ); final actionBackground = tester.widget( - find.byType(AnimatedContainer), + find.byKey(const ValueKey('narrow-actions-quick-actions-background')), ); final decoration = actionBackground.decoration; expect(decoration, isA()); @@ -669,6 +812,64 @@ void main() { expect(backlogCount, 1); }); + testWidgets('hover arrow exposes the same Push destination menu', ( + tester, + ) async { + var nextCount = 0; + var tomorrowCount = 0; + var backlogCount = 0; + + Future openMenu() async { + await tester.tap( + find.byKey(const ValueKey('quick-push-menu-quick-push-button')), + ); + await tester.pumpAndSettle(); + } + + await _pumpTimelineCard( + tester, + card: _timelineCard(id: 'quick-push-menu', title: 'Current task'), + size: const Size(420, 88), + onPushToNext: (_) async { + nextCount += 1; + }, + onPushToTomorrow: (_) async { + tomorrowCount += 1; + }, + onPushToBacklog: (_) async { + backlogCount += 1; + }, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('quick-push-menu'))), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check), findsNothing); + await openMenu(); + expect(find.text('Push to next'), findsOneWidget); + expect(find.text('Push to tomorrow'), findsOneWidget); + expect(find.text('Push to backlog'), findsOneWidget); + + 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); + + await gesture.removePointer(); + }); + testWidgets('Push menu dismisses on outside click without callback', ( tester, ) async { @@ -780,6 +981,33 @@ void main() { await _pumpPlanApp(tester, composition: composition); await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); + var statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isEmpty); + + final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ), + ); + await tester.pumpAndSettle(); + statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isNotEmpty); + await hover.removePointer(); + await tester.pumpAndSettle(); + final beforeClick = DateTime.now().toUtc(); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); @@ -797,6 +1025,26 @@ void main() { expect(completedTask.completedAt!.isAfter(afterClick), isFalse); expect(find.textContaining('Completed at:'), findsWidgets); + final completedHover = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await completedHover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ), + ); + await tester.pumpAndSettle(); + statusDecoration = + tester + .widget( + find.byKey(const ValueKey('clean-coffee-maker-status')), + ) + .decoration + as BoxDecoration; + expect(statusDecoration.boxShadow, isNotEmpty); + await completedHover.removePointer(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.pumpAndSettle(); @@ -825,6 +1073,32 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.textContaining('Completed -'), findsNothing); + var modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isEmpty); + + final hover = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('task-modal-status')), + ), + ); + await tester.pumpAndSettle(); + modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isNotEmpty); + await hover.removePointer(); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); @@ -839,6 +1113,26 @@ void main() { expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.byIcon(Icons.check), findsWidgets); + final completedHover = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await completedHover.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('task-modal-status')), + ), + ); + await tester.pumpAndSettle(); + modalStatusDecoration = + tester + .widget( + find.byKey(const ValueKey('task-modal-status')), + ) + .decoration + as BoxDecoration; + expect(modalStatusDecoration.boxShadow, isNotEmpty); + await completedHover.removePointer(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); await tester.pumpAndSettle(); @@ -994,6 +1288,9 @@ TimelineCardModel _timelineCard({ bool showPastTaskPushButton = false, bool isCompleted = false, String? completedTimeText, + TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium, + TimelineDifficultyIconToken difficultyIconToken = + TimelineDifficultyIconToken.medium, }) { final resolvedTaskType = taskType ?? @@ -1014,8 +1311,8 @@ TimelineCardModel _timelineCard({ endMinutes: endMinutes, taskType: resolvedTaskType, visualKind: visualKind, - rewardIconToken: TimelineRewardIconToken.medium, - difficultyIconToken: TimelineDifficultyIconToken.medium, + rewardIconToken: rewardIconToken, + difficultyIconToken: difficultyIconToken, completedTimeText: completedTimeText, isCompleted: isCompleted, isSelectable: true,