feat(ui): refine task modal actions and timeline navigation

This commit is contained in:
Ashley Venn 2026-07-04 22:16:37 -07:00
parent fd4fdd13d2
commit b156ddfd5e
19 changed files with 1206 additions and 239 deletions

View file

@ -107,6 +107,16 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
); );
} }
/// Permanently removes [card] from persistence.
Future<void> _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. /// Moves the visible planning date backward by one day.
void _selectPreviousDate() { void _selectPreviousDate() {
logger.finer(() => 'Previous date action requested.'); logger.finer(() => 'Previous date action requested.');
@ -165,6 +175,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onPushToNext: _pushCardToNext, onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow, onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog, onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,
@ -181,6 +192,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onPushToNext: _pushCardToNext, onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow, onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog, onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask, onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection, onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate, onPreviousDate: _selectPreviousDate,

View file

@ -16,6 +16,7 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.onPushToNext, required this.onPushToNext,
required this.onPushToTomorrow, required this.onPushToTomorrow,
required this.onPushToBacklog, required this.onPushToBacklog,
required this.onRemoveCard,
required this.onAddTask, required this.onAddTask,
required this.onClearSelection, required this.onClearSelection,
required this.onPreviousDate, 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(TimelineCardModel card) onPushToBacklog; final Future<void> 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<void> Function(TimelineCardModel card) onRemoveCard;
/// Stores the `onAddTask` value for this object. /// Stores the `onAddTask` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function() onAddTask; final Future<void> Function() onAddTask;

View file

@ -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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _timelineScrollRequest = 0; 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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _showUpcomingRequiredTask() { 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. /// 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. /// 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 @override
@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
} }
_timelineScrollTargetCardId = null; _timelineScrollTargetCardId = null;
_timelineScrollRequest = 0; _timelineScrollRequest = 0;
_timelineScrollToNowRequest = 0;
} }
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPreviousDate: widget.onPreviousDate, onPreviousDate: widget.onPreviousDate,
onNextDate: widget.onNextDate, onNextDate: widget.onNextDate,
onDatePressed: widget.onChooseDate, onDatePressed: widget.onChooseDate,
onDateLabelPressed: _scrollTimelineToCurrentTime,
), ),
if (widget.data.requiredBanner == null) if (widget.data.requiredBanner == null)
const SizedBox(height: 16) const SizedBox(height: 16)
@ -81,12 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
range: widget.data.timelineRange, range: widget.data.timelineRange,
scrollTargetCardId: _timelineScrollTargetCardId, scrollTargetCardId: _timelineScrollTargetCardId,
scrollRequest: _timelineScrollRequest, scrollRequest: _timelineScrollRequest,
scrollToNowRequest: _timelineScrollToNowRequest,
onCardSelected: widget.onSelectCard, onCardSelected: widget.onSelectCard,
onAddTask: widget.onAddTask, onAddTask: widget.onAddTask,
onCardCompleted: widget.onCompleteCard, onCardCompleted: widget.onCompleteCard,
onPushToNext: widget.onPushToNext, onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
now: () =>
DateTime.now().toUtc().add(widget.data.timeZoneOffset),
), ),
), ),
], ],
@ -112,6 +130,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onStatusPressed: widget.selectedCard!.canToggleCompletion onStatusPressed: widget.selectedCard!.canToggleCompletion
? () => widget.onCompleteCard(widget.selectedCard!) ? () => widget.onCompleteCard(widget.selectedCard!)
: null, : null,
onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
onRemove: widget.onRemoveCard,
), ),
), ),
], ],

View file

@ -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<StatusCircleButton> createState() => _StatusCircleButtonState();
}
/// Mutable state for [StatusCircleButton].
class _StatusCircleButtonState extends State<StatusCircleButton> {
/// 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,
),
),
);
}
}

View file

@ -3,12 +3,24 @@
part of '../task_selection_modal.dart'; 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. /// 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. /// 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. /// 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. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
@ -18,29 +30,309 @@ class _ActionButton extends StatelessWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final String label; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton.icon( return _ModalActionSurface(
onPressed: () {}, icon: widget.icon,
style: OutlinedButton.styleFrom( label: widget.label,
foregroundColor: FocusFlowTokens.textPrimary, isHovered: _isHovered,
side: const BorderSide(color: FocusFlowTokens.subtleBorder), onHoverChanged: (value) {
shape: RoundedRectangleBorder( 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<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
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;
});
},
),
);
}
/// 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<void> _runPush(Future<void> 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<void> 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<bool> 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( borderRadius: BorderRadius.circular(
FocusFlowTokens.smallButtonRadius, 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),
), ),
), ),
icon: Icon(icon), const SizedBox(width: 8),
label: Text( Flexible(
child: Text(
label, label,
style: const TextStyle( maxLines: 1,
fontSize: FocusFlowTokens.modalButtonSize, overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
),
],
),
),
),
),
); );
} }
} }

View file

@ -3,50 +3,71 @@
part of '../task_selection_modal.dart'; part of '../task_selection_modal.dart';
/// Private implementation type for `_InfoTile` in this library. /// Private implementation type for `_HeaderMetricIcons` 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 keeps task effort and reward metadata near the modal close action.
class _InfoTile extends StatelessWidget { class _HeaderMetricIcons extends StatelessWidget {
/// Creates a `_InfoTile` instance with the values required by its invariants. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Widget child; final TimelineCardModel card;
/// 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;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Column(
height: 58, mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Row(
children: [ children: [
child, _HeaderMetricIcon(
const SizedBox(width: 18), tooltip: card.effortLabel == 'Effort not set'
Flexible( ? 'Not Set'
child: Text( : card.effortLabel,
label, child: DifficultyBars(
maxLines: 1, difficulty: card.difficultyIconToken,
overflow: TextOverflow.ellipsis, color: FocusFlowTokens.restPurple,
style: const TextStyle( size: const Size(28, 16),
color: FocusFlowTokens.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
), ),
), ),
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)),
); );
} }
} }

View file

@ -4,7 +4,7 @@
part of '../task_selection_modal.dart'; part of '../task_selection_modal.dart';
/// Private implementation type for `_ModalStatusButton` in this library. /// 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 { class _ModalStatusButton extends StatelessWidget {
/// Creates a `_ModalStatusButton` instance with the values required by its invariants. /// 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. /// 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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ring = Container( return StatusCircleButton(
key: const ValueKey('task-modal-status'), circleKey: const ValueKey('task-modal-status'),
width: 44, color: color,
height: 44, checkColor: FocusFlowTokens.appBackground,
decoration: BoxDecoration( isCompleted: card.isCompleted,
shape: BoxShape.circle, size: 44,
color: card.isCompleted ? color : Colors.transparent, borderWidth: 4,
border: Border.all(color: color, width: 4), iconSize: 26,
), hoverBlurRadius: 12,
child: card.isCompleted semanticLabel: 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} not done'
: 'Mark ${card.title} complete', : 'Mark ${card.title} complete',
child: MouseRegion( onPressed: onPressed,
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
); );
} }
} }

View file

@ -4,10 +4,13 @@
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI. /// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
library; library;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
import 'status_circle_button.dart';
import 'timeline/difficulty_bars.dart'; import 'timeline/difficulty_bars.dart';
import 'timeline/reward_icon.dart'; import 'timeline/reward_icon.dart';
@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget {
required this.card, required this.card,
required this.onClose, required this.onClose,
this.onStatusPressed, this.onStatusPressed,
this.onPushToNext,
this.onPushToTomorrow,
this.onPushToBacklog,
this.onRemove,
super.key, super.key,
}); });
@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when the status circle should toggle completion. /// Callback invoked when the status circle should toggle completion.
final VoidCallback? onStatusPressed; final VoidCallback? onStatusPressed;
/// Callback invoked when Push to next is selected.
final Future<void> Function(TimelineCardModel card)? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked when Remove is selected.
final Future<void> Function(TimelineCardModel card)? onRemove;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 14),
_HeaderMetricIcons(card: card),
const SizedBox(width: 14),
IconButton( IconButton(
key: const ValueKey('close-task-modal'), key: const ValueKey('close-task-modal'),
onPressed: onClose, onPressed: onClose,
@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 26), const SizedBox(height: 24),
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),
GridView.count( GridView.count(
crossAxisCount: 2, crossAxisCount: 2,
crossAxisSpacing: 26, crossAxisSpacing: 18,
mainAxisSpacing: 16, mainAxisSpacing: 12,
childAspectRatio: 4.1, childAspectRatio: 5.3,
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: const [ children: [
_ActionButton(icon: Icons.check, label: 'Done'), _PushActionButton(
_ActionButton(icon: Icons.arrow_forward, label: 'Push'), card: card,
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
),
_ActionButton( _ActionButton(
icon: Icons.inventory_2_outlined, 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), const SizedBox(height: 14),

View file

@ -114,7 +114,7 @@ class _CardMetrics {
/// Returns the derived `actionsWidth` value for this object. /// 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. /// 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. /// 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. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.

View file

@ -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. /// 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({ const _NoOpIcon({
required this.icon, required this.icon,
required this.tooltip,
required this.color, required this.color,
required this.metrics, 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon; final IconData icon;
/// Tooltip message for this inactive icon affordance.
final String tooltip;
/// Stores the `color` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color; final Color color;
@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( return Tooltip(
message: 'No-op action', message: tooltip,
child: GestureDetector( child: GestureDetector(
onTap: () {}, onTap: () {},
behavior: HitTestBehavior.opaque, 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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final 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,
),
),
),
);
}
}

View file

@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget {
/// Creates a `_QuickActions` instance with the values required by its invariants. /// 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. /// 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({ const _QuickActions({
required this.card,
required this.color, required this.color,
required this.backgroundColor, required this.backgroundColor,
required this.metrics, required this.metrics,
required this.visible, 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color; 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final bool visible; 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<void> Function()? onPushToNext;
/// Callback invoked when Push to tomorrow is selected.
final Future<void> Function()? onPushToTomorrow;
/// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget {
ignoring: !visible, ignoring: !visible,
child: ClipRect( child: ClipRect(
child: AnimatedContainer( child: AnimatedContainer(
key: ValueKey('${card.id}-quick-actions-background'),
width: visible ? metrics.actionsWidth : 0, width: visible ? metrics.actionsWidth : 0,
height: metrics.actionButtonSize, height: metrics.actionButtonSize,
decoration: BoxDecoration(color: backgroundColor), decoration: BoxDecoration(color: backgroundColor),
@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget {
maxWidth: metrics.actionsWidth, maxWidth: metrics.actionsWidth,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: AnimatedOpacity( child: AnimatedOpacity(
key: ValueKey('${card.id}-quick-actions-opacity'),
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 90), duration: const Duration(milliseconds: 90),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_NoOpIcon( _QuickPushIcon(
icon: Icons.check, card: card,
color: color,
metrics: metrics,
),
_NoOpIcon(
icon: Icons.arrow_forward,
color: color, color: color,
metrics: metrics, metrics: metrics,
isRunning: pushRunning,
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
), ),
_NoOpIcon( _NoOpIcon(
icon: Icons.calendar_today, icon: Icons.calendar_today,
tooltip: 'Schedule',
color: color, color: color,
metrics: metrics, metrics: metrics,
), ),
_NoOpIcon( _NoOpIcon(
icon: Icons.wb_sunny_outlined, icon: Icons.wb_sunny_outlined,
tooltip: 'Protect time',
color: color, color: color,
metrics: metrics, metrics: metrics,
), ),

View file

@ -4,7 +4,7 @@
part of '../task_timeline_card.dart'; part of '../task_timeline_card.dart';
/// Private implementation type for `_StatusRing` in this library. /// 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 { class _StatusRing extends StatelessWidget {
/// Creates a `_StatusRing` instance with the values required by its invariants. /// 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. /// 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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = Border.all(color: color, width: metrics.statusBorderWidth); return StatusCircleButton(
final ring = Container( circleKey: ValueKey('${card.id}-status'),
key: ValueKey('${card.id}-status'), color: color,
width: metrics.statusRingSize, checkColor: FocusFlowTokens.appBackground,
height: metrics.statusRingSize, isCompleted: card.isCompleted,
decoration: BoxDecoration( size: metrics.statusRingSize,
shape: BoxShape.circle, borderWidth: metrics.statusBorderWidth,
color: card.isCompleted ? color : Colors.transparent, iconSize: metrics.statusIconSize,
border: border, semanticLabel: card.isCompleted
),
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} not done'
: 'Mark ${card.title} complete', : 'Mark ${card.title} complete',
child: MouseRegion( onPressed: onPressed,
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: ring,
),
),
),
); );
} }
} }

View file

@ -57,10 +57,15 @@ class _TrailingControls extends StatelessWidget {
children: [ children: [
if (card.showsQuickActions) if (card.showsQuickActions)
_QuickActions( _QuickActions(
card: card,
color: colors.accent, color: colors.accent,
backgroundColor: colors.background.withValues(alpha: 1), backgroundColor: colors.background.withValues(alpha: 1),
metrics: metrics, metrics: metrics,
visible: actionsVisible, visible: actionsVisible,
pushRunning: pushRunning,
onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog,
), ),
if (card.showPastTaskPushButton) ...[ if (card.showPastTaskPushButton) ...[
_PastTaskPushButton( _PastTaskPushButton(
@ -99,6 +104,86 @@ enum _PastTaskPushDestination {
backlog, 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<void> 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<void> Function()? onPushToNext,
required Future<void> Function()? onPushToTomorrow,
required Future<void> 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<PopupMenuEntry<_PastTaskPushDestination>> _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. /// 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. /// 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 { 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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = final options = _pushDestinationOptions(
!isRunning && onPushToNext: onPushToNext,
(onPushToNext != null || onPushToTomorrow: onPushToTomorrow,
onPushToTomorrow != null || onPushToBacklog: onPushToBacklog,
onPushToBacklog != null); );
final enabled = !isRunning && _hasEnabledPushDestination(options);
return PopupMenuButton<_PastTaskPushDestination>( return PopupMenuButton<_PastTaskPushDestination>(
key: ValueKey('${card.id}-push-button'), key: ValueKey('${card.id}-push-button'),
tooltip: card.pastTaskPushSemanticLabel, tooltip: card.pastTaskPushSemanticLabel,
@ -160,45 +246,8 @@ class _PastTaskPushButton extends StatelessWidget {
color: const Color(0xFF122018), color: const Color(0xFF122018),
elevation: 8, elevation: 8,
position: PopupMenuPosition.under, position: PopupMenuPosition.under,
onSelected: (destination) { onSelected: (destination) => _selectPushDestination(destination, options),
switch (destination) { itemBuilder: (context) => _pushDestinationMenuItems(options),
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( child: Semantics(
button: true, button: true,
enabled: enabled, enabled: enabled,

View file

@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart' show logger;
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import '../status_circle_button.dart';
import 'difficulty_bars.dart'; import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';

View file

@ -32,6 +32,7 @@ class TimelineView extends StatefulWidget {
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
this.scrollToNowRequest = 0,
super.key, super.key,
}); });
@ -68,6 +69,9 @@ class TimelineView extends StatefulWidget {
/// Monotonic request number that allows repeated jumps to the same card. /// Monotonic request number that allows repeated jumps to the same card.
final int scrollRequest; 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. /// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -28,6 +28,10 @@ class _TimelineViewState extends State<TimelineView> {
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
var _handledScrollRequest = 0; 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. /// 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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
late TimelineGeometry _geometry; late TimelineGeometry _geometry;
@ -86,6 +90,7 @@ class _TimelineViewState extends State<TimelineView> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_scheduleInitialScroll(_geometry, constraints.maxHeight); _scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleCurrentTimeScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry); _scheduleTargetScroll(_geometry);
return ScrollConfiguration( return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(), behavior: const _TimelineScrollBehavior(),
@ -237,21 +242,31 @@ class _TimelineViewState extends State<TimelineView> {
return; return;
} }
final now = widget.now?.call() ?? DateTime.now(); final now = widget.now?.call() ?? DateTime.now();
final currentMinute = now.hour * 60 + now.minute; _jumpToCurrentTime(geometry, viewportHeight, now);
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);
_didSetInitialScroll = true; _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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _scheduleTargetScroll(TimelineGeometry geometry) { void _scheduleTargetScroll(TimelineGeometry geometry) {
@ -284,4 +299,40 @@ class _TimelineViewState extends State<TimelineView> {
_handledScrollRequest = widget.scrollRequest; _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();
}
} }

View file

@ -21,6 +21,7 @@ class TopBar extends StatelessWidget {
this.onPreviousDate, this.onPreviousDate,
this.onNextDate, this.onNextDate,
this.onDatePressed, this.onDatePressed,
this.onDateLabelPressed,
super.key, super.key,
}); });
@ -36,6 +37,9 @@ class TopBar extends StatelessWidget {
/// Callback invoked when the choose-date control is activated. /// Callback invoked when the choose-date control is activated.
final VoidCallback? onDatePressed; final VoidCallback? onDatePressed;
/// Callback invoked when the displayed date label is activated.
final VoidCallback? onDateLabelPressed;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -78,13 +82,13 @@ class TopBar extends StatelessWidget {
width: metrics.dateWidth, width: metrics.dateWidth,
height: metrics.controlHeight, height: metrics.controlHeight,
child: Tooltip( child: Tooltip(
message: 'Choose date', message: 'Scroll to current time',
child: Semantics( child: Semantics(
button: true, button: true,
label: 'Choose date, $dateLabel', label: 'Scroll to current time, $dateLabel',
child: TextButton( child: TextButton(
key: const ValueKey('top-bar-date-button'), key: const ValueKey('top-bar-date-button'),
onPressed: onDatePressed, onPressed: onDateLabelPressed,
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary, foregroundColor: FocusFlowTokens.textPrimary,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(

View file

@ -35,7 +35,7 @@ void main() {
testWidgets('date picker jumps directly to the chosen date', (tester) async { testWidgets('date picker jumps directly to the chosen date', (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('top-bar-date-button'))); await tester.tap(find.byTooltip('Choose date'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(DatePickerDialog), findsOneWidget); expect(find.byType(DatePickerDialog), findsOneWidget);
@ -49,6 +49,18 @@ void main() {
expect(find.text('Clean coffee maker'), findsNothing); 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 { testWidgets('open modal closes when changing dates', (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);

View file

@ -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/models/today_screen_models.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.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/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/difficulty_bars.dart';
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
@ -95,7 +96,7 @@ void main() {
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); 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, tester,
) async { ) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
@ -109,14 +110,18 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.textContaining('Required'), findsWidgets); expect(find.textContaining('Required'), findsWidgets);
expect(find.text('Reward level 2'), findsOneWidget); expect(find.byTooltip('Reward level 2'), findsOneWidget);
expect(find.text('Medium effort'), findsOneWidget); expect(find.byTooltip('Medium effort'), findsOneWidget);
expect(find.text('Done'), 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('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); expect(find.text('Break up'), findsOneWidget);
await tester.tap(find.text('Done')); await tester.tap(find.text('Break up'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
@ -137,6 +142,91 @@ void main() {
expect(find.text('Pay bill'), findsOneWidget); 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<void> 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', () { test('Today screen timeline range covers the full day', () {
expect(TodayScreenData.defaultRange.startMinutes, 0); expect(TodayScreenData.defaultRange.startMinutes, 0);
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
@ -198,7 +288,7 @@ void main() {
}); });
testWidgets( testWidgets(
'timeline defaults near the current time while keeping full day', 'timeline defaults with current time one quarter down the viewport',
(tester) async { (tester) async {
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -218,10 +308,48 @@ void main() {
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable)); final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
final position = scrollable.position; final position = scrollable.position;
expect(position.maxScrollExtent, greaterThan(3000)); 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<ScrollableState>(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 { testWidgets('timeline hour labels stay on one line', (tester) async {
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -430,6 +558,7 @@ void main() {
var previousCount = 0; var previousCount = 0;
var nextCount = 0; var nextCount = 0;
var datePressedCount = 0; var datePressedCount = 0;
var dateLabelPressedCount = 0;
await _pumpConstrainedWidget( await _pumpConstrainedWidget(
tester, tester,
@ -445,18 +574,24 @@ void main() {
onDatePressed: () { onDatePressed: () {
datePressedCount += 1; datePressedCount += 1;
}, },
onDateLabelPressed: () {
dateLabelPressedCount += 1;
},
), ),
); );
await tester.tap(find.byTooltip('Previous day')); await tester.tap(find.byTooltip('Previous day'));
await tester.tap(find.byTooltip('Next 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.tap(find.byKey(const ValueKey('top-bar-date-button')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(previousCount, 1); expect(previousCount, 1);
expect(nextCount, 1); expect(nextCount, 1);
expect(datePressedCount, 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', ( testWidgets('required banner scales inside a compact header width', (
@ -515,7 +650,11 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect( expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity, tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
0, 0,
); );
@ -526,11 +665,15 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity, tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
1, 1,
); );
final actionBackground = tester.widget<AnimatedContainer>( final actionBackground = tester.widget<AnimatedContainer>(
find.byType(AnimatedContainer), find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
); );
final decoration = actionBackground.decoration; final decoration = actionBackground.decoration;
expect(decoration, isA<BoxDecoration>()); expect(decoration, isA<BoxDecoration>());
@ -669,6 +812,64 @@ void main() {
expect(backlogCount, 1); expect(backlogCount, 1);
}); });
testWidgets('hover arrow exposes the same Push destination menu', (
tester,
) async {
var nextCount = 0;
var tomorrowCount = 0;
var backlogCount = 0;
Future<void> 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', ( testWidgets('Push menu dismisses on outside click without callback', (
tester, tester,
) async { ) async {
@ -780,6 +981,33 @@ void main() {
await _pumpPlanApp(tester, composition: composition); await _pumpPlanApp(tester, composition: composition);
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
var statusDecoration =
tester
.widget<AnimatedContainer>(
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<AnimatedContainer>(
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(); final beforeClick = DateTime.now().toUtc();
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -797,6 +1025,26 @@ void main() {
expect(completedTask.completedAt!.isAfter(afterClick), isFalse); expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
expect(find.textContaining('Completed at:'), findsWidgets); 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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -825,6 +1073,32 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.textContaining('Completed -'), findsNothing); expect(find.textContaining('Completed -'), findsNothing);
var modalStatusDecoration =
tester
.widget<AnimatedContainer>(
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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -839,6 +1113,26 @@ void main() {
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
expect(find.byIcon(Icons.check), findsWidgets); 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<AnimatedContainer>(
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.tap(find.byKey(const ValueKey('task-modal-status')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -994,6 +1288,9 @@ TimelineCardModel _timelineCard({
bool showPastTaskPushButton = false, bool showPastTaskPushButton = false,
bool isCompleted = false, bool isCompleted = false,
String? completedTimeText, String? completedTimeText,
TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium,
TimelineDifficultyIconToken difficultyIconToken =
TimelineDifficultyIconToken.medium,
}) { }) {
final resolvedTaskType = final resolvedTaskType =
taskType ?? taskType ??
@ -1014,8 +1311,8 @@ TimelineCardModel _timelineCard({
endMinutes: endMinutes, endMinutes: endMinutes,
taskType: resolvedTaskType, taskType: resolvedTaskType,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium, rewardIconToken: rewardIconToken,
difficultyIconToken: TimelineDifficultyIconToken.medium, difficultyIconToken: difficultyIconToken,
completedTimeText: completedTimeText, completedTimeText: completedTimeText,
isCompleted: isCompleted, isCompleted: isCompleted,
isSelectable: true, isSelectable: true,