forked from eva/focus-flow
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.
|
||||
static const defaultRange = TimelineRangeModel(
|
||||
startMinutes: 16 * 60,
|
||||
endMinutes: 20 * 60 + 45,
|
||||
startMinutes: 0,
|
||||
endMinutes: 24 * 60,
|
||||
);
|
||||
|
||||
/// Display-ready date label.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue