immich/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Alex 4a2cf28882
feat: memories in new timeline (#19720)
* feat: memories sliver

* memories lane

* display and show memory

* fix: get correct memories

* naming

* pr feedback

* use equalsValue for visibility

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-07-04 13:49:15 -05:00

639 lines
18 KiB
Dart

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class Scrubber extends ConsumerStatefulWidget {
/// The view that will be scrolled with the scroll thumb
final CustomScrollView child;
/// The segments of the timeline
final List<Segment> layoutSegments;
final double timelineHeight;
final double topPadding;
final double bottomPadding;
final double? monthSegmentSnappingOffset;
Scrubber({
super.key,
Key? scrollThumbKey,
required this.layoutSegments,
required this.timelineHeight,
this.topPadding = 0,
this.bottomPadding = 0,
this.monthSegmentSnappingOffset,
required this.child,
}) : assert(child.scrollDirection == Axis.vertical);
@override
ConsumerState createState() => ScrubberState();
}
List<_Segment> _buildSegments({
required List<Segment> layoutSegments,
required double timelineHeight,
}) {
const double offsetThreshold = 20.0;
final segments = <_Segment>[];
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
return [];
}
final formatter = DateFormat.yMMM();
DateTime? lastDate;
double lastOffset = -offsetThreshold;
for (final layoutSegment in layoutSegments) {
final scrollPercentage =
layoutSegment.startOffset / layoutSegments.last.endOffset;
final startOffset = scrollPercentage * timelineHeight;
final date = (layoutSegment.bucket as TimeBucket).date;
final label = formatter.format(date);
final showSegment = lastOffset + offsetThreshold <= startOffset &&
(lastDate == null || date.year != lastDate.year);
segments.add(
_Segment(
date: date,
startOffset: startOffset,
scrollLabel: label,
showSegment: showSegment,
),
);
lastDate = date;
if (showSegment) {
lastOffset = startOffset;
}
}
return segments;
}
class ScrubberState extends ConsumerState<Scrubber>
with TickerProviderStateMixin {
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
double get _scrubberHeight =>
widget.timelineHeight - widget.topPadding - widget.bottomPadding;
late ScrollController _scrollController;
double get _currentOffset {
if (_scrollController.hasClients != true) return 0.0;
return _scrollController.offset *
_scrubberHeight /
_scrollController.position.maxScrollExtent;
}
@override
void initState() {
super.initState();
_isDragging = false;
_segments = _buildSegments(
layoutSegments: widget.layoutSegments,
timelineHeight: _scrubberHeight,
);
_thumbAnimationController = AnimationController(
vsync: this,
duration: kTimelineScrubberFadeInDuration,
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastEaseInToSlowEaseOut,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: kTimelineScrubberFadeInDuration,
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scrollController = PrimaryScrollController.of(context);
}
@override
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.layoutSegments.lastOrNull?.endOffset !=
widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(
layoutSegments: widget.layoutSegments,
timelineHeight: _scrubberHeight,
);
}
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
super.dispose();
}
void _resetThumbTimer() {
_fadeOutTimer?.cancel();
_fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () {
_thumbAnimationController.reverse();
_fadeOutTimer = null;
});
}
bool _onScrollNotification(ScrollNotification notification) {
if (_isDragging) {
// If the user is dragging the thumb, we don't want to update the position
return false;
}
if (notification is ScrollStartNotification ||
notification is ScrollUpdateNotification) {
ref.read(timelineStateProvider.notifier).setScrolling(true);
} else if (notification is ScrollEndNotification) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
setState(() {
if (notification is ScrollUpdateNotification) {
_thumbTopOffset = _currentOffset;
if (_labelAnimation.status != AnimationStatus.reverse) {
_labelAnimationController.reverse();
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
}
_resetThumbTimer();
});
return false;
}
void _onDragStart(DragStartDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(true);
setState(() {
_isDragging = true;
_labelAnimationController.forward();
_fadeOutTimer?.cancel();
});
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDragging) {
return;
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
final dragPosition = _calculateDragPosition(details);
final nearestMonthSegment = _findNearestMonthSegment(dragPosition);
if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
}
}
/// Calculate the drag position relative to the scrubber area
///
/// This method converts the global drag coordinates from the gesture detector
/// into a position relative to the scrubber's active area (excluding padding).
///
/// The scrubber has padding at the top and bottom, so we need to:
/// 1. Calculate the actual draggable area (timelineHeight - topPadding - bottomPadding)
/// 2. Convert the global Y position to a position within this draggable area
/// 3. Clamp the result to ensure it stays within bounds (0 to dragAreaHeight)
///
/// Example:
/// - If timelineHeight = 800, topPadding = 50, bottomPadding = 50
/// - Then dragAreaHeight = 700 (the actual scrubber area)
/// - If user drags to global Y position that's 100 pixels from the top
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
double _calculateDragPosition(DragUpdateDetails details) {
final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop;
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
}
/// Find the segment closest to the given position
_Segment? _findNearestMonthSegment(double position) {
_Segment? nearestSegment;
double minDistance = double.infinity;
for (final segment in _segments) {
final distance = (segment.startOffset - position).abs();
if (distance < minDistance) {
minDistance = distance;
nearestSegment = segment;
}
}
return nearestSegment;
}
/// Snap the scrubber thumb and scroll view to the given segment
void _snapToSegment(_Segment segment) {
setState(() {
_thumbTopOffset = segment.startOffset;
final layoutSegmentIndex = _findLayoutSegmentIndex(segment);
if (layoutSegmentIndex >= 0) {
_scrollToLayoutSegment(layoutSegmentIndex);
}
});
}
int _findLayoutSegmentIndex(_Segment segment) {
return widget.layoutSegments.indexWhere(
(layoutSegment) {
final bucket = layoutSegment.bucket as TimeBucket;
return bucket.date.year == segment.date.year &&
bucket.date.month == segment.date.month;
},
);
}
void _scrollToLayoutSegment(int layoutSegmentIndex) {
final layoutSegment = widget.layoutSegments[layoutSegmentIndex];
final maxScrollExtent = _scrollController.position.maxScrollExtent;
final viewportHeight = _scrollController.position.viewportDimension;
final targetScrollOffset = layoutSegment.startOffset;
final centeredOffset = targetScrollOffset -
(viewportHeight / 4) +
100 +
(widget.monthSegmentSnappingOffset ?? 0.0);
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
}
void _onDragEnd(DragEndDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
});
_resetThumbTimer();
}
@override
Widget build(BuildContext ctx) {
Text? label;
if (_scrollController.hasClients == true) {
// Cache to avoid multiple calls to [_currentOffset]
final scrollOffset = _currentOffset;
final labelText = _segments
.lastWhereOrNull(
(segment) => segment.startOffset <= scrollOffset,
)
?.scrollLabel ??
_segments.firstOrNull?.scrollLabel;
label = labelText != null
? Text(
labelText,
style: ctx.textTheme.bodyLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
)
: null;
}
return NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: Stack(
children: [
RepaintBoundary(child: widget.child),
// Scroll Segments - wrapped in RepaintBoundary for better performance
RepaintBoundary(
child: _SegmentsLayer(
key: ValueKey('segments_${_isDragging}_${_segments.length}'),
segments: _segments,
topPadding: widget.topPadding,
isDragging: _isDragging,
),
),
PositionedDirectional(
top: _thumbTopOffset + widget.topPadding,
end: 0,
child: RepaintBoundary(
child: GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: _Scrubber(
thumbAnimation: _thumbAnimation,
labelAnimation: _labelAnimation,
label: label,
),
),
),
),
],
),
);
}
}
class _SegmentsLayer extends StatelessWidget {
final List<_Segment> segments;
final double topPadding;
final bool isDragging;
const _SegmentsLayer({
super.key,
required this.segments,
required this.topPadding,
required this.isDragging,
});
@override
Widget build(BuildContext context) {
return Visibility(
visible: isDragging,
child: Stack(
children: segments
.where((segment) => segment.showSegment)
.map(
(segment) => PositionedDirectional(
key: ValueKey('segment_${segment.date.millisecondsSinceEpoch}'),
top: topPadding + segment.startOffset,
end: 100,
child: RepaintBoundary(
child: _SegmentWidget(segment),
),
),
)
.toList(),
),
);
}
}
class _SegmentWidget extends StatelessWidget {
final _Segment _segment;
const _SegmentWidget(this._segment);
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: const BoxConstraints(maxHeight: 28),
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: Text(
_segment.date.year.toString(),
style: context.textTheme.labelMedium?.copyWith(
fontFamily: "OverpassMono",
fontWeight: FontWeight.w600,
),
),
),
),
),
);
}
}
class _ScrollLabel extends StatelessWidget {
final Text label;
final Color backgroundColor;
final Animation<double> animation;
const _ScrollLabel({
required this.label,
required this.backgroundColor,
required this.animation,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: FadeTransition(
opacity: animation,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: const BoxConstraints(maxHeight: 28),
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: label,
),
),
),
),
);
}
}
class _Scrubber extends StatelessWidget {
final Text? label;
final Animation<double> thumbAnimation;
final Animation<double> labelAnimation;
const _Scrubber({
this.label,
required this.thumbAnimation,
required this.labelAnimation,
});
@override
Widget build(BuildContext context) {
final backgroundColor = context.isDarkTheme
? context.colorScheme.primary.darken(amount: .5)
: context.colorScheme.primary;
return _SlideFadeTransition(
animation: thumbAnimation,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (label != null)
_ScrollLabel(
label: label!,
backgroundColor: backgroundColor,
animation: labelAnimation,
),
_CircularThumb(backgroundColor),
],
),
);
}
}
class _CircularThumb extends StatelessWidget {
final Color backgroundColor;
const _CircularThumb(this.backgroundColor);
@override
Widget build(BuildContext context) {
return CustomPaint(
foregroundPainter: const _ArrowPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(48.0),
topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0),
),
child: Container(
constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)),
),
),
);
}
}
class _ArrowPainter extends CustomPainter {
final Color color;
const _ArrowPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
paint,
);
canvas.drawPath(
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
paint,
);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
class _SlideFadeTransition extends StatelessWidget {
final Animation<double> _animation;
final Widget _child;
const _SlideFadeTransition({
required Animation<double> animation,
required Widget child,
}) : _animation = animation,
_child = child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) =>
_animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(
begin: const Offset(0.3, 0.0),
end: const Offset(0.0, 0.0),
).animate(_animation),
child: FadeTransition(
opacity: _animation,
child: _child,
),
),
);
}
}
class _Segment {
final DateTime date;
final double startOffset;
final String scrollLabel;
final bool showSegment;
const _Segment({
required this.date,
required this.startOffset,
required this.scrollLabel,
this.showSegment = false,
});
_Segment copyWith({
DateTime? date,
double? startOffset,
String? scrollLabel,
bool? showSegment,
}) {
return _Segment(
date: date ?? this.date,
startOffset: startOffset ?? this.startOffset,
scrollLabel: scrollLabel ?? this.scrollLabel,
showSegment: showSegment ?? this.showSegment,
);
}
@override
String toString() {
return 'Segment(scrollLabel: $scrollLabel, date: $date)';
}
}