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,41 +38,13 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
constraints,
kind: card.visualKind,
);
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(
final content = metrics.isCompact
? _CompactCardText(
card: card,
color: colors.accent,
width: metrics.borderWidth,
),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.12),
blurRadius: metrics.shadowBlur,
spreadRadius: metrics.shadowSpread,
),
],
),
child: Row(
metrics: metrics,
)
: Row(
children: [
_StatusRing(
card: card,
@ -110,7 +82,43 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
),
),
],
);
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,
clipBehavior: Clip.antiAlias,
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: content,
),
),
),
@ -121,12 +129,13 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
}
class _CardMetrics {
const _CardMetrics(this.scale);
const _CardMetrics({required this.scale, required this.height});
static const _referenceWidth = 640.0;
static const _referenceHeight = 88.0;
static const _freeSlotReferenceHeight = 108.0;
static const _minimumScale = 0.38;
static const _compactHeightThreshold = 56.0;
static const _minimumScale = 0.16;
factory _CardMetrics.fromConstraints(
BoxConstraints constraints, {
@ -145,15 +154,19 @@ class _CardMetrics {
? _freeSlotReferenceHeight
: _referenceHeight);
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 height;
double get cardRadius => FocusFlowTokens.cardRadius * scale;
double get borderWidth => math.max(1, 1.2 * scale);
double get shadowBlur => 18 * scale;
double get shadowSpread => scale;
bool get isCompact => height < _compactHeightThreshold;
double get cardRadius =>
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 statusBorderWidth => math.max(1.2, 3 * scale);
double get freeSlotStatusBorderWidth => math.max(1, 2 * scale);
@ -171,9 +184,17 @@ class _CardMetrics {
double get metaSize => FocusFlowTokens.cardMetaSize * scale;
double get titleMetaGap => 4 * scale;
double get freeSlotTimeGap => 10 * scale;
double get compactTextGap => math.max(6, 16 * scale);
Size get difficultySize => Size(54 * scale, 34 * scale);
EdgeInsets get padding =>
EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale);
EdgeInsets get padding {
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 {
@ -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 {
const _QuickActions({
required this.color,

View file

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

View file

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

View file

@ -129,6 +129,50 @@ void main() {
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 {
await _pumpPlanApp(tester);
@ -255,8 +299,10 @@ void main() {
expect(waterPlants.width, closeTo(sortMail.width, 1));
expect(sortMail.right, lessThan(startLaundry.left));
expect(waterPlants.right, lessThan(startLaundry.left));
expect(cleanedKitchenEarly.left, greaterThan(cleanCoffee.left));
expect(cleanCoffee.right, lessThan(cleanedKitchenEarly.left));
expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1));
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', (
@ -337,6 +383,29 @@ void main() {
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', (
tester,
) async {
@ -457,6 +526,6 @@ TimelineCardModel _timelineCard({
isCompleted: false,
isSelectable: true,
showsQuickActions: showsQuickActions,
durationMinutes: 30,
durationMinutes: endMinutes - startMinutes,
);
}