/// 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 cards; /// Visible range used to build the timeline geometry. final TimelineRangeModel range; /// Callback invoked when a card is selected. final ValueChanged 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 createState() => _TimelineViewState(); } class _TimelineViewState extends State { 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 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 group, { required TimelineGeometry geometry, }) { final laneEnds = []; final laneByCard = {}; 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; } }