fix: pack overlapping timeline tasks

This commit is contained in:
Ashley Venn 2026-06-30 14:07:25 -07:00
parent 1e0e76d72f
commit 4f19860054
3 changed files with 202 additions and 22 deletions

View file

@ -126,7 +126,7 @@ class _CardMetrics {
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.62; static const _minimumScale = 0.38;
factory _CardMetrics.fromConstraints( factory _CardMetrics.fromConstraints(
BoxConstraints constraints, { BoxConstraints constraints, {

View file

@ -73,6 +73,7 @@ class _TimelineViewState extends State<TimelineView> {
minCardHeight: 88, minCardHeight: 88,
); );
final contentHeight = geometry.totalHeight + 24; final contentHeight = geometry.totalHeight + 24;
final placements = _TimelineCardPlacement.pack(widget.cards);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight); _scheduleInitialScroll(geometry, constraints.maxHeight);
@ -87,26 +88,35 @@ class _TimelineViewState extends State<TimelineView> {
TimelineAxis(geometry: geometry), TimelineAxis(geometry: geometry),
const SizedBox(width: 18), const SizedBox(width: 18),
Expanded( Expanded(
child: Stack( child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill(child: TimelineGrid(geometry: geometry)), Positioned.fill(
for (final card in widget.cards) child: TimelineGrid(geometry: geometry),
),
for (final placement in placements)
Positioned( Positioned(
top: geometry.yForMinutesSinceMidnight( top: geometry.yForMinutesSinceMidnight(
card.startMinutes, placement.card.startMinutes,
), ),
left: FocusFlowTokens.cardHorizontalPadding, left: placement.left(trackWidth),
right: 0, width: placement.width(trackWidth),
height: geometry.heightForDuration( height: geometry.heightForDuration(
card.endMinutes - card.startMinutes, placement.card.endMinutes -
placement.card.startMinutes,
), ),
child: TaskTimelineCard( child: TaskTimelineCard(
card: card, card: placement.card,
onTap: () => widget.onCardSelected(card), onTap: () =>
widget.onCardSelected(placement.card),
), ),
), ),
], ],
);
},
), ),
), ),
], ],
@ -171,3 +181,120 @@ class _TimelineViewState extends State<TimelineView> {
}); });
} }
} }
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<TimelineCardModel> 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<TimelineCardModel> group,
) {
final laneEnds = <int>[];
final laneByCard = <TimelineCardModel, int>{};
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;
}
}

View file

@ -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', ( testWidgets('top bar controls scale inside a compact header width', (
tester, tester,
) async { ) async {
@ -338,6 +389,8 @@ TimelineCardModel _timelineCard({
required String title, required String title,
String subtitle = '30 min', String subtitle = '30 min',
String timeText = '6:00 PM - 6:30 PM', String timeText = '6:00 PM - 6:30 PM',
int startMinutes = 18 * 60,
int endMinutes = 18 * 60 + 30,
TaskVisualKind visualKind = TaskVisualKind.flexible, TaskVisualKind visualKind = TaskVisualKind.flexible,
bool showsQuickActions = true, bool showsQuickActions = true,
}) { }) {
@ -347,8 +400,8 @@ TimelineCardModel _timelineCard({
typeLabel: 'Flexible', typeLabel: 'Flexible',
subtitle: subtitle, subtitle: subtitle,
timeText: timeText, timeText: timeText,
startMinutes: 18 * 60, startMinutes: startMinutes,
endMinutes: 18 * 60 + 30, endMinutes: endMinutes,
visualKind: visualKind, visualKind: visualKind,
rewardIconToken: TimelineRewardIconToken.medium, rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium,