From adf542291715d3954ddcb871ec0ff27688ab1d15 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:40:37 -0700 Subject: [PATCH] fix: make timeline scroll full day --- .../lib/models/today_screen_models.dart | 4 +- .../widgets/timeline/timeline_geometry.dart | 26 ++-- .../lib/widgets/timeline/timeline_view.dart | 134 ++++++++++++------ apps/focus_flow_flutter/test/widget_test.dart | 38 +++++ 4 files changed, 144 insertions(+), 58 deletions(-) diff --git a/apps/focus_flow_flutter/lib/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index 905826a..4cdc533 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -88,8 +88,8 @@ class TodayScreenData { /// Default visible range for the compact timeline. static const defaultRange = TimelineRangeModel( - startMinutes: 16 * 60, - endMinutes: 20 * 60 + 45, + startMinutes: 0, + endMinutes: 24 * 60, ); /// Display-ready date label. diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart index e36478f..9558c3b 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -1,23 +1,23 @@ /// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline. library; -import 'package:flutter/material.dart'; - /// Converts timeline clock values into vertical pixel positions. class TimelineGeometry { /// Creates geometry for a visible time range. const TimelineGeometry({ - required this.visibleStart, - required this.visibleEnd, + required this.startMinutes, + required this.endMinutes, required this.pixelsPerMinute, this.minCardHeight = 72, - }); + }) : assert(startMinutes >= 0), + assert(endMinutes <= 24 * 60), + assert(endMinutes > startMinutes); - /// First visible time on the timeline. - final TimeOfDay visibleStart; + /// First visible minute from midnight. + final int startMinutes; - /// Last visible time on the timeline. - final TimeOfDay visibleEnd; + /// Last visible minute from midnight. + final int endMinutes; /// Vertical scale used to convert minutes into pixels. final double pixelsPerMinute; @@ -25,12 +25,6 @@ class TimelineGeometry { /// Minimum rendered height for a timeline card. final double minCardHeight; - /// First visible minute from midnight. - int get startMinutes => visibleStart.hour * 60 + visibleStart.minute; - - /// Last visible minute from midnight. - int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute; - /// Total vertical height of the visible timeline. double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute; @@ -63,7 +57,7 @@ class TimelineGeometry { } static String _labelFor(int minutes) { - final hour24 = minutes ~/ 60; + final hour24 = (minutes ~/ 60) % 24; final minute = minutes % 60; final period = hour24 >= 12 ? 'PM' : 'AM'; final rawHour = hour24 % 12; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index a32e786..eb09690 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -10,12 +10,13 @@ import 'timeline_axis.dart'; import 'timeline_geometry.dart'; /// Scrollable compact timeline view for Today cards. -class TimelineView extends StatelessWidget { +class TimelineView extends StatefulWidget { /// Creates a timeline view. const TimelineView({ required this.cards, required this.range, required this.onCardSelected, + this.now, super.key, }); @@ -28,53 +29,106 @@ class TimelineView extends StatelessWidget { /// Callback invoked when a card is selected. final ValueChanged onCardSelected; + /// Clock used to center the initial scroll position. + final DateTime Function()? now; + + @override + State createState() => _TimelineViewState(); +} + +class _TimelineViewState extends State { + final _scrollController = ScrollController(); + bool _didSetInitialScroll = false; + + @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( - visibleStart: TimeOfDay( - hour: range.startMinutes ~/ 60, - minute: range.startMinutes % 60, - ), - visibleEnd: TimeOfDay( - hour: range.endMinutes ~/ 60, - minute: range.endMinutes % 60, - ), + startMinutes: widget.range.startMinutes, + endMinutes: widget.range.endMinutes, pixelsPerMinute: 2.45, minCardHeight: 88, ); final contentHeight = geometry.totalHeight + 24; - return SingleChildScrollView( - 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 cards) - Positioned( - top: geometry.yForMinutesSinceMidnight(card.startMinutes), - left: FocusFlowTokens.cardHorizontalPadding, - right: 0, - height: geometry.heightForDuration( - card.endMinutes - card.startMinutes, - ), - child: TaskTimelineCard( - card: card, - onTap: () => onCardSelected(card), - ), - ), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + _scheduleInitialScroll(geometry, constraints.maxHeight); + 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; + }); + } } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index fd89a94..b286cd5 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -13,6 +13,7 @@ import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; import 'package:focus_flow_flutter/widgets/required_banner.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart'; import 'package:focus_flow_flutter/widgets/top_bar.dart'; /// Runs FocusFlow widget and visual adapter tests. @@ -82,6 +83,7 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle(); @@ -106,6 +108,7 @@ void main() { expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle(); await tester.tapAt(const Offset(1450, 140)); @@ -115,6 +118,36 @@ void main() { expect(find.text('Pay bill'), findsOneWidget); }); + test('Today screen timeline range covers the full day', () { + expect(TodayScreenData.defaultRange.startMinutes, 0); + expect(TodayScreenData.defaultRange.endMinutes, 24 * 60); + }); + + testWidgets( + 'timeline defaults near the current time while keeping full day', + (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 12), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('11:00 PM'), findsOneWidget); + expect(find.text('12:00 AM'), findsWidgets); + + final scrollable = tester.state(find.byType(Scrollable)); + final position = scrollable.position; + expect(position.maxScrollExtent, greaterThan(3000)); + expect(position.pixels, closeTo(1564, 1)); + }, + ); + testWidgets('top bar controls scale inside a compact header width', ( tester, ) async { @@ -237,6 +270,11 @@ TaskVisualKind _kindFor(TodayScreenData data, String id) { return data.cards.singleWhere((card) => card.id == id).visualKind; } +Future _scrollIntoView(WidgetTester tester, ValueKey key) async { + await tester.ensureVisible(find.byKey(key)); + await tester.pumpAndSettle(); +} + Future _pumpTimelineCard( WidgetTester tester, { required TimelineCardModel card,