/// 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, minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; 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: Stack( clipBehavior: Clip.none, children: [ Positioned.fill(child: TimelineGrid(geometry: geometry)), for (final card in widget.cards) Positioned( top: geometry.yForMinutesSinceMidnight( card.startMinutes, ), left: FocusFlowTokens.cardHorizontalPadding, right: 0, height: geometry.heightForDuration( card.endMinutes - card.startMinutes, ), child: TaskTimelineCard( card: card, onTap: () => widget.onCardSelected(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; }); } }