focus-flow/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.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;
});
}
}