300 lines
9.2 KiB
Dart
300 lines
9.2 KiB
Dart
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../models/today_screen_models.dart';
|
|
import '../../theme/focus_flow_tokens.dart';
|
|
import 'task_timeline_card.dart';
|
|
import 'timeline_axis.dart';
|
|
import 'timeline_geometry.dart';
|
|
|
|
/// Scrollable compact timeline view for Today cards.
|
|
class TimelineView extends StatefulWidget {
|
|
/// Creates a timeline view.
|
|
const TimelineView({
|
|
required this.cards,
|
|
required this.range,
|
|
required this.onCardSelected,
|
|
this.now,
|
|
this.scrollTargetCardId,
|
|
this.scrollRequest = 0,
|
|
super.key,
|
|
});
|
|
|
|
/// Cards to position on the timeline.
|
|
final List<TimelineCardModel> cards;
|
|
|
|
/// Visible range used to build the timeline geometry.
|
|
final TimelineRangeModel range;
|
|
|
|
/// Callback invoked when a card is selected.
|
|
final ValueChanged<TimelineCardModel> onCardSelected;
|
|
|
|
/// Clock used to center the initial scroll position.
|
|
final DateTime Function()? now;
|
|
|
|
/// Card id requested by another surface, such as the required banner.
|
|
final String? scrollTargetCardId;
|
|
|
|
/// Monotonic request number that allows repeated jumps to the same card.
|
|
final int scrollRequest;
|
|
|
|
@override
|
|
State<TimelineView> createState() => _TimelineViewState();
|
|
}
|
|
|
|
class _TimelineViewState extends State<TimelineView> {
|
|
final _scrollController = ScrollController();
|
|
bool _didSetInitialScroll = false;
|
|
var _handledScrollRequest = 0;
|
|
|
|
@override
|
|
void didUpdateWidget(covariant TimelineView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.range.startMinutes != widget.range.startMinutes ||
|
|
oldWidget.range.endMinutes != widget.range.endMinutes) {
|
|
_didSetInitialScroll = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final geometry = TimelineGeometry(
|
|
startMinutes: widget.range.startMinutes,
|
|
endMinutes: widget.range.endMinutes,
|
|
pixelsPerMinute: 2.45,
|
|
minCardHeight: 88,
|
|
);
|
|
final contentHeight = geometry.totalHeight + 24;
|
|
final placements = _TimelineCardPlacement.pack(widget.cards);
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
|
_scheduleTargetScroll(geometry);
|
|
return SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: SizedBox(
|
|
height: contentHeight,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TimelineAxis(geometry: geometry),
|
|
const SizedBox(width: 18),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, cardConstraints) {
|
|
final trackWidth = cardConstraints.maxWidth;
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(
|
|
child: TimelineGrid(geometry: geometry),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _scheduleInitialScroll(
|
|
TimelineGeometry geometry,
|
|
double viewportHeight,
|
|
) {
|
|
if (_didSetInitialScroll || !viewportHeight.isFinite) {
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_scrollController.hasClients) {
|
|
return;
|
|
}
|
|
final now = widget.now?.call() ?? DateTime.now();
|
|
final currentMinute = now.hour * 60 + now.minute;
|
|
final clampedMinute = currentMinute
|
|
.clamp(geometry.startMinutes, geometry.endMinutes)
|
|
.toInt();
|
|
final centeredOffset =
|
|
geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2;
|
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
final offset = centeredOffset.clamp(0, maxOffset).toDouble();
|
|
_scrollController.jumpTo(offset);
|
|
_didSetInitialScroll = true;
|
|
});
|
|
}
|
|
|
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
|
final targetCardId = widget.scrollTargetCardId;
|
|
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_scrollController.hasClients) {
|
|
return;
|
|
}
|
|
TimelineCardModel? targetCard;
|
|
for (final card in widget.cards) {
|
|
if (card.id == targetCardId) {
|
|
targetCard = card;
|
|
break;
|
|
}
|
|
}
|
|
if (targetCard == null) {
|
|
_handledScrollRequest = widget.scrollRequest;
|
|
return;
|
|
}
|
|
final targetOffset =
|
|
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
|
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
|
_scrollController.jumpTo(offset);
|
|
_handledScrollRequest = widget.scrollRequest;
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|