fix: hide task quick actions until hover

This commit is contained in:
Ashley Venn 2026-06-30 13:18:50 -07:00
parent dacd189435
commit 5c6649945d
2 changed files with 137 additions and 67 deletions

View file

@ -11,7 +11,7 @@ import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';
/// Interactive card that renders a scheduled task on the compact timeline. /// Interactive card that renders a scheduled task on the compact timeline.
class TaskTimelineCard extends StatelessWidget { class TaskTimelineCard extends StatefulWidget {
/// Creates a task timeline card. /// Creates a task timeline card.
const TaskTimelineCard({required this.card, required this.onTap, super.key}); 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. /// Callback invoked when the card is tapped.
final VoidCallback onTap; final VoidCallback onTap;
@override
State<TaskTimelineCard> createState() => _TaskTimelineCardState();
}
class _TaskTimelineCardState extends State<TaskTimelineCard> {
bool _isHovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final card = widget.card;
final colors = _CardColors.forKind(card.visualKind); final colors = _CardColors.forKind(card.visualKind);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -30,84 +38,79 @@ class TaskTimelineCard extends StatelessWidget {
constraints, constraints,
kind: card.visualKind, kind: card.visualKind,
); );
return Material( return MouseRegion(
color: Colors.transparent, onEnter: (_) {
child: InkWell( setState(() {
key: ValueKey(card.id), _isHovered = true;
borderRadius: BorderRadius.circular(metrics.cardRadius), });
onTap: onTap, },
child: Container( onExit: (_) {
padding: metrics.padding, setState(() {
decoration: BoxDecoration( _isHovered = false;
color: colors.background, });
borderRadius: BorderRadius.circular(metrics.cardRadius), },
border: Border.all( child: Material(
color: colors.accent, color: Colors.transparent,
width: metrics.borderWidth, child: InkWell(
), key: ValueKey(card.id),
boxShadow: [ borderRadius: BorderRadius.circular(metrics.cardRadius),
BoxShadow( onTap: widget.onTap,
color: colors.accent.withValues(alpha: 0.12), child: Container(
blurRadius: metrics.shadowBlur, padding: metrics.padding,
spreadRadius: metrics.shadowSpread, decoration: BoxDecoration(
), color: colors.background,
], borderRadius: BorderRadius.circular(metrics.cardRadius),
), border: Border.all(
child: Row(
children: [
_StatusRing(
card: card,
color: colors.accent, color: colors.accent,
metrics: metrics, width: metrics.borderWidth,
), ),
SizedBox(width: metrics.statusGap), boxShadow: [
Expanded( BoxShadow(
child: _CardText( color: colors.accent.withValues(alpha: 0.12),
blurRadius: metrics.shadowBlur,
spreadRadius: metrics.shadowSpread,
),
],
),
child: Row(
children: [
_StatusRing(
card: card, card: card,
color: colors.accent, color: colors.accent,
metrics: metrics, metrics: metrics,
), ),
), SizedBox(width: metrics.statusGap),
if (card.showsQuickActions) ...[ Expanded(
_NoOpIcon( child: _CardText(
icon: Icons.check, card: card,
color: colors.accent, color: colors.accent,
metrics: metrics, metrics: metrics,
),
), ),
_NoOpIcon( if (card.showsQuickActions)
icon: Icons.arrow_forward, _QuickActions(
color: colors.accent, color: colors.accent,
metrics: metrics,
visible: _isHovered,
),
_Badge(
metrics: metrics, metrics: metrics,
child: RewardIcon(
color: colors.reward,
size: metrics.rewardIconSize,
),
), ),
_NoOpIcon( SizedBox(width: metrics.badgeGap),
icon: Icons.calendar_today, _Badge(
color: colors.accent,
metrics: metrics, 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 { class _NoOpIcon extends StatelessWidget {
const _NoOpIcon({ const _NoOpIcon({
required this.icon, required this.icon,

View file

@ -1,6 +1,7 @@
/// Tests Widget behavior in the FocusFlow Flutter app. /// Tests Widget behavior in the FocusFlow Flutter app.
library; library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
@ -124,12 +125,29 @@ void main() {
); );
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(
tester.widget<AnimatedOpacity>(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<AnimatedOpacity>(find.byType(AnimatedOpacity)).opacity,
1,
);
final button = tester.widget<IconButton>( final button = tester.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.arrow_forward), find.widgetWithIcon(IconButton, Icons.arrow_forward),
); );
expect(button.iconSize, lessThan(18)); expect(button.iconSize, lessThan(18));
expect(button.constraints?.maxWidth, lessThan(32)); expect(button.constraints?.maxWidth, lessThan(32));
await gesture.removePointer();
}); });
testWidgets('free slot text scales inside a short task bubble', ( testWidgets('free slot text scales inside a short task bubble', (