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 _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, {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
clipBehavior: Clip.none,
|
builder: (context, cardConstraints) {
|
||||||
children: [
|
final trackWidth = cardConstraints.maxWidth;
|
||||||
Positioned.fill(child: TimelineGrid(geometry: geometry)),
|
return Stack(
|
||||||
for (final card in widget.cards)
|
clipBehavior: Clip.none,
|
||||||
Positioned(
|
children: [
|
||||||
top: geometry.yForMinutesSinceMidnight(
|
Positioned.fill(
|
||||||
card.startMinutes,
|
child: TimelineGrid(geometry: geometry),
|
||||||
),
|
),
|
||||||
left: FocusFlowTokens.cardHorizontalPadding,
|
for (final placement in placements)
|
||||||
right: 0,
|
Positioned(
|
||||||
height: geometry.heightForDuration(
|
top: geometry.yForMinutesSinceMidnight(
|
||||||
card.endMinutes - card.startMinutes,
|
placement.card.startMinutes,
|
||||||
),
|
),
|
||||||
child: TaskTimelineCard(
|
left: placement.left(trackWidth),
|
||||||
card: card,
|
width: placement.width(trackWidth),
|
||||||
onTap: () => widget.onCardSelected(card),
|
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', (
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue