173 lines
5.5 KiB
Dart
173 lines
5.5 KiB
Dart
/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../models/today_screen_models.dart';
|
|
import '../../theme/focus_flow_tokens.dart';
|
|
import 'task_timeline_card.dart';
|
|
import 'timeline_axis.dart';
|
|
import 'timeline_geometry.dart';
|
|
|
|
/// Scrollable compact timeline view for Today cards.
|
|
class TimelineView extends StatefulWidget {
|
|
/// Creates a timeline view.
|
|
const TimelineView({
|
|
required this.cards,
|
|
required this.range,
|
|
required this.onCardSelected,
|
|
this.now,
|
|
this.scrollTargetCardId,
|
|
this.scrollRequest = 0,
|
|
super.key,
|
|
});
|
|
|
|
/// Cards to position on the timeline.
|
|
final List<TimelineCardModel> cards;
|
|
|
|
/// Visible range used to build the timeline geometry.
|
|
final TimelineRangeModel range;
|
|
|
|
/// Callback invoked when a card is selected.
|
|
final ValueChanged<TimelineCardModel> onCardSelected;
|
|
|
|
/// Clock used to center the initial scroll position.
|
|
final DateTime Function()? now;
|
|
|
|
/// Card id requested by another surface, such as the required banner.
|
|
final String? scrollTargetCardId;
|
|
|
|
/// Monotonic request number that allows repeated jumps to the same card.
|
|
final int scrollRequest;
|
|
|
|
@override
|
|
State<TimelineView> createState() => _TimelineViewState();
|
|
}
|
|
|
|
class _TimelineViewState extends State<TimelineView> {
|
|
final _scrollController = ScrollController();
|
|
bool _didSetInitialScroll = false;
|
|
var _handledScrollRequest = 0;
|
|
|
|
@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(
|
|
startMinutes: widget.range.startMinutes,
|
|
endMinutes: widget.range.endMinutes,
|
|
pixelsPerMinute: 2.45,
|
|
minCardHeight: 88,
|
|
);
|
|
final contentHeight = geometry.totalHeight + 24;
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
|
_scheduleTargetScroll(geometry);
|
|
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;
|
|
});
|
|
}
|
|
|
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
|
final targetCardId = widget.scrollTargetCardId;
|
|
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !_scrollController.hasClients) {
|
|
return;
|
|
}
|
|
TimelineCardModel? targetCard;
|
|
for (final card in widget.cards) {
|
|
if (card.id == targetCardId) {
|
|
targetCard = card;
|
|
break;
|
|
}
|
|
}
|
|
if (targetCard == null) {
|
|
_handledScrollRequest = widget.scrollRequest;
|
|
return;
|
|
}
|
|
final targetOffset =
|
|
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
|
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
|
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
|
_scrollController.jumpTo(offset);
|
|
_handledScrollRequest = widget.scrollRequest;
|
|
});
|
|
}
|
|
}
|