forked from eva/focus-flow
fix: pack overlapping timeline tasks
This commit is contained in:
parent
1e0e76d72f
commit
4f19860054
3 changed files with 202 additions and 22 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
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<TimelineView> {
|
|||
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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue