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.
void _selectPreviousDate() {
logger.finer(() => 'Previous date action requested.');
@ -165,6 +175,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
@ -181,6 +192,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,

View file

@ -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<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.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
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.
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,
),
),
],

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';
/// 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<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;
});
},
),
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<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(
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,
),
),
),
],
),
),
),
),
);

View file

@ -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)),
);
}
}

View file

@ -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,
);
}
}

View file

@ -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<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.
/// 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),

View file

@ -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.

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.
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<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.
/// 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<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
@ -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,
),

View file

@ -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,
);
}
}

View file

@ -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<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.
/// 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,

View file

@ -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';

View file

@ -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

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.
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<TimelineView> {
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<TimelineView> {
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<TimelineView> {
_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.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(

View file

@ -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);

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/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<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', () {
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<ScrollableState>(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<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 {
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<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
0,
);
@ -526,11 +665,15 @@ void main() {
await tester.pumpAndSettle();
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
tester
.widget<AnimatedOpacity>(
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
)
.opacity,
1,
);
final actionBackground = tester.widget<AnimatedContainer>(
find.byType(AnimatedContainer),
find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
);
final decoration = actionBackground.decoration;
expect(decoration, isA<BoxDecoration>());
@ -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<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', (
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<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();
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<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.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<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.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<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.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,