From f6afe0d793cc877ed8d29764c5164ce4f03b6fd2 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 15:25:50 -0700 Subject: [PATCH] fix: size timeline tasks to exact duration --- .../widgets/timeline/task_timeline_card.dart | 198 +++++++++++++----- .../widgets/timeline/timeline_geometry.dart | 8 +- .../lib/widgets/timeline/timeline_view.dart | 1 - apps/focus_flow_flutter/test/widget_test.dart | 75 ++++++- 4 files changed, 228 insertions(+), 54 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 d8188b0..94beab4 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 @@ -38,6 +38,51 @@ class _TaskTimelineCardState extends State { constraints, 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( onEnter: (_) { setState(() { @@ -57,6 +102,7 @@ class _TaskTimelineCardState extends State { onTap: widget.onTap, child: Container( padding: metrics.padding, + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: colors.background, borderRadius: BorderRadius.circular(metrics.cardRadius), @@ -72,45 +118,7 @@ class _TaskTimelineCardState extends State { ), ], ), - child: 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, - ), - ), - ], - ), + child: content, ), ), ), @@ -121,12 +129,13 @@ class _TaskTimelineCardState extends State { } 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, diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart index 9558c3b..e6f806b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -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. diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index fe9ea4f..dcd98d5 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -70,7 +70,6 @@ class _TimelineViewState extends State { startMinutes: widget.range.startMinutes, endMinutes: widget.range.endMinutes, pixelsPerMinute: 2.45, - minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; final placements = _TimelineCardPlacement.pack( diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index cc3b324..d641983 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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, ); }