fix: make timeline scroll full day

This commit is contained in:
Ashley Venn 2026-06-30 13:40:37 -07:00
parent bfc2c86088
commit adf5422917
4 changed files with 144 additions and 58 deletions

View file

@ -88,8 +88,8 @@ class TodayScreenData {
/// Default visible range for the compact timeline. /// Default visible range for the compact timeline.
static const defaultRange = TimelineRangeModel( static const defaultRange = TimelineRangeModel(
startMinutes: 16 * 60, startMinutes: 0,
endMinutes: 20 * 60 + 45, endMinutes: 24 * 60,
); );
/// Display-ready date label. /// Display-ready date label.

View file

@ -1,23 +1,23 @@
/// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline. /// Renders Timeline Geometry pieces for the FocusFlow Flutter timeline.
library; library;
import 'package:flutter/material.dart';
/// Converts timeline clock values into vertical pixel positions. /// Converts timeline clock values into vertical pixel positions.
class TimelineGeometry { class TimelineGeometry {
/// Creates geometry for a visible time range. /// Creates geometry for a visible time range.
const TimelineGeometry({ const TimelineGeometry({
required this.visibleStart, required this.startMinutes,
required this.visibleEnd, required this.endMinutes,
required this.pixelsPerMinute, required this.pixelsPerMinute,
this.minCardHeight = 72, this.minCardHeight = 72,
}); }) : assert(startMinutes >= 0),
assert(endMinutes <= 24 * 60),
assert(endMinutes > startMinutes);
/// First visible time on the timeline. /// First visible minute from midnight.
final TimeOfDay visibleStart; final int startMinutes;
/// Last visible time on the timeline. /// Last visible minute from midnight.
final TimeOfDay visibleEnd; final int endMinutes;
/// Vertical scale used to convert minutes into pixels. /// Vertical scale used to convert minutes into pixels.
final double pixelsPerMinute; final double pixelsPerMinute;
@ -25,12 +25,6 @@ class TimelineGeometry {
/// Minimum rendered height for a timeline card. /// Minimum rendered height for a timeline card.
final double minCardHeight; 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. /// Total vertical height of the visible timeline.
double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute; double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute;
@ -63,7 +57,7 @@ class TimelineGeometry {
} }
static String _labelFor(int minutes) { static String _labelFor(int minutes) {
final hour24 = minutes ~/ 60; final hour24 = (minutes ~/ 60) % 24;
final minute = minutes % 60; final minute = minutes % 60;
final period = hour24 >= 12 ? 'PM' : 'AM'; final period = hour24 >= 12 ? 'PM' : 'AM';
final rawHour = hour24 % 12; final rawHour = hour24 % 12;

View file

@ -10,12 +10,13 @@ import 'timeline_axis.dart';
import 'timeline_geometry.dart'; import 'timeline_geometry.dart';
/// Scrollable compact timeline view for Today cards. /// Scrollable compact timeline view for Today cards.
class TimelineView extends StatelessWidget { class TimelineView extends StatefulWidget {
/// Creates a timeline view. /// Creates a timeline view.
const TimelineView({ const TimelineView({
required this.cards, required this.cards,
required this.range, required this.range,
required this.onCardSelected, required this.onCardSelected,
this.now,
super.key, super.key,
}); });
@ -28,22 +29,46 @@ class TimelineView extends StatelessWidget {
/// Callback invoked when a card is selected. /// Callback invoked when a card is selected.
final ValueChanged<TimelineCardModel> onCardSelected; final ValueChanged<TimelineCardModel> onCardSelected;
/// Clock used to center the initial scroll position.
final DateTime Function()? now;
@override
State<TimelineView> createState() => _TimelineViewState();
}
class _TimelineViewState extends State<TimelineView> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final geometry = TimelineGeometry( final geometry = TimelineGeometry(
visibleStart: TimeOfDay( startMinutes: widget.range.startMinutes,
hour: range.startMinutes ~/ 60, endMinutes: widget.range.endMinutes,
minute: range.startMinutes % 60,
),
visibleEnd: TimeOfDay(
hour: range.endMinutes ~/ 60,
minute: range.endMinutes % 60,
),
pixelsPerMinute: 2.45, pixelsPerMinute: 2.45,
minCardHeight: 88, minCardHeight: 88,
); );
final contentHeight = geometry.totalHeight + 24; final contentHeight = geometry.totalHeight + 24;
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(geometry, constraints.maxHeight);
return SingleChildScrollView( return SingleChildScrollView(
controller: _scrollController,
child: SizedBox( child: SizedBox(
height: contentHeight, height: contentHeight,
child: Row( child: Row(
@ -56,9 +81,11 @@ class TimelineView extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill(child: TimelineGrid(geometry: geometry)), Positioned.fill(child: TimelineGrid(geometry: geometry)),
for (final card in cards) for (final card in widget.cards)
Positioned( Positioned(
top: geometry.yForMinutesSinceMidnight(card.startMinutes), top: geometry.yForMinutesSinceMidnight(
card.startMinutes,
),
left: FocusFlowTokens.cardHorizontalPadding, left: FocusFlowTokens.cardHorizontalPadding,
right: 0, right: 0,
height: geometry.heightForDuration( height: geometry.heightForDuration(
@ -66,7 +93,7 @@ class TimelineView extends StatelessWidget {
), ),
child: TaskTimelineCard( child: TaskTimelineCard(
card: card, card: card,
onTap: () => onCardSelected(card), onTap: () => widget.onCardSelected(card),
), ),
), ),
], ],
@ -76,5 +103,32 @@ class TimelineView extends StatelessWidget {
), ),
), ),
); );
},
);
}
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;
});
} }
} }

View file

@ -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/required_banner.dart';
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.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/task_timeline_card.dart';
import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart';
import 'package:focus_flow_flutter/widgets/top_bar.dart'; import 'package:focus_flow_flutter/widgets/top_bar.dart';
/// Runs FocusFlow widget and visual adapter tests. /// Runs FocusFlow widget and visual adapter tests.
@ -82,6 +83,7 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); 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.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -106,6 +108,7 @@ void main() {
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); 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.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140)); await tester.tapAt(const Offset(1450, 140));
@ -115,6 +118,36 @@ void main() {
expect(find.text('Pay bill'), findsOneWidget); 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<ScrollableState>(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', ( testWidgets('top bar controls scale inside a compact header width', (
tester, tester,
) async { ) async {
@ -237,6 +270,11 @@ TaskVisualKind _kindFor(TodayScreenData data, String id) {
return data.cards.singleWhere((card) => card.id == id).visualKind; return data.cards.singleWhere((card) => card.id == id).visualKind;
} }
Future<void> _scrollIntoView(WidgetTester tester, ValueKey<String> key) async {
await tester.ensureVisible(find.byKey(key));
await tester.pumpAndSettle();
}
Future<void> _pumpTimelineCard( Future<void> _pumpTimelineCard(
WidgetTester tester, { WidgetTester tester, {
required TimelineCardModel card, required TimelineCardModel card,