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.
|
/// Moves the visible planning date backward by one day.
|
||||||
void _selectPreviousDate() {
|
void _selectPreviousDate() {
|
||||||
logger.finer(() => 'Previous date action requested.');
|
logger.finer(() => 'Previous date action requested.');
|
||||||
|
|
@ -165,6 +175,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
onPushToNext: _pushCardToNext,
|
onPushToNext: _pushCardToNext,
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onClearSelection: controller.clearSelection,
|
onClearSelection: controller.clearSelection,
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
|
@ -181,6 +192,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
onPushToNext: _pushCardToNext,
|
onPushToNext: _pushCardToNext,
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onClearSelection: controller.clearSelection,
|
onClearSelection: controller.clearSelection,
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class _TodayScreenScaffold extends StatefulWidget {
|
||||||
required this.onPushToNext,
|
required this.onPushToNext,
|
||||||
required this.onPushToTomorrow,
|
required this.onPushToTomorrow,
|
||||||
required this.onPushToBacklog,
|
required this.onPushToBacklog,
|
||||||
|
required this.onRemoveCard,
|
||||||
required this.onAddTask,
|
required this.onAddTask,
|
||||||
required this.onClearSelection,
|
required this.onClearSelection,
|
||||||
required this.onPreviousDate,
|
required this.onPreviousDate,
|
||||||
|
|
@ -51,6 +52,10 @@ class _TodayScreenScaffold extends StatefulWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Future<void> Function(TimelineCardModel card) onPushToBacklog;
|
final Future<void> Function(TimelineCardModel card) onPushToBacklog;
|
||||||
|
|
||||||
|
/// Stores the `onRemoveCard` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final Future<void> Function(TimelineCardModel card) onRemoveCard;
|
||||||
|
|
||||||
/// Stores the `onAddTask` value for this object.
|
/// Stores the `onAddTask` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Future<void> Function() onAddTask;
|
final Future<void> Function() onAddTask;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
var _timelineScrollRequest = 0;
|
var _timelineScrollRequest = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_timelineScrollToNowRequest` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
var _timelineScrollToNowRequest = 0;
|
||||||
|
|
||||||
/// Runs the `_showUpcomingRequiredTask` helper used inside this library.
|
/// Runs the `_showUpcomingRequiredTask` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
void _showUpcomingRequiredTask() {
|
void _showUpcomingRequiredTask() {
|
||||||
|
|
@ -31,6 +35,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scrollTimelineToCurrentTime` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _scrollTimelineToCurrentTime() {
|
||||||
|
setState(() {
|
||||||
|
_timelineScrollTargetCardId = null;
|
||||||
|
_timelineScrollToNowRequest += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs the `didUpdateWidget` behavior for this scheduler component.
|
/// Performs the `didUpdateWidget` behavior for this scheduler component.
|
||||||
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
|
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
|
||||||
@override
|
@override
|
||||||
|
|
@ -41,6 +54,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
}
|
}
|
||||||
_timelineScrollTargetCardId = null;
|
_timelineScrollTargetCardId = null;
|
||||||
_timelineScrollRequest = 0;
|
_timelineScrollRequest = 0;
|
||||||
|
_timelineScrollToNowRequest = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
|
|
@ -64,6 +78,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
onPreviousDate: widget.onPreviousDate,
|
onPreviousDate: widget.onPreviousDate,
|
||||||
onNextDate: widget.onNextDate,
|
onNextDate: widget.onNextDate,
|
||||||
onDatePressed: widget.onChooseDate,
|
onDatePressed: widget.onChooseDate,
|
||||||
|
onDateLabelPressed: _scrollTimelineToCurrentTime,
|
||||||
),
|
),
|
||||||
if (widget.data.requiredBanner == null)
|
if (widget.data.requiredBanner == null)
|
||||||
const SizedBox(height: 16)
|
const SizedBox(height: 16)
|
||||||
|
|
@ -81,12 +96,15 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
range: widget.data.timelineRange,
|
range: widget.data.timelineRange,
|
||||||
scrollTargetCardId: _timelineScrollTargetCardId,
|
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||||
scrollRequest: _timelineScrollRequest,
|
scrollRequest: _timelineScrollRequest,
|
||||||
|
scrollToNowRequest: _timelineScrollToNowRequest,
|
||||||
onCardSelected: widget.onSelectCard,
|
onCardSelected: widget.onSelectCard,
|
||||||
onAddTask: widget.onAddTask,
|
onAddTask: widget.onAddTask,
|
||||||
onCardCompleted: widget.onCompleteCard,
|
onCardCompleted: widget.onCompleteCard,
|
||||||
onPushToNext: widget.onPushToNext,
|
onPushToNext: widget.onPushToNext,
|
||||||
onPushToTomorrow: widget.onPushToTomorrow,
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
onPushToBacklog: widget.onPushToBacklog,
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
|
now: () =>
|
||||||
|
DateTime.now().toUtc().add(widget.data.timeZoneOffset),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -112,6 +130,10 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
onStatusPressed: widget.selectedCard!.canToggleCompletion
|
||||||
? () => widget.onCompleteCard(widget.selectedCard!)
|
? () => widget.onCompleteCard(widget.selectedCard!)
|
||||||
: null,
|
: null,
|
||||||
|
onPushToNext: widget.onPushToNext,
|
||||||
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
|
onRemove: widget.onRemoveCard,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
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';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
|
/// Menu destinations exposed by the modal Push button.
|
||||||
|
enum _ModalPushDestination {
|
||||||
|
/// Pushes the task to the next available slot.
|
||||||
|
next,
|
||||||
|
|
||||||
|
/// Pushes the task to tomorrow's first available slot.
|
||||||
|
tomorrow,
|
||||||
|
|
||||||
|
/// Pushes the task to Backlog.
|
||||||
|
backlog,
|
||||||
|
}
|
||||||
|
|
||||||
/// Private implementation type for `_ActionButton` in this library.
|
/// Private implementation type for `_ActionButton` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||||
class _ActionButton extends StatelessWidget {
|
class _ActionButton extends StatefulWidget {
|
||||||
/// Creates a `_ActionButton` instance with the values required by its invariants.
|
/// Creates a `_ActionButton` instance with the values required by its invariants.
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
const _ActionButton({required this.icon, required this.label});
|
const _ActionButton({required this.icon, required this.label, this.onTap});
|
||||||
|
|
||||||
/// Stores the `icon` value for this object.
|
/// Stores the `icon` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
|
@ -18,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.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
|
/// Callback invoked when the action is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Creates the mutable state object used by this stateful widget.
|
||||||
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
|
@override
|
||||||
|
State<_ActionButton> createState() => _ActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_ActionButtonState` in this library.
|
||||||
|
/// It owns hover feedback for compact modal action buttons.
|
||||||
|
class _ActionButtonState extends State<_ActionButton> {
|
||||||
|
/// Whether the pointer is hovering this action button.
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return OutlinedButton.icon(
|
return _ModalActionSurface(
|
||||||
onPressed: () {},
|
icon: widget.icon,
|
||||||
style: OutlinedButton.styleFrom(
|
label: widget.label,
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
isHovered: _isHovered,
|
||||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
onHoverChanged: (value) {
|
||||||
shape: RoundedRectangleBorder(
|
setState(() {
|
||||||
borderRadius: BorderRadius.circular(
|
_isHovered = value;
|
||||||
FocusFlowTokens.smallButtonRadius,
|
});
|
||||||
|
},
|
||||||
|
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(
|
/// Builds the push destination option list for this button.
|
||||||
fontSize: FocusFlowTokens.modalButtonSize,
|
List<_ModalPushDestinationOption> _modalPushDestinationOptions() {
|
||||||
fontWeight: FontWeight.w700,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,71 @@
|
||||||
|
|
||||||
part of '../task_selection_modal.dart';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_InfoTile` in this library.
|
/// Private implementation type for `_HeaderMetricIcons` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It keeps task effort and reward metadata near the modal close action.
|
||||||
class _InfoTile extends StatelessWidget {
|
class _HeaderMetricIcons extends StatelessWidget {
|
||||||
/// Creates a `_InfoTile` instance with the values required by its invariants.
|
/// Creates a `_HeaderMetricIcons` instance with the values required by its invariants.
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
const _InfoTile({required this.child, required this.label});
|
const _HeaderMetricIcons({required this.card});
|
||||||
|
|
||||||
/// Stores the `child` value for this object.
|
/// Stores the `card` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Widget child;
|
final TimelineCardModel card;
|
||||||
|
|
||||||
/// Stores the `label` value for this object.
|
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Column(
|
||||||
height: 58,
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: [
|
||||||
decoration: BoxDecoration(
|
_HeaderMetricIcon(
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
|
tooltip: card.effortLabel == 'Effort not set'
|
||||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
? 'Not Set'
|
||||||
),
|
: card.effortLabel,
|
||||||
child: Row(
|
child: DifficultyBars(
|
||||||
children: [
|
difficulty: card.difficultyIconToken,
|
||||||
child,
|
color: FocusFlowTokens.restPurple,
|
||||||
const SizedBox(width: 18),
|
size: const Size(28, 16),
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: FocusFlowTokens.textPrimary,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
|
_HeaderMetricIcon(
|
||||||
|
tooltip: card.rewardLabel == 'Reward not set'
|
||||||
|
? 'Not Set'
|
||||||
|
: card.rewardLabel,
|
||||||
|
child: const RewardIcon(
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_HeaderMetricIcon` in this library.
|
||||||
|
/// It keeps metric tooltips scoped to icon-only modal affordances.
|
||||||
|
class _HeaderMetricIcon extends StatelessWidget {
|
||||||
|
/// Creates a `_HeaderMetricIcon` instance with the values required by its invariants.
|
||||||
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
const _HeaderMetricIcon({required this.tooltip, required this.child});
|
||||||
|
|
||||||
|
/// Stores the `tooltip` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
|
/// Stores the `child` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// Builds the widget subtree for this component.
|
||||||
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: SizedBox.square(dimension: 28, child: Center(child: child)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
part of '../task_selection_modal.dart';
|
part of '../task_selection_modal.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_ModalStatusButton` in this library.
|
/// Private implementation type for `_ModalStatusButton` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It adapts modal sizing and labels to the shared status-circle button.
|
||||||
class _ModalStatusButton extends StatelessWidget {
|
class _ModalStatusButton extends StatelessWidget {
|
||||||
/// Creates a `_ModalStatusButton` instance with the values required by its invariants.
|
/// Creates a `_ModalStatusButton` instance with the values required by its invariants.
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
|
@ -30,42 +30,19 @@ class _ModalStatusButton extends StatelessWidget {
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ring = Container(
|
return StatusCircleButton(
|
||||||
key: const ValueKey('task-modal-status'),
|
circleKey: const ValueKey('task-modal-status'),
|
||||||
width: 44,
|
color: color,
|
||||||
height: 44,
|
checkColor: FocusFlowTokens.appBackground,
|
||||||
decoration: BoxDecoration(
|
isCompleted: card.isCompleted,
|
||||||
shape: BoxShape.circle,
|
size: 44,
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
borderWidth: 4,
|
||||||
border: Border.all(color: color, width: 4),
|
iconSize: 26,
|
||||||
),
|
hoverBlurRadius: 12,
|
||||||
child: card.isCompleted
|
semanticLabel: card.isCompleted
|
||||||
? const Icon(
|
? 'Mark ${card.title} not done'
|
||||||
Icons.check,
|
: 'Mark ${card.title} complete',
|
||||||
color: FocusFlowTokens.appBackground,
|
onPressed: onPressed,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
/// Renders the Task Selection Modal widget for the FocusFlow Flutter UI.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
import 'status_circle_button.dart';
|
||||||
import 'timeline/difficulty_bars.dart';
|
import 'timeline/difficulty_bars.dart';
|
||||||
import 'timeline/reward_icon.dart';
|
import 'timeline/reward_icon.dart';
|
||||||
|
|
||||||
|
|
@ -24,6 +27,10 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
required this.card,
|
required this.card,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.onStatusPressed,
|
this.onStatusPressed,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
this.onRemove,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,6 +43,18 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
/// Callback invoked when the status circle should toggle completion.
|
/// Callback invoked when the status circle should toggle completion.
|
||||||
final VoidCallback? onStatusPressed;
|
final VoidCallback? onStatusPressed;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
|
||||||
|
|
||||||
|
/// Callback invoked when Remove is selected.
|
||||||
|
final Future<void> Function(TimelineCardModel card)? onRemove;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -81,6 +100,9 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
_HeaderMetricIcons(card: card),
|
||||||
|
const SizedBox(width: 14),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('close-task-modal'),
|
key: const ValueKey('close-task-modal'),
|
||||||
onPressed: onClose,
|
onPressed: onClose,
|
||||||
|
|
@ -92,43 +114,35 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 26),
|
const SizedBox(height: 24),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _InfoTile(
|
|
||||||
label: card.rewardLabel,
|
|
||||||
child: RewardIcon(color: FocusFlowTokens.accentMagenta),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 28),
|
|
||||||
Expanded(
|
|
||||||
child: _InfoTile(
|
|
||||||
label: card.effortLabel,
|
|
||||||
child: DifficultyBars(
|
|
||||||
difficulty: card.difficultyIconToken,
|
|
||||||
color: FocusFlowTokens.restPurple,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
GridView.count(
|
GridView.count(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
crossAxisSpacing: 26,
|
crossAxisSpacing: 18,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 12,
|
||||||
childAspectRatio: 4.1,
|
childAspectRatio: 5.3,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: const [
|
children: [
|
||||||
_ActionButton(icon: Icons.check, label: 'Done'),
|
_PushActionButton(
|
||||||
_ActionButton(icon: Icons.arrow_forward, label: 'Push'),
|
card: card,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
),
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
label: 'Move to Backlog',
|
label: 'Backlog',
|
||||||
|
),
|
||||||
|
const _ActionButton(icon: Icons.apps, label: 'Break up'),
|
||||||
|
_ActionButton(
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
label: 'Remove',
|
||||||
|
onTap: onRemove == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(onRemove!(card));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_ActionButton(icon: Icons.apps, label: 'Break up'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class _CardMetrics {
|
||||||
|
|
||||||
/// Returns the derived `actionsWidth` value for this object.
|
/// Returns the derived `actionsWidth` value for this object.
|
||||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
double get actionsWidth => actionButtonSize * 3 + actionGap;
|
||||||
|
|
||||||
/// Returns the derived `pushButtonHeight` value for this object.
|
/// Returns the derived `pushButtonHeight` value for this object.
|
||||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
const _NoOpIcon({
|
const _NoOpIcon({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.metrics,
|
required this.metrics,
|
||||||
});
|
});
|
||||||
|
|
@ -18,6 +19,9 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Tooltip message for this inactive icon affordance.
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
/// Stores the `color` value for this object.
|
/// Stores the `color` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
@ -31,7 +35,7 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'No-op action',
|
message: tooltip,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
|
@ -43,3 +47,80 @@ class _NoOpIcon extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Private implementation type for `_QuickPushIcon` in this library.
|
||||||
|
/// It keeps the timeline hover push action visually compact while reusing the same destination menu as the full overdue Push button.
|
||||||
|
class _QuickPushIcon extends StatelessWidget {
|
||||||
|
/// Creates a `_QuickPushIcon` instance with the values required by its invariants.
|
||||||
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
const _QuickPushIcon({
|
||||||
|
required this.card,
|
||||||
|
required this.color,
|
||||||
|
required this.metrics,
|
||||||
|
required this.isRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stores the `card` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final TimelineCardModel card;
|
||||||
|
|
||||||
|
/// Stores the `color` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Stores the `metrics` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final _CardMetrics metrics;
|
||||||
|
|
||||||
|
/// Stores the `isRunning` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final bool isRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
|
/// Builds the widget subtree for this component.
|
||||||
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final options = _pushDestinationOptions(
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
|
);
|
||||||
|
final enabled = !isRunning && _hasEnabledPushDestination(options);
|
||||||
|
return PopupMenuButton<_PastTaskPushDestination>(
|
||||||
|
key: ValueKey('${card.id}-quick-push-button'),
|
||||||
|
tooltip: 'Push task',
|
||||||
|
enabled: enabled,
|
||||||
|
color: const Color(0xFF122018),
|
||||||
|
elevation: 8,
|
||||||
|
position: PopupMenuPosition.under,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onSelected: (destination) => _selectPushDestination(destination, options),
|
||||||
|
itemBuilder: (context) => _pushDestinationMenuItems(options),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: enabled,
|
||||||
|
label: 'Push ${card.title}',
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: metrics.actionButtonSize,
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
color: color,
|
||||||
|
size: metrics.actionIconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,21 @@ class _QuickActions extends StatelessWidget {
|
||||||
/// Creates a `_QuickActions` instance with the values required by its invariants.
|
/// Creates a `_QuickActions` instance with the values required by its invariants.
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
const _QuickActions({
|
const _QuickActions({
|
||||||
|
required this.card,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.backgroundColor,
|
required this.backgroundColor,
|
||||||
required this.metrics,
|
required this.metrics,
|
||||||
required this.visible,
|
required this.visible,
|
||||||
|
required this.pushRunning,
|
||||||
|
this.onPushToNext,
|
||||||
|
this.onPushToTomorrow,
|
||||||
|
this.onPushToBacklog,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Stores the `card` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final TimelineCardModel card;
|
||||||
|
|
||||||
/// Stores the `color` value for this object.
|
/// Stores the `color` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
@ -31,6 +40,19 @@ class _QuickActions extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final bool visible;
|
final bool visible;
|
||||||
|
|
||||||
|
/// Stores the `pushRunning` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final bool pushRunning;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to next is selected.
|
||||||
|
final Future<void> Function()? onPushToNext;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to tomorrow is selected.
|
||||||
|
final Future<void> Function()? onPushToTomorrow;
|
||||||
|
|
||||||
|
/// Callback invoked when Push to backlog is selected.
|
||||||
|
final Future<void> Function()? onPushToBacklog;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -41,6 +63,7 @@ class _QuickActions extends StatelessWidget {
|
||||||
ignoring: !visible,
|
ignoring: !visible,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
|
key: ValueKey('${card.id}-quick-actions-background'),
|
||||||
width: visible ? metrics.actionsWidth : 0,
|
width: visible ? metrics.actionsWidth : 0,
|
||||||
height: metrics.actionButtonSize,
|
height: metrics.actionButtonSize,
|
||||||
decoration: BoxDecoration(color: backgroundColor),
|
decoration: BoxDecoration(color: backgroundColor),
|
||||||
|
|
@ -51,29 +74,31 @@ class _QuickActions extends StatelessWidget {
|
||||||
maxWidth: metrics.actionsWidth,
|
maxWidth: metrics.actionsWidth,
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
|
key: ValueKey('${card.id}-quick-actions-opacity'),
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 90),
|
duration: const Duration(milliseconds: 90),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_NoOpIcon(
|
_QuickPushIcon(
|
||||||
icon: Icons.check,
|
card: card,
|
||||||
color: color,
|
|
||||||
metrics: metrics,
|
|
||||||
),
|
|
||||||
_NoOpIcon(
|
|
||||||
icon: Icons.arrow_forward,
|
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
|
isRunning: pushRunning,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
),
|
),
|
||||||
_NoOpIcon(
|
_NoOpIcon(
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
|
tooltip: 'Schedule',
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
),
|
),
|
||||||
_NoOpIcon(
|
_NoOpIcon(
|
||||||
icon: Icons.wb_sunny_outlined,
|
icon: Icons.wb_sunny_outlined,
|
||||||
|
tooltip: 'Protect time',
|
||||||
color: color,
|
color: color,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
part of '../task_timeline_card.dart';
|
part of '../task_timeline_card.dart';
|
||||||
|
|
||||||
/// Private implementation type for `_StatusRing` in this library.
|
/// Private implementation type for `_StatusRing` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It adapts timeline card sizing and labels to the shared status-circle button.
|
||||||
class _StatusRing extends StatelessWidget {
|
class _StatusRing extends StatelessWidget {
|
||||||
/// Creates a `_StatusRing` instance with the values required by its invariants.
|
/// Creates a `_StatusRing` instance with the values required by its invariants.
|
||||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||||
|
|
@ -35,43 +35,18 @@ class _StatusRing extends StatelessWidget {
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final border = Border.all(color: color, width: metrics.statusBorderWidth);
|
return StatusCircleButton(
|
||||||
final ring = Container(
|
circleKey: ValueKey('${card.id}-status'),
|
||||||
key: ValueKey('${card.id}-status'),
|
color: color,
|
||||||
width: metrics.statusRingSize,
|
checkColor: FocusFlowTokens.appBackground,
|
||||||
height: metrics.statusRingSize,
|
isCompleted: card.isCompleted,
|
||||||
decoration: BoxDecoration(
|
size: metrics.statusRingSize,
|
||||||
shape: BoxShape.circle,
|
borderWidth: metrics.statusBorderWidth,
|
||||||
color: card.isCompleted ? color : Colors.transparent,
|
iconSize: metrics.statusIconSize,
|
||||||
border: border,
|
semanticLabel: card.isCompleted
|
||||||
),
|
? 'Mark ${card.title} not done'
|
||||||
child: card.isCompleted
|
: 'Mark ${card.title} complete',
|
||||||
? Icon(
|
onPressed: onPressed,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,15 @@ class _TrailingControls extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
if (card.showsQuickActions)
|
if (card.showsQuickActions)
|
||||||
_QuickActions(
|
_QuickActions(
|
||||||
|
card: card,
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.background.withValues(alpha: 1),
|
backgroundColor: colors.background.withValues(alpha: 1),
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
visible: actionsVisible,
|
visible: actionsVisible,
|
||||||
|
pushRunning: pushRunning,
|
||||||
|
onPushToNext: onPushToNext,
|
||||||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
|
onPushToBacklog: onPushToBacklog,
|
||||||
),
|
),
|
||||||
if (card.showPastTaskPushButton) ...[
|
if (card.showPastTaskPushButton) ...[
|
||||||
_PastTaskPushButton(
|
_PastTaskPushButton(
|
||||||
|
|
@ -99,6 +104,86 @@ enum _PastTaskPushDestination {
|
||||||
backlog,
|
backlog,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Menu item model for push destination options.
|
||||||
|
class _PushDestinationOption {
|
||||||
|
/// Creates one push destination menu option.
|
||||||
|
const _PushDestinationOption({
|
||||||
|
required this.destination,
|
||||||
|
required this.label,
|
||||||
|
required this.callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Destination selected by this option.
|
||||||
|
final _PastTaskPushDestination destination;
|
||||||
|
|
||||||
|
/// Visible label for this option.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Callback invoked when this option is selected.
|
||||||
|
final Future<void> Function()? callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [callback] for [destination] when that destination has a callback.
|
||||||
|
void _selectPushDestination(
|
||||||
|
_PastTaskPushDestination destination,
|
||||||
|
List<_PushDestinationOption> options,
|
||||||
|
) {
|
||||||
|
for (final option in options) {
|
||||||
|
if (option.destination != destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callback = option.callback;
|
||||||
|
if (callback != null) {
|
||||||
|
unawaited(callback());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds push destination menu options.
|
||||||
|
List<_PushDestinationOption> _pushDestinationOptions({
|
||||||
|
required Future<void> Function()? onPushToNext,
|
||||||
|
required Future<void> Function()? onPushToTomorrow,
|
||||||
|
required Future<void> Function()? onPushToBacklog,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.next,
|
||||||
|
label: 'Push to next',
|
||||||
|
callback: onPushToNext,
|
||||||
|
),
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.tomorrow,
|
||||||
|
label: 'Push to tomorrow',
|
||||||
|
callback: onPushToTomorrow,
|
||||||
|
),
|
||||||
|
_PushDestinationOption(
|
||||||
|
destination: _PastTaskPushDestination.backlog,
|
||||||
|
label: 'Push to backlog',
|
||||||
|
callback: onPushToBacklog,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any push destination option has an active callback.
|
||||||
|
bool _hasEnabledPushDestination(List<_PushDestinationOption> options) {
|
||||||
|
return options.any((option) => option.callback != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds popup menu entries for push destination options.
|
||||||
|
List<PopupMenuEntry<_PastTaskPushDestination>> _pushDestinationMenuItems(
|
||||||
|
List<_PushDestinationOption> options,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
for (final option in options)
|
||||||
|
PopupMenuItem<_PastTaskPushDestination>(
|
||||||
|
value: option.destination,
|
||||||
|
enabled: option.callback != null,
|
||||||
|
child: Text(option.label),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Private implementation type for `_PastTaskPushButton` in this library.
|
/// Private implementation type for `_PastTaskPushButton` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||||
class _PastTaskPushButton extends StatelessWidget {
|
class _PastTaskPushButton extends StatelessWidget {
|
||||||
|
|
@ -148,11 +233,12 @@ class _PastTaskPushButton extends StatelessWidget {
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled =
|
final options = _pushDestinationOptions(
|
||||||
!isRunning &&
|
onPushToNext: onPushToNext,
|
||||||
(onPushToNext != null ||
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
onPushToTomorrow != null ||
|
onPushToBacklog: onPushToBacklog,
|
||||||
onPushToBacklog != null);
|
);
|
||||||
|
final enabled = !isRunning && _hasEnabledPushDestination(options);
|
||||||
return PopupMenuButton<_PastTaskPushDestination>(
|
return PopupMenuButton<_PastTaskPushDestination>(
|
||||||
key: ValueKey('${card.id}-push-button'),
|
key: ValueKey('${card.id}-push-button'),
|
||||||
tooltip: card.pastTaskPushSemanticLabel,
|
tooltip: card.pastTaskPushSemanticLabel,
|
||||||
|
|
@ -160,45 +246,8 @@ class _PastTaskPushButton extends StatelessWidget {
|
||||||
color: const Color(0xFF122018),
|
color: const Color(0xFF122018),
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
position: PopupMenuPosition.under,
|
position: PopupMenuPosition.under,
|
||||||
onSelected: (destination) {
|
onSelected: (destination) => _selectPushDestination(destination, options),
|
||||||
switch (destination) {
|
itemBuilder: (context) => _pushDestinationMenuItems(options),
|
||||||
case _PastTaskPushDestination.next:
|
|
||||||
final callback = onPushToNext;
|
|
||||||
if (callback != null) {
|
|
||||||
unawaited(callback());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case _PastTaskPushDestination.tomorrow:
|
|
||||||
final callback = onPushToTomorrow;
|
|
||||||
if (callback != null) {
|
|
||||||
unawaited(callback());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case _PastTaskPushDestination.backlog:
|
|
||||||
final callback = onPushToBacklog;
|
|
||||||
if (callback != null) {
|
|
||||||
unawaited(callback());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem<_PastTaskPushDestination>(
|
|
||||||
value: _PastTaskPushDestination.next,
|
|
||||||
enabled: onPushToNext != null,
|
|
||||||
child: const Text('Push to next'),
|
|
||||||
),
|
|
||||||
PopupMenuItem<_PastTaskPushDestination>(
|
|
||||||
value: _PastTaskPushDestination.tomorrow,
|
|
||||||
enabled: onPushToTomorrow != null,
|
|
||||||
child: const Text('Push to tomorrow'),
|
|
||||||
),
|
|
||||||
PopupMenuItem<_PastTaskPushDestination>(
|
|
||||||
value: _PastTaskPushDestination.backlog,
|
|
||||||
enabled: onPushToBacklog != null,
|
|
||||||
child: const Text('Push to backlog'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
|
|
||||||
import '../../models/today_screen_models.dart';
|
import '../../models/today_screen_models.dart';
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
import '../status_circle_button.dart';
|
||||||
import 'difficulty_bars.dart';
|
import 'difficulty_bars.dart';
|
||||||
import 'reward_icon.dart';
|
import 'reward_icon.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class TimelineView extends StatefulWidget {
|
||||||
this.now,
|
this.now,
|
||||||
this.scrollTargetCardId,
|
this.scrollTargetCardId,
|
||||||
this.scrollRequest = 0,
|
this.scrollRequest = 0,
|
||||||
|
this.scrollToNowRequest = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,6 +69,9 @@ class TimelineView extends StatefulWidget {
|
||||||
/// Monotonic request number that allows repeated jumps to the same card.
|
/// Monotonic request number that allows repeated jumps to the same card.
|
||||||
final int scrollRequest;
|
final int scrollRequest;
|
||||||
|
|
||||||
|
/// Monotonic request number that allows repeated jumps to current time.
|
||||||
|
final int scrollToNowRequest;
|
||||||
|
|
||||||
/// Creates the mutable state object used by this stateful widget.
|
/// Creates the mutable state object used by this stateful widget.
|
||||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
var _handledScrollRequest = 0;
|
var _handledScrollRequest = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_handledScrollToNowRequest` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
var _handledScrollToNowRequest = 0;
|
||||||
|
|
||||||
/// Private state stored as `_geometry` for this implementation.
|
/// Private state stored as `_geometry` for this implementation.
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
late TimelineGeometry _geometry;
|
late TimelineGeometry _geometry;
|
||||||
|
|
@ -86,6 +90,7 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
_scheduleInitialScroll(_geometry, constraints.maxHeight);
|
||||||
|
_scheduleCurrentTimeScroll(_geometry, constraints.maxHeight);
|
||||||
_scheduleTargetScroll(_geometry);
|
_scheduleTargetScroll(_geometry);
|
||||||
return ScrollConfiguration(
|
return ScrollConfiguration(
|
||||||
behavior: const _TimelineScrollBehavior(),
|
behavior: const _TimelineScrollBehavior(),
|
||||||
|
|
@ -237,21 +242,31 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final now = widget.now?.call() ?? DateTime.now();
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
final currentMinute = now.hour * 60 + now.minute;
|
_jumpToCurrentTime(geometry, viewportHeight, now);
|
||||||
final clampedMinute = currentMinute
|
|
||||||
.clamp(geometry.startMinutes, geometry.endMinutes)
|
|
||||||
.toInt();
|
|
||||||
final centeredOffset =
|
|
||||||
geometry.yForMinutesSinceMidnight(clampedMinute) +
|
|
||||||
_topInset -
|
|
||||||
viewportHeight / 2;
|
|
||||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
||||||
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
|
||||||
_scrollController.jumpTo(offset);
|
|
||||||
_didSetInitialScroll = true;
|
_didSetInitialScroll = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scheduleCurrentTimeScroll` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _scheduleCurrentTimeScroll(
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
double viewportHeight,
|
||||||
|
) {
|
||||||
|
if (widget.scrollToNowRequest == _handledScrollToNowRequest ||
|
||||||
|
!viewportHeight.isFinite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = widget.now?.call() ?? DateTime.now();
|
||||||
|
_jumpToCurrentTime(geometry, viewportHeight, now);
|
||||||
|
_handledScrollToNowRequest = widget.scrollToNowRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the `_scheduleTargetScroll` helper used inside this library.
|
/// Runs the `_scheduleTargetScroll` helper used inside this library.
|
||||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||||
|
|
@ -284,4 +299,40 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
_handledScrollRequest = widget.scrollRequest;
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the `_jumpToCurrentTime` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _jumpToCurrentTime(
|
||||||
|
TimelineGeometry geometry,
|
||||||
|
double viewportHeight,
|
||||||
|
DateTime now,
|
||||||
|
) {
|
||||||
|
final currentMinute = now.hour * 60 + now.minute;
|
||||||
|
final clampedMinute = currentMinute
|
||||||
|
.clamp(geometry.startMinutes, geometry.endMinutes)
|
||||||
|
.toInt();
|
||||||
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||||
|
final offset = _scrollOffsetForMinute(
|
||||||
|
geometry: geometry,
|
||||||
|
minute: clampedMinute,
|
||||||
|
viewportHeight: viewportHeight,
|
||||||
|
maxOffset: maxOffset,
|
||||||
|
);
|
||||||
|
_scrollController.jumpTo(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_scrollOffsetForMinute` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
double _scrollOffsetForMinute({
|
||||||
|
required TimelineGeometry geometry,
|
||||||
|
required int minute,
|
||||||
|
required double viewportHeight,
|
||||||
|
required double maxOffset,
|
||||||
|
}) {
|
||||||
|
final targetOffset =
|
||||||
|
geometry.yForMinutesSinceMidnight(minute) +
|
||||||
|
_topInset -
|
||||||
|
viewportHeight * 0.25;
|
||||||
|
return targetOffset.clamp(0, maxOffset).toDouble();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class TopBar extends StatelessWidget {
|
||||||
this.onPreviousDate,
|
this.onPreviousDate,
|
||||||
this.onNextDate,
|
this.onNextDate,
|
||||||
this.onDatePressed,
|
this.onDatePressed,
|
||||||
|
this.onDateLabelPressed,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,6 +37,9 @@ class TopBar extends StatelessWidget {
|
||||||
/// Callback invoked when the choose-date control is activated.
|
/// Callback invoked when the choose-date control is activated.
|
||||||
final VoidCallback? onDatePressed;
|
final VoidCallback? onDatePressed;
|
||||||
|
|
||||||
|
/// Callback invoked when the displayed date label is activated.
|
||||||
|
final VoidCallback? onDateLabelPressed;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -78,13 +82,13 @@ class TopBar extends StatelessWidget {
|
||||||
width: metrics.dateWidth,
|
width: metrics.dateWidth,
|
||||||
height: metrics.controlHeight,
|
height: metrics.controlHeight,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: 'Choose date',
|
message: 'Scroll to current time',
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Choose date, $dateLabel',
|
label: 'Scroll to current time, $dateLabel',
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
key: const ValueKey('top-bar-date-button'),
|
key: const ValueKey('top-bar-date-button'),
|
||||||
onPressed: onDatePressed,
|
onPressed: onDateLabelPressed,
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ void main() {
|
||||||
testWidgets('date picker jumps directly to the chosen date', (tester) async {
|
testWidgets('date picker jumps directly to the chosen date', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
await tester.tap(find.byTooltip('Choose date'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(DatePickerDialog), findsOneWidget);
|
expect(find.byType(DatePickerDialog), findsOneWidget);
|
||||||
|
|
@ -49,6 +49,18 @@ void main() {
|
||||||
expect(find.text('Clean coffee maker'), findsNothing);
|
expect(find.text('Clean coffee maker'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('date label scrolls timeline instead of opening picker', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(DatePickerDialog), findsNothing);
|
||||||
|
expect(find.text('May 20, 2025'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('open modal closes when changing dates', (tester) async {
|
testWidgets('open modal closes when changing dates', (tester) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
||||||
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
||||||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
import 'package:focus_flow_flutter/widgets/required_banner.dart';
|
||||||
|
import 'package:focus_flow_flutter/widgets/task_selection_modal.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
|
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
|
import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart';
|
||||||
|
|
@ -95,7 +96,7 @@ void main() {
|
||||||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('task modal opens closes and keeps action buttons inert', (
|
testWidgets('task modal opens closes and keeps compact actions inert', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await _pumpPlanApp(tester);
|
await _pumpPlanApp(tester);
|
||||||
|
|
@ -109,14 +110,18 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
expect(find.textContaining('Required'), findsWidgets);
|
expect(find.textContaining('Required'), findsWidgets);
|
||||||
expect(find.text('Reward level 2'), findsOneWidget);
|
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||||
expect(find.text('Medium effort'), findsOneWidget);
|
expect(find.byTooltip('Medium effort'), findsOneWidget);
|
||||||
expect(find.text('Done'), findsOneWidget);
|
expect(find.byTooltip('Mark complete'), findsNothing);
|
||||||
|
expect(find.text('Reward level 2'), findsNothing);
|
||||||
|
expect(find.text('Medium effort'), findsNothing);
|
||||||
|
expect(find.text('Done'), findsNothing);
|
||||||
expect(find.text('Push'), findsWidgets);
|
expect(find.text('Push'), findsWidgets);
|
||||||
expect(find.text('Move to Backlog'), findsOneWidget);
|
expect(find.text('Move to Backlog'), findsNothing);
|
||||||
|
expect(find.text('Backlog'), findsWidgets);
|
||||||
expect(find.text('Break up'), findsOneWidget);
|
expect(find.text('Break up'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Done'));
|
await tester.tap(find.text('Break up'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
|
|
@ -137,6 +142,91 @@ void main() {
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
expect(find.text('Pay bill'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('task modal Push exposes destination labels and callbacks', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var nextCount = 0;
|
||||||
|
var tomorrowCount = 0;
|
||||||
|
var backlogCount = 0;
|
||||||
|
|
||||||
|
Future<void> openMenu() async {
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('modal-push-modal-push-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pumpConstrainedWidget(
|
||||||
|
tester,
|
||||||
|
size: const Size(640, 380),
|
||||||
|
child: TaskSelectionModal(
|
||||||
|
card: _timelineCard(
|
||||||
|
id: 'modal-push',
|
||||||
|
title: 'Modal push task',
|
||||||
|
rewardIconToken: TimelineRewardIconToken.notSet,
|
||||||
|
difficultyIconToken: TimelineDifficultyIconToken.notSet,
|
||||||
|
),
|
||||||
|
onClose: () {},
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
nextCount += 1;
|
||||||
|
},
|
||||||
|
onPushToTomorrow: (_) async {
|
||||||
|
tomorrowCount += 1;
|
||||||
|
},
|
||||||
|
onPushToBacklog: (_) async {
|
||||||
|
backlogCount += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Not Set'), findsNWidgets(2));
|
||||||
|
expect(find.byTooltip('Push task'), findsNothing);
|
||||||
|
expect(find.text('Remove'), findsOneWidget);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
expect(find.text('Push to next'), findsOneWidget);
|
||||||
|
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||||
|
expect(find.text('Push to backlog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to tomorrow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tomorrowCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to backlog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(backlogCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('task modal Remove deletes task and closes modal', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final composition = _composition();
|
||||||
|
await _pumpPlanApp(tester, composition: composition);
|
||||||
|
|
||||||
|
await _scrollIntoView(tester, const ValueKey('water-plants'));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('water-plants')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
|
expect(find.text('Water plants'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Remove'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
|
expect(find.text('Water plants'), findsNothing);
|
||||||
|
expect(
|
||||||
|
composition.store.currentTasks.any((task) => task.id == 'water-plants'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Today screen timeline range covers the full day', () {
|
test('Today screen timeline range covers the full day', () {
|
||||||
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
expect(TodayScreenData.defaultRange.startMinutes, 0);
|
||||||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||||
|
|
@ -198,7 +288,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'timeline defaults near the current time while keeping full day',
|
'timeline defaults with current time one quarter down the viewport',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -218,10 +308,48 @@ void main() {
|
||||||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||||
final position = scrollable.position;
|
final position = scrollable.position;
|
||||||
expect(position.maxScrollExtent, greaterThan(3000));
|
expect(position.maxScrollExtent, greaterThan(3000));
|
||||||
expect(position.pixels, closeTo(1576, 1));
|
expect(position.pixels, closeTo(1676, 1));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('timeline scroll-to-now request reuses initial scroll math', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var request = 0;
|
||||||
|
var currentTime = DateTime(2026, 6, 30, 12);
|
||||||
|
late StateSetter updateTimeline;
|
||||||
|
|
||||||
|
await _pumpConstrainedWidget(
|
||||||
|
tester,
|
||||||
|
size: const Size(680, 400),
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
updateTimeline = setState;
|
||||||
|
return TimelineView(
|
||||||
|
cards: const [],
|
||||||
|
range: TodayScreenData.defaultRange,
|
||||||
|
now: () => currentTime,
|
||||||
|
scrollToNowRequest: request,
|
||||||
|
onCardSelected: (_) {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||||
|
expect(scrollable.position.pixels, closeTo(1676, 1));
|
||||||
|
|
||||||
|
scrollable.position.jumpTo(0);
|
||||||
|
await tester.pump();
|
||||||
|
updateTimeline(() {
|
||||||
|
currentTime = DateTime(2026, 6, 30, 18);
|
||||||
|
request += 1;
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(scrollable.position.pixels, closeTo(2558, 1));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('timeline hour labels stay on one line', (tester) async {
|
testWidgets('timeline hour labels stay on one line', (tester) async {
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -430,6 +558,7 @@ void main() {
|
||||||
var previousCount = 0;
|
var previousCount = 0;
|
||||||
var nextCount = 0;
|
var nextCount = 0;
|
||||||
var datePressedCount = 0;
|
var datePressedCount = 0;
|
||||||
|
var dateLabelPressedCount = 0;
|
||||||
|
|
||||||
await _pumpConstrainedWidget(
|
await _pumpConstrainedWidget(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -445,18 +574,24 @@ void main() {
|
||||||
onDatePressed: () {
|
onDatePressed: () {
|
||||||
datePressedCount += 1;
|
datePressedCount += 1;
|
||||||
},
|
},
|
||||||
|
onDateLabelPressed: () {
|
||||||
|
dateLabelPressedCount += 1;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Previous day'));
|
await tester.tap(find.byTooltip('Previous day'));
|
||||||
await tester.tap(find.byTooltip('Next day'));
|
await tester.tap(find.byTooltip('Next day'));
|
||||||
|
await tester.tap(find.byTooltip('Choose date'));
|
||||||
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
await tester.tap(find.byKey(const ValueKey('top-bar-date-button')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(previousCount, 1);
|
expect(previousCount, 1);
|
||||||
expect(nextCount, 1);
|
expect(nextCount, 1);
|
||||||
expect(datePressedCount, 1);
|
expect(datePressedCount, 1);
|
||||||
expect(find.byTooltip('Choose date'), findsWidgets);
|
expect(dateLabelPressedCount, 1);
|
||||||
|
expect(find.byTooltip('Choose date'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Scroll to current time'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('required banner scales inside a compact header width', (
|
testWidgets('required banner scales inside a compact header width', (
|
||||||
|
|
@ -515,7 +650,11 @@ void main() {
|
||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
tester
|
||||||
|
.widget<AnimatedOpacity>(
|
||||||
|
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -526,11 +665,15 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
|
tester
|
||||||
|
.widget<AnimatedOpacity>(
|
||||||
|
find.byKey(const ValueKey('narrow-actions-quick-actions-opacity')),
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
final actionBackground = tester.widget<AnimatedContainer>(
|
final actionBackground = tester.widget<AnimatedContainer>(
|
||||||
find.byType(AnimatedContainer),
|
find.byKey(const ValueKey('narrow-actions-quick-actions-background')),
|
||||||
);
|
);
|
||||||
final decoration = actionBackground.decoration;
|
final decoration = actionBackground.decoration;
|
||||||
expect(decoration, isA<BoxDecoration>());
|
expect(decoration, isA<BoxDecoration>());
|
||||||
|
|
@ -669,6 +812,64 @@ void main() {
|
||||||
expect(backlogCount, 1);
|
expect(backlogCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('hover arrow exposes the same Push destination menu', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var nextCount = 0;
|
||||||
|
var tomorrowCount = 0;
|
||||||
|
var backlogCount = 0;
|
||||||
|
|
||||||
|
Future<void> openMenu() async {
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('quick-push-menu-quick-push-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pumpTimelineCard(
|
||||||
|
tester,
|
||||||
|
card: _timelineCard(id: 'quick-push-menu', title: 'Current task'),
|
||||||
|
size: const Size(420, 88),
|
||||||
|
onPushToNext: (_) async {
|
||||||
|
nextCount += 1;
|
||||||
|
},
|
||||||
|
onPushToTomorrow: (_) async {
|
||||||
|
tomorrowCount += 1;
|
||||||
|
},
|
||||||
|
onPushToBacklog: (_) async {
|
||||||
|
backlogCount += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await gesture.addPointer(
|
||||||
|
location: tester.getCenter(find.byKey(const ValueKey('quick-push-menu'))),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.check), findsNothing);
|
||||||
|
await openMenu();
|
||||||
|
expect(find.text('Push to next'), findsOneWidget);
|
||||||
|
expect(find.text('Push to tomorrow'), findsOneWidget);
|
||||||
|
expect(find.text('Push to backlog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Push to next'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to tomorrow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tomorrowCount, 1);
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
await tester.tap(find.text('Push to backlog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(backlogCount, 1);
|
||||||
|
|
||||||
|
await gesture.removePointer();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Push menu dismisses on outside click without callback', (
|
testWidgets('Push menu dismisses on outside click without callback', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -780,6 +981,33 @@ void main() {
|
||||||
await _pumpPlanApp(tester, composition: composition);
|
await _pumpPlanApp(tester, composition: composition);
|
||||||
|
|
||||||
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
|
await _scrollIntoView(tester, const ValueKey('clean-coffee-maker'));
|
||||||
|
var statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isEmpty);
|
||||||
|
|
||||||
|
final hover = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await hover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await hover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final beforeClick = DateTime.now().toUtc();
|
final beforeClick = DateTime.now().toUtc();
|
||||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -797,6 +1025,26 @@ void main() {
|
||||||
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
expect(completedTask.completedAt!.isAfter(afterClick), isFalse);
|
||||||
expect(find.textContaining('Completed at:'), findsWidgets);
|
expect(find.textContaining('Completed at:'), findsWidgets);
|
||||||
|
|
||||||
|
final completedHover = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await completedHover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
statusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('clean-coffee-maker-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(statusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await completedHover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -825,6 +1073,32 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||||
expect(find.textContaining('Completed -'), findsNothing);
|
expect(find.textContaining('Completed -'), findsNothing);
|
||||||
|
var modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isEmpty);
|
||||||
|
|
||||||
|
final hover = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await hover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await hover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -839,6 +1113,26 @@ void main() {
|
||||||
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.check), findsWidgets);
|
expect(find.byIcon(Icons.check), findsWidgets);
|
||||||
|
|
||||||
|
final completedHover = await tester.createGesture(
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await completedHover.addPointer(
|
||||||
|
location: tester.getCenter(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
modalStatusDecoration =
|
||||||
|
tester
|
||||||
|
.widget<AnimatedContainer>(
|
||||||
|
find.byKey(const ValueKey('task-modal-status')),
|
||||||
|
)
|
||||||
|
.decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
expect(modalStatusDecoration.boxShadow, isNotEmpty);
|
||||||
|
await completedHover.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
await tester.tap(find.byKey(const ValueKey('task-modal-status')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -994,6 +1288,9 @@ TimelineCardModel _timelineCard({
|
||||||
bool showPastTaskPushButton = false,
|
bool showPastTaskPushButton = false,
|
||||||
bool isCompleted = false,
|
bool isCompleted = false,
|
||||||
String? completedTimeText,
|
String? completedTimeText,
|
||||||
|
TimelineRewardIconToken rewardIconToken = TimelineRewardIconToken.medium,
|
||||||
|
TimelineDifficultyIconToken difficultyIconToken =
|
||||||
|
TimelineDifficultyIconToken.medium,
|
||||||
}) {
|
}) {
|
||||||
final resolvedTaskType =
|
final resolvedTaskType =
|
||||||
taskType ??
|
taskType ??
|
||||||
|
|
@ -1014,8 +1311,8 @@ TimelineCardModel _timelineCard({
|
||||||
endMinutes: endMinutes,
|
endMinutes: endMinutes,
|
||||||
taskType: resolvedTaskType,
|
taskType: resolvedTaskType,
|
||||||
visualKind: visualKind,
|
visualKind: visualKind,
|
||||||
rewardIconToken: TimelineRewardIconToken.medium,
|
rewardIconToken: rewardIconToken,
|
||||||
difficultyIconToken: TimelineDifficultyIconToken.medium,
|
difficultyIconToken: difficultyIconToken,
|
||||||
completedTimeText: completedTimeText,
|
completedTimeText: completedTimeText,
|
||||||
isCompleted: isCompleted,
|
isCompleted: isCompleted,
|
||||||
isSelectable: true,
|
isSelectable: true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue