fix: size timeline tasks to exact duration

This commit is contained in:
Ashley Venn 2026-06-30 15:25:50 -07:00
parent dd6552e68d
commit f6afe0d793
4 changed files with 228 additions and 54 deletions

View file

@ -38,6 +38,51 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
constraints, constraints,
kind: card.visualKind, kind: card.visualKind,
); );
final content = metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
metrics: metrics,
)
: Row(
children: [
_StatusRing(
card: card,
color: colors.accent,
metrics: metrics,
),
SizedBox(width: metrics.statusGap),
Expanded(
child: _CardText(
card: card,
color: colors.accent,
metrics: metrics,
),
),
if (card.showsQuickActions)
_QuickActions(
color: colors.accent,
metrics: metrics,
visible: _isHovered,
),
_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,
),
),
],
);
return MouseRegion( return MouseRegion(
onEnter: (_) { onEnter: (_) {
setState(() { setState(() {
@ -57,6 +102,7 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
onTap: widget.onTap, onTap: widget.onTap,
child: Container( child: Container(
padding: metrics.padding, padding: metrics.padding,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.background, color: colors.background,
borderRadius: BorderRadius.circular(metrics.cardRadius), borderRadius: BorderRadius.circular(metrics.cardRadius),
@ -72,45 +118,7 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
), ),
], ],
), ),
child: Row( child: content,
children: [
_StatusRing(
card: card,
color: colors.accent,
metrics: metrics,
),
SizedBox(width: metrics.statusGap),
Expanded(
child: _CardText(
card: card,
color: colors.accent,
metrics: metrics,
),
),
if (card.showsQuickActions)
_QuickActions(
color: colors.accent,
metrics: metrics,
visible: _isHovered,
),
_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,
),
),
],
),
), ),
), ),
), ),
@ -121,12 +129,13 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
} }
class _CardMetrics { class _CardMetrics {
const _CardMetrics(this.scale); const _CardMetrics({required this.scale, required this.height});
static const _referenceWidth = 640.0; static const _referenceWidth = 640.0;
static const _referenceHeight = 88.0; static const _referenceHeight = 88.0;
static const _freeSlotReferenceHeight = 108.0; static const _freeSlotReferenceHeight = 108.0;
static const _minimumScale = 0.38; static const _compactHeightThreshold = 56.0;
static const _minimumScale = 0.16;
factory _CardMetrics.fromConstraints( factory _CardMetrics.fromConstraints(
BoxConstraints constraints, { BoxConstraints constraints, {
@ -145,15 +154,19 @@ class _CardMetrics {
? _freeSlotReferenceHeight ? _freeSlotReferenceHeight
: _referenceHeight); : _referenceHeight);
final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0); final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0);
return _CardMetrics(scale.toDouble()); return _CardMetrics(scale: scale.toDouble(), height: height);
} }
final double scale; final double scale;
final double height;
double get cardRadius => FocusFlowTokens.cardRadius * scale; bool get isCompact => height < _compactHeightThreshold;
double get borderWidth => math.max(1, 1.2 * scale);
double get shadowBlur => 18 * scale; double get cardRadius =>
double get shadowSpread => scale; math.min(FocusFlowTokens.cardRadius * scale, height / 2);
double get borderWidth => math.max(0.75, 1.2 * scale);
double get shadowBlur => isCompact ? 0 : 18 * scale;
double get shadowSpread => isCompact ? 0 : scale;
double get statusRingSize => 34 * scale; double get statusRingSize => 34 * scale;
double get statusBorderWidth => math.max(1.2, 3 * scale); double get statusBorderWidth => math.max(1.2, 3 * scale);
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
@ -171,9 +184,17 @@ class _CardMetrics {
double get metaSize => FocusFlowTokens.cardMetaSize * scale; double get metaSize => FocusFlowTokens.cardMetaSize * scale;
double get titleMetaGap => 4 * scale; double get titleMetaGap => 4 * scale;
double get freeSlotTimeGap => 10 * scale; double get freeSlotTimeGap => 10 * scale;
double get compactTextGap => math.max(6, 16 * scale);
Size get difficultySize => Size(54 * scale, 34 * scale); Size get difficultySize => Size(54 * scale, 34 * scale);
EdgeInsets get padding => EdgeInsets get padding {
EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); if (isCompact) {
return EdgeInsets.symmetric(
horizontal: math.max(4, 12 * scale),
vertical: math.max(1, 3 * scale),
);
}
return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
}
} }
class _StatusRing extends StatelessWidget { class _StatusRing extends StatelessWidget {
@ -287,6 +308,87 @@ class _CardText extends StatelessWidget {
} }
} }
class _CompactCardText extends StatelessWidget {
const _CompactCardText({
required this.card,
required this.color,
required this.metrics,
});
final TimelineCardModel card;
final Color color;
final _CardMetrics metrics;
@override
Widget build(BuildContext context) {
final metaText = _compactMetaText(card);
final titleStyle = TextStyle(
color: card.isCompleted
? FocusFlowTokens.completedText
: FocusFlowTokens.textPrimary,
fontSize: metrics.titleSize,
height: 1,
fontWeight: FontWeight.w800,
decoration: card.isCompleted ? TextDecoration.lineThrough : null,
decorationColor: FocusFlowTokens.completedText,
decorationThickness: math.max(1, 2 * metrics.scale),
);
final metaStyle = TextStyle(
color: card.isCompleted ? FocusFlowTokens.completedText : color,
fontSize: metrics.metaSize,
height: 1,
fontWeight: FontWeight.w700,
);
return LayoutBuilder(
builder: (context, constraints) {
final line = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 3,
child: Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
SizedBox(width: metrics.compactTextGap),
Flexible(
flex: 2,
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
),
),
],
);
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return line;
}
return FittedBox(
alignment: Alignment.centerLeft,
fit: BoxFit.scaleDown,
child: SizedBox(width: constraints.maxWidth, child: line),
);
},
);
}
String _compactMetaText(TimelineCardModel card) {
if (card.visualKind == TaskVisualKind.freeSlot &&
card.timeText.isNotEmpty) {
return card.timeText;
}
if (card.subtitle.isNotEmpty) {
return card.subtitle;
}
return card.timeText;
}
}
class _QuickActions extends StatelessWidget { class _QuickActions extends StatelessWidget {
const _QuickActions({ const _QuickActions({
required this.color, required this.color,

View file

@ -8,10 +8,11 @@ class TimelineGeometry {
required this.startMinutes, required this.startMinutes,
required this.endMinutes, required this.endMinutes,
required this.pixelsPerMinute, required this.pixelsPerMinute,
this.minCardHeight = 72, this.minCardHeight = 0,
}) : assert(startMinutes >= 0), }) : assert(startMinutes >= 0),
assert(endMinutes <= 24 * 60), assert(endMinutes <= 24 * 60),
assert(endMinutes > startMinutes); assert(endMinutes > startMinutes),
assert(minCardHeight >= 0);
/// First visible minute from midnight. /// First visible minute from midnight.
final int startMinutes; final int startMinutes;
@ -23,6 +24,9 @@ class TimelineGeometry {
final double pixelsPerMinute; final double pixelsPerMinute;
/// Minimum rendered height for a timeline card. /// Minimum rendered height for a timeline card.
///
/// Defaults to zero so scheduled cards match their exact duration on the
/// timeline.
final double minCardHeight; final double minCardHeight;
/// Total vertical height of the visible timeline. /// Total vertical height of the visible timeline.

View file

@ -70,7 +70,6 @@ class _TimelineViewState extends State<TimelineView> {
startMinutes: widget.range.startMinutes, startMinutes: widget.range.startMinutes,
endMinutes: widget.range.endMinutes, endMinutes: widget.range.endMinutes,
pixelsPerMinute: 2.45, pixelsPerMinute: 2.45,
minCardHeight: 88,
); );
final contentHeight = geometry.totalHeight + 24; final contentHeight = geometry.totalHeight + 24;
final placements = _TimelineCardPlacement.pack( final placements = _TimelineCardPlacement.pack(

View file

@ -129,6 +129,50 @@ void main() {
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
}); });
testWidgets('timeline task bubbles match duration height exactly', (
tester,
) async {
await _pumpConstrainedWidget(
tester,
size: const Size(680, 360),
child: TimelineView(
cards: [
_timelineCard(
id: 'five-minute-task',
title: 'Five minute task',
subtitle: '5 min',
startMinutes: 8 * 60,
endMinutes: 8 * 60 + 5,
),
_timelineCard(
id: 'fifteen-minute-task',
title: 'Fifteen minute task',
subtitle: '15 min',
startMinutes: 8 * 60 + 5,
endMinutes: 8 * 60 + 20,
),
],
range: TodayScreenData.defaultRange,
now: () => DateTime(2026, 6, 30, 8),
onCardSelected: (_) {},
),
);
expect(tester.takeException(), isNull);
final fiveMinute = tester.getRect(
find.byKey(const ValueKey('five-minute-task')),
);
final fifteenMinute = tester.getRect(
find.byKey(const ValueKey('fifteen-minute-task')),
);
expect(fiveMinute.height, closeTo(5 * 2.45, 0.5));
expect(fifteenMinute.height, closeTo(15 * 2.45, 0.5));
expect(fiveMinute.left, closeTo(fifteenMinute.left, 1));
expect(fiveMinute.bottom, closeTo(fifteenMinute.top, 1));
});
testWidgets('required banner jumps timeline to linked task', (tester) async { testWidgets('required banner jumps timeline to linked task', (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
@ -255,8 +299,10 @@ void main() {
expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(waterPlants.width, closeTo(sortMail.width, 1));
expect(sortMail.right, lessThan(startLaundry.left)); expect(sortMail.right, lessThan(startLaundry.left));
expect(waterPlants.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left));
expect(cleanedKitchenEarly.left, greaterThan(cleanCoffee.left)); expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
expect(cleanCoffee.right, lessThan(cleanedKitchenEarly.left)); expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5));
expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5));
expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top));
}); });
testWidgets('top bar controls scale inside a compact header width', ( testWidgets('top bar controls scale inside a compact header width', (
@ -337,6 +383,29 @@ void main() {
await gesture.removePointer(); await gesture.removePointer();
}); });
testWidgets('short task card keeps time inline with title', (tester) async {
await _pumpTimelineCard(
tester,
card: _timelineCard(
id: 'short-inline-time',
title: 'Five minute reset',
subtitle: '5 min',
timeText: '6:00 PM - 6:05 PM',
startMinutes: 18 * 60,
endMinutes: 18 * 60 + 5,
),
size: const Size(360, 24),
);
expect(tester.takeException(), isNull);
final title = tester.getRect(find.text('Five minute reset'));
final time = tester.getRect(find.text('5 min'));
expect(title.right, lessThan(time.left));
expect((title.center.dy - time.center.dy).abs(), lessThan(2));
});
testWidgets('free slot text scales inside a short task bubble', ( testWidgets('free slot text scales inside a short task bubble', (
tester, tester,
) async { ) async {
@ -457,6 +526,6 @@ TimelineCardModel _timelineCard({
isCompleted: false, isCompleted: false,
isSelectable: true, isSelectable: true,
showsQuickActions: showsQuickActions, showsQuickActions: showsQuickActions,
durationMinutes: 30, durationMinutes: endMinutes - startMinutes,
); );
} }