From 4f198600548a902086be20fe7f67bf327b6b2e41 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 14:07:25 -0700 Subject: [PATCH] fix: pack overlapping timeline tasks --- .../widgets/timeline/task_timeline_card.dart | 2 +- .../lib/widgets/timeline/timeline_view.dart | 165 ++++++++++++++++-- apps/focus_flow_flutter/test/widget_test.dart | 57 +++++- 3 files changed, 202 insertions(+), 22 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 e55676a..d8188b0 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 @@ -126,7 +126,7 @@ class _CardMetrics { static const _referenceWidth = 640.0; static const _referenceHeight = 88.0; static const _freeSlotReferenceHeight = 108.0; - static const _minimumScale = 0.62; + static const _minimumScale = 0.38; factory _CardMetrics.fromConstraints( BoxConstraints constraints, { 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 96ccdf4..0d3537f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -73,6 +73,7 @@ class _TimelineViewState extends State { minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; + final placements = _TimelineCardPlacement.pack(widget.cards); return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(geometry, constraints.maxHeight); @@ -87,26 +88,35 @@ class _TimelineViewState extends State { TimelineAxis(geometry: geometry), const SizedBox(width: 18), Expanded( - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: TimelineGrid(geometry: geometry)), - for (final card in widget.cards) - Positioned( - top: geometry.yForMinutesSinceMidnight( - card.startMinutes, + child: LayoutBuilder( + builder: (context, cardConstraints) { + final trackWidth = cardConstraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: TimelineGrid(geometry: geometry), ), - left: FocusFlowTokens.cardHorizontalPadding, - right: 0, - height: geometry.heightForDuration( - card.endMinutes - card.startMinutes, - ), - child: TaskTimelineCard( - card: card, - onTap: () => widget.onCardSelected(card), - ), - ), - ], + for (final placement in placements) + Positioned( + top: geometry.yForMinutesSinceMidnight( + placement.card.startMinutes, + ), + left: placement.left(trackWidth), + width: placement.width(trackWidth), + height: geometry.heightForDuration( + placement.card.endMinutes - + placement.card.startMinutes, + ), + child: TaskTimelineCard( + card: placement.card, + onTap: () => + widget.onCardSelected(placement.card), + ), + ), + ], + ); + }, ), ), ], @@ -171,3 +181,120 @@ class _TimelineViewState extends State { }); } } + +class _TimelineCardPlacement { + const _TimelineCardPlacement({ + required this.card, + required this.lane, + required this.laneCount, + }); + + static const _laneGap = 8.0; + + final TimelineCardModel card; + final int lane; + final int laneCount; + + static List<_TimelineCardPlacement> pack(List cards) { + if (cards.isEmpty) { + return const []; + } + final sorted = [...cards] + ..sort((a, b) { + final startComparison = a.startMinutes.compareTo(b.startMinutes); + if (startComparison != 0) { + return startComparison; + } + final endComparison = a.endMinutes.compareTo(b.endMinutes); + if (endComparison != 0) { + return endComparison; + } + return a.id.compareTo(b.id); + }); + final placements = <_TimelineCardPlacement>[]; + var groupStart = 0; + var groupEnd = sorted.first.endMinutes; + for (var index = 1; index < sorted.length; index += 1) { + final card = sorted[index]; + if (card.startMinutes < groupEnd) { + if (card.endMinutes > groupEnd) { + groupEnd = card.endMinutes; + } + continue; + } + placements.addAll(_packGroup(sorted.sublist(groupStart, index))); + groupStart = index; + groupEnd = card.endMinutes; + } + placements.addAll(_packGroup(sorted.sublist(groupStart))); + return placements; + } + + static List<_TimelineCardPlacement> _packGroup( + List group, + ) { + final laneEnds = []; + final laneByCard = {}; + for (final card in group) { + var assignedLane = -1; + for (var lane = 0; lane < laneEnds.length; lane += 1) { + if (laneEnds[lane] <= card.startMinutes) { + assignedLane = lane; + break; + } + } + if (assignedLane == -1) { + assignedLane = laneEnds.length; + laneEnds.add(card.endMinutes); + } else { + laneEnds[assignedLane] = card.endMinutes; + } + laneByCard[card] = assignedLane; + } + final laneCount = laneEnds.length; + return [ + for (final card in group) + _TimelineCardPlacement( + card: card, + lane: laneByCard[card]!, + laneCount: laneCount, + ), + ]; + } + + double left(double trackWidth) { + final laneWidth = _laneWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); + } + + double width(double trackWidth) { + return _laneWidth(trackWidth); + } + + double _availableWidth(double trackWidth) { + final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; + if (availableWidth < 0) { + return 0; + } + return availableWidth; + } + + double _laneWidth(double trackWidth) { + final availableWidth = _availableWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return (availableWidth - laneGap * (laneCount - 1)) / laneCount; + } + + double _laneGapForWidth(double trackWidth) { + if (laneCount == 1) { + return 0; + } + final availableWidth = _availableWidth(trackWidth); + final totalGap = _laneGap * (laneCount - 1); + if (availableWidth <= totalGap) { + return 0; + } + return _laneGap; + } +} diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index b5e9b64..aabd58c 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -159,6 +159,57 @@ void main() { }, ); + testWidgets('timeline reuses empty overlap lanes for chained tasks', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 520), + child: TimelineView( + cards: [ + _timelineCard( + id: 'left-first', + title: 'Left first', + startMinutes: 9 * 60, + endMinutes: 10 * 60, + ), + _timelineCard( + id: 'right-middle', + title: 'Right middle', + startMinutes: 9 * 60 + 30, + endMinutes: 10 * 60 + 30, + ), + _timelineCard( + id: 'left-reused', + title: 'Left reused', + startMinutes: 10 * 60, + endMinutes: 11 * 60, + ), + ], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 9, 30), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final leftFirst = tester.getRect(find.byKey(const ValueKey('left-first'))); + final rightMiddle = tester.getRect( + find.byKey(const ValueKey('right-middle')), + ); + final leftReused = tester.getRect( + find.byKey(const ValueKey('left-reused')), + ); + + expect(rightMiddle.left, greaterThan(leftFirst.left)); + expect(leftReused.left, closeTo(leftFirst.left, 1)); + expect(leftFirst.width, closeTo(rightMiddle.width, 1)); + expect(leftReused.width, closeTo(leftFirst.width, 1)); + expect(leftFirst.right, lessThan(rightMiddle.left)); + expect(leftReused.right, lessThan(rightMiddle.left)); + }); + testWidgets('top bar controls scale inside a compact header width', ( tester, ) async { @@ -338,6 +389,8 @@ TimelineCardModel _timelineCard({ required String title, String subtitle = '30 min', String timeText = '6:00 PM - 6:30 PM', + int startMinutes = 18 * 60, + int endMinutes = 18 * 60 + 30, TaskVisualKind visualKind = TaskVisualKind.flexible, bool showsQuickActions = true, }) { @@ -347,8 +400,8 @@ TimelineCardModel _timelineCard({ typeLabel: 'Flexible', subtitle: subtitle, timeText: timeText, - startMinutes: 18 * 60, - endMinutes: 18 * 60 + 30, + startMinutes: startMinutes, + endMinutes: endMinutes, visualKind: visualKind, rewardIconToken: TimelineRewardIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium,