/// Renders Timeline View pieces for the FocusFlow Flutter timeline. library; import 'package:flutter/gestures.dart'; 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.onCardCompleted, 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; /// Callback invoked when a task card status control requests completion. final ValueChanged? onCardCompleted; /// 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 { static const _topInset = 12.0; final _scrollController = ScrollController(); bool _didSetInitialScroll = false; var _handledScrollRequest = 0; late TimelineGeometry _geometry; late double _contentHeight; late List<_TimelineCardPlacement> _placements; late int _cardsLayoutHash; @override void initState() { super.initState(); _updateLayoutCache(); } @override void didUpdateWidget(covariant TimelineView oldWidget) { super.didUpdateWidget(oldWidget); final rangeChanged = oldWidget.range.startMinutes != widget.range.startMinutes || oldWidget.range.endMinutes != widget.range.endMinutes; if (rangeChanged) { _didSetInitialScroll = false; } final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash; if (rangeChanged || layoutChanged) { _updateLayoutCache(); } else if (!identical(oldWidget.cards, widget.cards)) { _refreshPlacementCards(); } } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { _scheduleInitialScroll(_geometry, constraints.maxHeight); _scheduleTargetScroll(_geometry); return ScrollConfiguration( behavior: const _TimelineScrollBehavior(), child: SingleChildScrollView( controller: _scrollController, child: SizedBox( height: _contentHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ RepaintBoundary( child: TimelineAxis( geometry: _geometry, topInset: _topInset, ), ), const SizedBox(width: 18), Expanded( child: LayoutBuilder( builder: (context, cardConstraints) { final trackWidth = cardConstraints.maxWidth; return Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: RepaintBoundary( child: TimelineGrid( geometry: _geometry, topInset: _topInset, ), ), ), for (final placement in _placements) Positioned( top: placement.top + _topInset, left: placement.left(trackWidth), width: placement.width(trackWidth), height: placement.height, child: RepaintBoundary( child: TaskTimelineCard( card: placement.card, onTap: () => widget.onCardSelected(placement.card), onStatusPressed: widget.onCardCompleted == null ? null : () => widget.onCardCompleted!( placement.card, ), ), ), ), ], ); }, ), ), ], ), ), ), ); }, ); } void _updateLayoutCache() { _geometry = TimelineGeometry( startMinutes: widget.range.startMinutes, endMinutes: widget.range.endMinutes, pixelsPerMinute: 2.45, ); _contentHeight = _geometry.totalHeight + _topInset + 24; _placements = _TimelineCardPlacement.pack( widget.cards, geometry: _geometry, ); _cardsLayoutHash = _layoutHashFor(widget.cards); } int _layoutHashFor(List cards) { return Object.hashAll( cards.map( (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), ), ); } void _refreshPlacementCards() { final cardsById = {for (final card in widget.cards) card.id: card}; _placements = [ for (final placement in _placements) placement.withCard(cardsById[placement.card.id] ?? 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) + _topInset - 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) + _topInset - 24; final maxOffset = _scrollController.position.maxScrollExtent; final offset = targetOffset.clamp(0, maxOffset).toDouble(); _scrollController.jumpTo(offset); _handledScrollRequest = widget.scrollRequest; }); } } class _TimelineScrollBehavior extends MaterialScrollBehavior { const _TimelineScrollBehavior(); @override Set get dragDevices { return {...super.dragDevices, PointerDeviceKind.mouse}; } } class _TimelineCardPlacement { const _TimelineCardPlacement({ required this.card, required this.lane, required this.laneCount, required this.top, required this.height, }); static const _laneGap = 8.0; final TimelineCardModel card; final int lane; final int laneCount; final double top; final double height; _TimelineCardPlacement withCard(TimelineCardModel nextCard) { if (identical(card, nextCard)) { return this; } return _TimelineCardPlacement( card: nextCard, lane: lane, laneCount: laneCount, top: top, height: height, ); } 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, top: _visualStart(card, geometry), height: geometry.heightForDuration( card.endMinutes - card.startMinutes, ), ), ]; } 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; } }