focus-flow/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart

421 lines
13 KiB
Dart

/// Renders Timeline View pieces for the FocusFlow Flutter timeline.
library;
import 'package:flutter/gestures.dart';
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.onCardCompleted,
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;
/// Callback invoked when a task card status control requests completion.
final ValueChanged<TimelineCardModel>? onCardCompleted;
/// 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> {
static const _topInset = 12.0;
final _scrollController = ScrollController();
bool _didSetInitialScroll = false;
var _handledScrollRequest = 0;
late TimelineGeometry _geometry;
late double _contentHeight;
late List<_TimelineCardPlacement> _placements;
late int _cardsLayoutHash;
@override
void initState() {
super.initState();
_updateLayoutCache();
}
@override
void didUpdateWidget(covariant TimelineView oldWidget) {
super.didUpdateWidget(oldWidget);
final rangeChanged =
oldWidget.range.startMinutes != widget.range.startMinutes ||
oldWidget.range.endMinutes != widget.range.endMinutes;
if (rangeChanged) {
_didSetInitialScroll = false;
}
final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash;
if (rangeChanged || layoutChanged) {
_updateLayoutCache();
} else if (!identical(oldWidget.cards, widget.cards)) {
_refreshPlacementCards();
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_scheduleInitialScroll(_geometry, constraints.maxHeight);
_scheduleTargetScroll(_geometry);
return ScrollConfiguration(
behavior: const _TimelineScrollBehavior(),
child: SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
height: _contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RepaintBoundary(
child: TimelineAxis(
geometry: _geometry,
topInset: _topInset,
),
),
const SizedBox(width: 18),
Expanded(
child: LayoutBuilder(
builder: (context, cardConstraints) {
final trackWidth = cardConstraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: RepaintBoundary(
child: TimelineGrid(
geometry: _geometry,
topInset: _topInset,
),
),
),
for (final placement in _placements)
Positioned(
top: placement.top + _topInset,
left: placement.left(trackWidth),
width: placement.width(trackWidth),
height: placement.height,
child: RepaintBoundary(
child: TaskTimelineCard(
card: placement.card,
onTap: () =>
widget.onCardSelected(placement.card),
onStatusPressed:
widget.onCardCompleted == null
? null
: () => widget.onCardCompleted!(
placement.card,
),
),
),
),
],
);
},
),
),
],
),
),
),
);
},
);
}
void _updateLayoutCache() {
_geometry = TimelineGeometry(
startMinutes: widget.range.startMinutes,
endMinutes: widget.range.endMinutes,
pixelsPerMinute: 2.45,
);
_contentHeight = _geometry.totalHeight + _topInset + 24;
_placements = _TimelineCardPlacement.pack(
widget.cards,
geometry: _geometry,
);
_cardsLayoutHash = _layoutHashFor(widget.cards);
}
int _layoutHashFor(List<TimelineCardModel> cards) {
return Object.hashAll(
cards.map(
(card) => Object.hash(card.id, card.startMinutes, card.endMinutes),
),
);
}
void _refreshPlacementCards() {
final cardsById = {for (final card in widget.cards) card.id: card};
_placements = [
for (final placement in _placements)
placement.withCard(cardsById[placement.card.id] ?? placement.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) +
_topInset -
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) +
_topInset -
24;
final maxOffset = _scrollController.position.maxScrollExtent;
final offset = targetOffset.clamp(0, maxOffset).toDouble();
_scrollController.jumpTo(offset);
_handledScrollRequest = widget.scrollRequest;
});
}
}
class _TimelineScrollBehavior extends MaterialScrollBehavior {
const _TimelineScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices {
return {...super.dragDevices, PointerDeviceKind.mouse};
}
}
class _TimelineCardPlacement {
const _TimelineCardPlacement({
required this.card,
required this.lane,
required this.laneCount,
required this.top,
required this.height,
});
static const _laneGap = 8.0;
final TimelineCardModel card;
final int lane;
final int laneCount;
final double top;
final double height;
_TimelineCardPlacement withCard(TimelineCardModel nextCard) {
if (identical(card, nextCard)) {
return this;
}
return _TimelineCardPlacement(
card: nextCard,
lane: lane,
laneCount: laneCount,
top: top,
height: height,
);
}
static List<_TimelineCardPlacement> pack(
List<TimelineCardModel> cards, {
required TimelineGeometry geometry,
}) {
if (cards.isEmpty) {
return const [];
}
final sorted = [...cards]
..sort((a, b) {
final startComparison = a.startMinutes.compareTo(b.startMinutes);
if (startComparison != 0) {
return startComparison;
}
final endComparison = a.endMinutes.compareTo(b.endMinutes);
if (endComparison != 0) {
return endComparison;
}
return a.id.compareTo(b.id);
});
final placements = <_TimelineCardPlacement>[];
var groupStart = 0;
var groupEnd = _visualEnd(sorted.first, geometry);
for (var index = 1; index < sorted.length; index += 1) {
final card = sorted[index];
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
if (cardStart < groupEnd) {
if (cardEnd > groupEnd) {
groupEnd = cardEnd;
}
continue;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart, index), geometry: geometry),
);
groupStart = index;
groupEnd = cardEnd;
}
placements.addAll(
_packGroup(sorted.sublist(groupStart), geometry: geometry),
);
return placements;
}
static List<_TimelineCardPlacement> _packGroup(
List<TimelineCardModel> group, {
required TimelineGeometry geometry,
}) {
final laneEnds = <double>[];
final laneByCard = <TimelineCardModel, int>{};
for (final card in group) {
final cardStart = _visualStart(card, geometry);
final cardEnd = _visualEnd(card, geometry);
var assignedLane = -1;
for (var lane = 0; lane < laneEnds.length; lane += 1) {
if (laneEnds[lane] <= cardStart) {
assignedLane = lane;
break;
}
}
if (assignedLane == -1) {
assignedLane = laneEnds.length;
laneEnds.add(cardEnd);
} else {
laneEnds[assignedLane] = cardEnd;
}
laneByCard[card] = assignedLane;
}
final laneCount = laneEnds.length;
return [
for (final card in group)
_TimelineCardPlacement(
card: card,
lane: laneByCard[card]!,
laneCount: laneCount,
top: _visualStart(card, geometry),
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
),
];
}
static double _visualStart(
TimelineCardModel card,
TimelineGeometry geometry,
) {
return geometry.yForMinutesSinceMidnight(card.startMinutes);
}
static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) {
final duration = card.endMinutes - card.startMinutes;
return _visualStart(card, geometry) + geometry.heightForDuration(duration);
}
double left(double trackWidth) {
final laneWidth = _laneWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap);
}
double width(double trackWidth) {
return _laneWidth(trackWidth);
}
double _availableWidth(double trackWidth) {
final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding;
if (availableWidth < 0) {
return 0;
}
return availableWidth;
}
double _laneWidth(double trackWidth) {
final availableWidth = _availableWidth(trackWidth);
final laneGap = _laneGapForWidth(trackWidth);
return (availableWidth - laneGap * (laneCount - 1)) / laneCount;
}
double _laneGapForWidth(double trackWidth) {
if (laneCount == 1) {
return 0;
}
final availableWidth = _availableWidth(trackWidth);
final totalGap = _laneGap * (laneCount - 1);
if (availableWidth <= totalGap) {
return 0;
}
return _laneGap;
}
}