focus-flow/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart

326 lines
9.9 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,
);
final contentHeight = geometry.totalHeight + 24;
final placements = _TimelineCardPlacement.pack(
widget.cards,
geometry: geometry,
);
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, {
required TimelineGeometry geometry,
}) {
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 = _visualEnd(sorted.first, geometry);
for (var index = 1; index < sorted.length; index += 1) {
final card = sorted[index];
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
if (cardStart < groupEnd) {
if (cardEnd > groupEnd) {
groupEnd = cardEnd;
}
continue;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
);
groupStart = index;
groupEnd = cardEnd;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart), geometry: geometry),
);
return placements;
}
static List<_TimelineCardPlacement> _packGroup(
List<TimelineCardModel> group, {
required TimelineGeometry geometry,
}) {
final laneEnds = <double>[];
final laneByCard = <TimelineCardModel, int>{};
for (final card in group) {
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
var assignedLane = -1;
for (var lane = 0; lane < laneEnds.length; lane += 1) {
if (laneEnds[lane] <= cardStart) {
assignedLane = lane;
break;
}
}
if (assignedLane == -1) {
assignedLane = laneEnds.length;
laneEnds.add(cardEnd);
} else {
laneEnds[assignedLane] = cardEnd;
}
laneByCard[card] = assignedLane;
}
final laneCount = laneEnds.length;
return [
for (final card in group)
_TimelineCardPlacement(
card: card,
lane: laneByCard[card]!,
laneCount: laneCount,
),
];
}
static double _visualStart(
TimelineCardModel card,
TimelineGeometry geometry,
) {
return geometry.yForMinutesSinceMidnight(card.startMinutes);
}
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
final duration = card.endMinutes - card.startMinutes;
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
}
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;
}
}