fix: make timeline scroll full day
This commit is contained in:
parent
bfc2c86088
commit
adf5422917
4 changed files with 144 additions and 58 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue