forked from eva/focus-flow
feat(ui): refine task modal actions and timeline navigation
This commit is contained in:
parent
fd4fdd13d2
commit
b156ddfd5e
19 changed files with 1206 additions and 239 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
125
apps/focus_flow_flutter/lib/widgets/status_circle_button.dart
Normal file
125
apps/focus_flow_flutter/lib/widgets/status_circle_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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.
|
||||
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(
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
),
|
||||
),
|
||||
icon: Icon(icon),
|
||||
label: Text(
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: FocusFlowTokens.modalButtonSize,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
_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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: ring,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue