From 5c6649945dfc769a13dec2129267699358f2a044 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:18:50 -0700 Subject: [PATCH] fix: hide task quick actions until hover --- .../widgets/timeline/task_timeline_card.dart | 186 +++++++++++------- apps/focus_flow_flutter/test/widget_test.dart | 18 ++ 2 files changed, 137 insertions(+), 67 deletions(-) diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index 9e4ff41..e55676a 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -11,7 +11,7 @@ import 'difficulty_bars.dart'; import 'reward_icon.dart'; /// Interactive card that renders a scheduled task on the compact timeline. -class TaskTimelineCard extends StatelessWidget { +class TaskTimelineCard extends StatefulWidget { /// Creates a task timeline card. const TaskTimelineCard({required this.card, required this.onTap, super.key}); @@ -21,8 +21,16 @@ class TaskTimelineCard extends StatelessWidget { /// Callback invoked when the card is tapped. final VoidCallback onTap; + @override + State createState() => _TaskTimelineCardState(); +} + +class _TaskTimelineCardState extends State { + bool _isHovered = false; + @override Widget build(BuildContext context) { + final card = widget.card; final colors = _CardColors.forKind(card.visualKind); return LayoutBuilder( builder: (context, constraints) { @@ -30,84 +38,79 @@ class TaskTimelineCard extends StatelessWidget { constraints, kind: card.visualKind, ); - return Material( - color: Colors.transparent, - child: InkWell( - key: ValueKey(card.id), - borderRadius: BorderRadius.circular(metrics.cardRadius), - onTap: onTap, - child: Container( - padding: metrics.padding, - decoration: BoxDecoration( - color: colors.background, - borderRadius: BorderRadius.circular(metrics.cardRadius), - border: Border.all( - color: colors.accent, - width: metrics.borderWidth, - ), - boxShadow: [ - BoxShadow( - color: colors.accent.withValues(alpha: 0.12), - blurRadius: metrics.shadowBlur, - spreadRadius: metrics.shadowSpread, - ), - ], - ), - child: Row( - children: [ - _StatusRing( - card: card, + return MouseRegion( + onEnter: (_) { + setState(() { + _isHovered = true; + }); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(card.id), + borderRadius: BorderRadius.circular(metrics.cardRadius), + onTap: widget.onTap, + child: Container( + padding: metrics.padding, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(metrics.cardRadius), + border: Border.all( color: colors.accent, - metrics: metrics, + width: metrics.borderWidth, ), - SizedBox(width: metrics.statusGap), - Expanded( - child: _CardText( + boxShadow: [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.12), + blurRadius: metrics.shadowBlur, + spreadRadius: metrics.shadowSpread, + ), + ], + ), + child: Row( + children: [ + _StatusRing( card: card, color: colors.accent, metrics: metrics, ), - ), - if (card.showsQuickActions) ...[ - _NoOpIcon( - icon: Icons.check, - color: colors.accent, - metrics: metrics, + SizedBox(width: metrics.statusGap), + Expanded( + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), ), - _NoOpIcon( - icon: Icons.arrow_forward, - color: colors.accent, + if (card.showsQuickActions) + _QuickActions( + color: colors.accent, + metrics: metrics, + visible: _isHovered, + ), + _Badge( metrics: metrics, + child: RewardIcon( + color: colors.reward, + size: metrics.rewardIconSize, + ), ), - _NoOpIcon( - icon: Icons.calendar_today, - color: colors.accent, + SizedBox(width: metrics.badgeGap), + _Badge( metrics: metrics, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), ), - _NoOpIcon( - icon: Icons.wb_sunny_outlined, - color: colors.accent, - metrics: metrics, - ), - SizedBox(width: metrics.actionGap), ], - _Badge( - metrics: metrics, - child: RewardIcon( - color: colors.reward, - size: metrics.rewardIconSize, - ), - ), - SizedBox(width: metrics.badgeGap), - _Badge( - metrics: metrics, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ), - ], + ), ), ), ), @@ -284,6 +287,55 @@ class _CardText extends StatelessWidget { } } +class _QuickActions extends StatelessWidget { + const _QuickActions({ + required this.color, + required this.metrics, + required this.visible, + }); + + final Color color; + final _CardMetrics metrics; + final bool visible; + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + excluding: !visible, + child: IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _NoOpIcon(icon: Icons.check, color: color, metrics: metrics), + _NoOpIcon( + icon: Icons.arrow_forward, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.calendar_today, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.wb_sunny_outlined, + color: color, + metrics: metrics, + ), + SizedBox(width: metrics.actionGap), + ], + ), + ), + ), + ); + } +} + class _NoOpIcon extends StatelessWidget { const _NoOpIcon({ required this.icon, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 90960f2..f3b3096 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -1,6 +1,7 @@ /// Tests Widget behavior in the FocusFlow Flutter app. library; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:scheduler_core/scheduler_core.dart'; @@ -124,12 +125,29 @@ void main() { ); expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 0, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byKey(const ValueKey('narrow-actions'))), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 1, + ); final button = tester.widget( find.widgetWithIcon(IconButton, Icons.arrow_forward), ); expect(button.iconSize, lessThan(18)); expect(button.constraints?.maxWidth, lessThan(32)); + + await gesture.removePointer(); }); testWidgets('free slot text scales inside a short task bubble', (