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';
/// 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<TaskTimelineCard> createState() => _TaskTimelineCardState();
}
class _TaskTimelineCardState extends State<TaskTimelineCard> {
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,

View file

@ -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<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>(
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', (