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.
static const defaultRange = TimelineRangeModel(
startMinutes: 16 * 60,
endMinutes: 20 * 60 + 45,
startMinutes: 0,
endMinutes: 24 * 60,
);
/// Display-ready date label.

View file

@ -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;

View file

@ -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<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
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;
});
}
}

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/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<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', (
tester,
) async {
@ -237,6 +270,11 @@ TaskVisualKind _kindFor(TodayScreenData data, String id) {
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(
WidgetTester tester, {
required TimelineCardModel card,