Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,12 @@
import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB

View file

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double dimension;
final double spacing;
final TextDirection textDirection;
const FixedTimelineRow({
super.key,
required this.dimension,
required this.spacing,
required this.textDirection,
required super.children,
});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
}
@override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension;
renderObject.spacing = spacing;
renderObject.textDirection = textDirection;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
}
class _RowParentData extends ContainerBoxParentData<RenderBox> {}
class RenderFixedRow extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double dimension,
required double spacing,
required TextDirection textDirection,
}) : _dimension = dimension,
_spacing = spacing,
_textDirection = textDirection {
addAll(children);
}
double get dimension => _dimension;
double _dimension;
set dimension(double value) {
if (_dimension == value) return;
_dimension = value;
markNeedsLayout();
}
double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (_textDirection == value) return;
_textDirection = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _RowParentData) {
child.parentData = _RowParentData();
}
}
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => dimension;
@override
double computeMaxIntrinsicHeight(double width) => dimension;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@override
void performLayout() {
RenderBox? child = firstChild;
if (child == null) {
size = constraints.smallest;
return;
}
// Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
// Layout each child horizontally.
while (child != null) {
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset;
offset += Offset(dx, 0);
child = childParentData.nextSibling;
}
}
}

View file

@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class FixedSegment extends Segment {
final double tileHeight;
final int columnCount;
final double mainAxisExtend;
const FixedSegment({
required super.firstIndex,
required super.lastIndex,
required super.startOffset,
required super.endOffset,
required super.firstAssetIndex,
required super.bucket,
required this.tileHeight,
required this.columnCount,
required super.headerExtent,
required super.spacing,
required super.header,
}) : assert(tileHeight != 0),
mainAxisExtend = tileHeight + spacing;
@override
double indexToLayoutOffset(int index) {
final relativeIndex = index - gridIndex;
return relativeIndex < 0 ? startOffset : gridOffset + (mainAxisExtend * relativeIndex);
}
@override
int getMinChildIndexForScrollOffset(double scrollOffset) {
final adjustedOffset = scrollOffset - gridOffset;
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
return gridIndex + (adjustedOffset / mainAxisExtend).floor();
}
@override
int getMaxChildIndexForScrollOffset(double scrollOffset) {
final adjustedOffset = scrollOffset - gridOffset;
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1;
}
@override
Widget builder(BuildContext context, int index) {
final rowIndexInSegment = index - (firstIndex + 1);
final assetIndex = rowIndexInSegment * columnCount;
final assetCount = bucket.assetCount;
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
if (index == firstIndex) {
return TimelineHeader(bucket: bucket, header: header, height: headerExtent, assetOffset: firstAssetIndex);
}
return _FixedSegmentRow(
assetIndex: firstAssetIndex + assetIndex,
assetCount: numberOfAssets,
tileHeight: tileHeight,
spacing: spacing,
);
}
}
class _FixedSegmentRow extends ConsumerWidget {
final int assetIndex;
final int assetCount;
final double tileHeight;
final double spacing;
const _FixedSegmentRow({
required this.assetIndex,
required this.assetCount,
required this.tileHeight,
required this.spacing,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
if (isScrubbing) {
return _buildPlaceholder(context);
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
}
return FutureBuilder<List<BaseAsset>>(
future: timelineService.loadAssets(assetIndex, assetCount),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData, timelineService);
},
);
}
Widget _buildPlaceholder(BuildContext context) {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
);
}
}
class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset;
final int assetIndex;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
} else {
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
AssetViewer.setAsset(ref, asset);
unawaited(
ctx.pushRoute(
AssetViewerRoute(
initialIndex: assetIndex,
timelineService: ref.read(timelineServiceProvider),
heroOffset: heroOffset,
currentAlbum: ref.read(currentRemoteAlbumProvider),
),
),
);
}
}
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.isEnabled || multiSelectState.forceEnable) {
return;
}
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
}
bool _getLockSelectionStatus(WidgetRef ref) {
final lockSelectionAssets = ref.read(multiSelectProvider.select((state) => state.lockedSelectionAssets));
if (lockSelectionAssets.isEmpty) {
return false;
}
return lockSelectionAssets.contains(asset);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
return RepaintBoundary(
child: GestureDetector(
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
),
),
);
}
}

View file

@ -0,0 +1,71 @@
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
class FixedSegmentBuilder extends SegmentBuilder {
final double tileHeight;
final int columnCount;
const FixedSegmentBuilder({
required super.buckets,
required this.tileHeight,
required this.columnCount,
super.spacing,
super.groupBy,
});
List<Segment> generate() {
final segments = <Segment>[];
int firstIndex = 0;
double startOffset = 0;
int assetIndex = 0;
DateTime? previousDate;
for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i];
final assetCount = bucket.assetCount;
final numberOfRows = (assetCount / columnCount).ceil();
final segmentCount = numberOfRows + 1;
final segmentFirstIndex = firstIndex;
firstIndex += segmentCount;
final segmentLastIndex = firstIndex - 1;
final timelineHeader = switch (groupBy) {
GroupAssetsBy.month => HeaderType.month,
GroupAssetsBy.day || GroupAssetsBy.auto =>
bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
GroupAssetsBy.none => HeaderType.none,
};
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
final segmentStartOffset = startOffset;
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset;
segments.add(
FixedSegment(
firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex,
startOffset: segmentStartOffset,
endOffset: segmentEndOffset,
firstAssetIndex: assetIndex,
bucket: bucket,
tileHeight: tileHeight,
columnCount: columnCount,
headerExtent: headerExtent,
spacing: spacing,
header: timelineHeader,
),
);
assetIndex += assetCount;
if (bucket is TimeBucket) {
previousDate = bucket.date;
}
}
return segments;
}
}

View file

@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineHeader extends HookConsumerWidget {
final Bucket bucket;
final HeaderType header;
final double height;
final int assetOffset;
const TimelineHeader({
super.key,
required this.bucket,
required this.header,
required this.height,
required this.assetOffset,
});
String _formatMonth(BuildContext context, DateTime date) {
final formatter = date.year == DateTime.now().year
? DateFormat.MMMM(context.locale.toLanguageTag())
: DateFormat.yMMMM(context.locale.toLanguageTag());
return formatter.format(date);
}
String _formatDay(BuildContext context, DateTime date) {
final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag());
return formatter.format(date);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink();
}
final date = (bucket as TimeBucket).date;
final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay;
final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay;
return Padding(
padding: EdgeInsets.only(top: isMonthHeader ? 8.0 : 0.0, left: 12.0, right: 12.0),
child: SizedBox(
height: height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (isMonthHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatMonth(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
),
const Spacer(),
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
),
if (isDayHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatDay(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
const Spacer(),
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
),
],
),
),
);
}
}
class _BulkSelectIconButton extends ConsumerWidget {
final Bucket bucket;
final int assetOffset;
const _BulkSelectIconButton({required this.bucket, required this.assetOffset});
@override
Widget build(BuildContext context, WidgetRef ref) {
List<BaseAsset> bucketAssets;
try {
bucketAssets = ref.watch(timelineServiceProvider).getAssets(assetOffset, bucket.assetCount);
} catch (e) {
bucketAssets = <BaseAsset>[];
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets));
return isReadonlyModeEnabled
? const SizedBox.shrink()
: IconButton(
onPressed: () {
ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount);
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
icon: isAllSelected
? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor)
: Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary),
);
}
}

View file

@ -0,0 +1,615 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.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:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.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;
final bool snapToMonth;
/// Whether an app bar is present, affects coordinate calculations
final bool hasAppBar;
Scrubber({
super.key,
Key? scrollThumbKey,
required this.layoutSegments,
required this.timelineHeight,
this.topPadding = 0,
this.bottomPadding = 0,
this.monthSegmentSnappingOffset,
this.snapToMonth = true,
this.hasAppBar = true,
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 = 40.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 {
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
int _monthCount = 0;
DateTime? _currentScrubberDate;
Debouncer? _scrubberDebouncer;
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);
_monthCount = getMonthCount();
_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);
_monthCount = getMonthCount();
}
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
_scrubberDebouncer?.dispose();
super.dispose();
}
void _resetThumbTimer() {
_fadeOutTimer?.cancel();
_fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () {
_thumbAnimationController.reverse();
_fadeOutTimer = null;
});
}
int getMonthCount() {
return _segments.map((e) => "${e.date.month}_${e.date.year}").toSet().length;
}
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 _onScrubberDateChanged(DateTime date) {
if (_currentScrubberDate != date) {
// Date changed, immediately set scrubbing to true
_currentScrubberDate = date;
ref.read(timelineStateProvider.notifier).setScrubbing(true);
// Initialize debouncer if needed
_scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50));
// Debounce setting scrubbing to false
_scrubberDebouncer!.run(() {
if (_currentScrubberDate == date) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
}
});
}
}
void _onDragStart(DragStartDetails _) {
setState(() {
_isDragging = true;
_labelAnimationController.forward();
_fadeOutTimer?.cancel();
_lastLabel = null;
});
}
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) {
final label = nearestMonthSegment.scrollLabel;
if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label;
// Notify timeline state of the new scrubber date position
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
_onScrubberDateChanged(nearestMonthSegment.date);
}
}
}
if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) {
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
setState(() {
_thumbTopOffset = dragPosition;
_scrollController.jumpTo((dragPosition / _scrubberHeight) * _scrollController.position.maxScrollExtent);
});
} else 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) {
if (widget.hasAppBar) {
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);
}
// Get the local position relative to the gesture detector
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final localPosition = renderBox.globalToLocal(details.globalPosition);
return localPosition.dy.clamp(0.0, _scrubberHeight);
}
// Fallback to current logic if render box is not available
final dragAreaTop = widget.topPadding;
final relativePosition = details.globalPosition.dy - dragAreaTop;
return relativePosition.clamp(0.0, _scrubberHeight);
}
/// 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 _) {
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
});
ref.read(timelineStateProvider.notifier).setScrubbing(false);
// Reset scrubber tracking when drag ends
_currentScrubberDate = null;
_scrubberDebouncer?.dispose();
_scrubberDebouncer = null;
_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,
),
),
if (_scrollController.hasClients && _scrollController.position.maxScrollExtent > 0)
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: 36.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: "GoogleSansCode", 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)';
}
}

View file

@ -0,0 +1,95 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
// Segments are the time groups buckets in the timeline view.
// Each segment contains a header and a list of asset rows.
abstract class Segment {
// The index of the first row of the segment, usually the header, but if not, it can be any asset.
final int firstIndex;
// The index of the last asset of the segment.
final int lastIndex;
// The offset of the first row from beginning of the timeline.
final double startOffset;
// The offset of the last row from beginning of the timeline.
final double endOffset;
// The spacing between the header and the first row of the segment.
final double spacing;
final double headerExtent;
// the start index of the asset of this segment from the beginning of the timeline.
final int firstAssetIndex;
final Bucket bucket;
// The index of the row after the header
final int gridIndex;
// The offset of the row after the header
final double gridOffset;
// The type of the header
final HeaderType header;
const Segment({
required this.firstIndex,
required this.lastIndex,
required this.startOffset,
required this.endOffset,
required this.firstAssetIndex,
required this.bucket,
required this.headerExtent,
required this.spacing,
required this.header,
}) : gridIndex = firstIndex + 1,
gridOffset = startOffset + headerExtent + spacing;
bool containsIndex(int index) => firstIndex <= index && index <= lastIndex;
bool isWithinOffset(double offset) => startOffset <= offset && offset <= endOffset;
int getMinChildIndexForScrollOffset(double scrollOffset);
int getMaxChildIndexForScrollOffset(double scrollOffset);
double indexToLayoutOffset(int index);
Widget builder(BuildContext context, int index);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Segment &&
other.firstIndex == firstIndex &&
other.lastIndex == lastIndex &&
other.startOffset == startOffset &&
other.endOffset == endOffset &&
other.spacing == spacing &&
other.firstAssetIndex == firstAssetIndex &&
other.headerExtent == headerExtent &&
other.gridIndex == gridIndex &&
other.gridOffset == gridOffset &&
other.header == header;
}
@override
int get hashCode =>
firstIndex.hashCode ^
lastIndex.hashCode ^
startOffset.hashCode ^
endOffset.hashCode ^
spacing.hashCode ^
headerExtent.hashCode ^
firstAssetIndex.hashCode ^
gridIndex.hashCode ^
gridOffset.hashCode ^
header.hashCode;
@override
String toString() {
return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)';
}
}
extension SegmentListExtension on List<Segment> {
bool equals(List<Segment> other) => length == other.length && lastOrNull?.endOffset == other.lastOrNull?.endOffset;
Segment? findByIndex(int index) => firstWhereOrNull((s) => s.containsIndex(index));
Segment? findByOffset(double offset) => firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull;
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
abstract class SegmentBuilder {
final List<Bucket> buckets;
final double spacing;
final GroupAssetsBy groupBy;
const SegmentBuilder({required this.buckets, this.spacing = kTimelineSpacing, this.groupBy = GroupAssetsBy.day});
static double headerExtent(HeaderType header) => switch (header) {
HeaderType.month => kTimelineHeaderExtent,
HeaderType.day => kTimelineHeaderExtent * 0.90,
HeaderType.monthAndDay => kTimelineHeaderExtent * 1.6,
HeaderType.none => 0.0,
};
static Widget buildPlaceholder(
BuildContext context,
int count, {
Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing,
}) => RepaintBoundary(
child: FixedTimelineRow(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.generate(count, (_) => ThumbnailPlaceholder(width: size.width, height: size.height)),
),
);
}

View file

@ -0,0 +1,110 @@
import 'dart:math' as math;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class TimelineArgs {
final double maxWidth;
final double maxHeight;
final double spacing;
final int columnCount;
final bool showStorageIndicator;
final bool withStack;
final GroupAssetsBy? groupBy;
const TimelineArgs({
required this.maxWidth,
required this.maxHeight,
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
this.showStorageIndicator = false,
this.withStack = false,
this.groupBy,
});
@override
bool operator ==(covariant TimelineArgs other) {
return spacing == other.spacing &&
maxWidth == other.maxWidth &&
maxHeight == other.maxHeight &&
columnCount == other.columnCount &&
showStorageIndicator == other.showStorageIndicator &&
withStack == other.withStack &&
groupBy == other.groupBy;
}
@override
int get hashCode =>
maxWidth.hashCode ^
maxHeight.hashCode ^
spacing.hashCode ^
columnCount.hashCode ^
showStorageIndicator.hashCode ^
withStack.hashCode ^
groupBy.hashCode;
}
class TimelineState {
final bool isScrubbing;
final bool isScrolling;
const TimelineState({this.isScrubbing = false, this.isScrolling = false});
bool get isInteracting => isScrubbing || isScrolling;
@override
bool operator ==(covariant TimelineState other) {
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
}
@override
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling);
}
}
class TimelineStateNotifier extends Notifier<TimelineState> {
void setScrubbing(bool isScrubbing) {
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
state = state.copyWith(isScrolling: isScrolling);
}
@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
}
// This provider watches the buckets from the timeline service & args and serves the segments.
// It should be used only after the timeline service and timeline args provider is overridden
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
final args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount;
final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount;
final groupBy = args.groupBy ?? GroupAssetsBy.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)];
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
columnCount: columnCount,
spacing: spacing,
groupBy: groupBy,
).generate();
});
}, dependencies: [timelineServiceProvider, timelineArgsProvider]);
final timelineStateProvider = NotifierProvider<TimelineStateNotifier, TimelineState>(TimelineStateNotifier.new);

View file

@ -0,0 +1,696 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class Timeline extends StatelessWidget {
const Timeline({
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.23),
this.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
final bool withStack;
final GroupAssetsBy? groupBy;
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
),
),
),
);
}
}
class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
@override
bool build() => true;
@override
void setReadonlyMode(bool value) {}
@override
void toggleReadonlyMode() {}
}
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
@override
ConsumerState createState() => _SliverTimelineState();
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
// Drag selection state
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
int? _scaleRestoreAssetIndex;
@override
void initState() {
super.initState();
_scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreScalePosition,
);
_eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
_perRow = currentTilesPerRow;
_scaleFactor = 7.0 - _perRow;
_baseScaleFactor = _scaleFactor;
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
}
void _onEvent(Event event) {
switch (event) {
case ScrollToTopEvent():
ref.read(timelineStateProvider.notifier).setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
case ScrollToDateEvent scrollToDateEvent:
_scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent():
setState(() {});
default:
break;
}
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
void _restoreScalePosition(_) {
if (_scaleRestoreAssetIndex == null) return;
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _scaleRestoreAssetIndex!);
if (targetSegment != null) {
final assetIndexInSegment = _scaleRestoreAssetIndex! - targetSegment.firstAssetIndex;
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_scrollController.jumpTo(targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
});
}
});
_scaleRestoreAssetIndex = null;
}
@override
void dispose() {
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
}
void _scrollToDate(DateTime date) {
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
// Find the segment that contains assets from the target date
final targetSegment = segments.firstWhereOrNull((segment) {
if (segment.bucket is TimeBucket) {
final segmentDate = (segment.bucket as TimeBucket).date;
// Check if the segment date matches the target date (year, month, day)
return segmentDate.year == date.year && segmentDate.month == date.month && segmentDate.day == date.day;
}
return false;
});
// If exact date not found, try to find the closest month
final fallbackSegment =
targetSegment ??
segments.firstWhereOrNull((segment) {
if (segment.bucket is TimeBucket) {
final segmentDate = (segment.bucket as TimeBucket).date;
return segmentDate.year == date.year && segmentDate.month == date.month;
}
return false;
});
if (fallbackSegment != null) {
// Scroll to the segment with a small offset to show the header
final targetOffset = fallbackSegment.startOffset - 50;
ref.read(timelineStateProvider.notifier).setScrubbing(true);
_scrollController
.animateTo(
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
} else {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
}
});
}
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
});
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) return;
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
@override
Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider);
final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
return PopScope(
canPop: !isMultiSelectEnabled,
onPopInvokedWithResult: (_, __) {
if (isMultiSelectEnabled) {
ref.read(multiSelectProvider.notifier).reset();
}
},
child: asyncSegments.widgetWhen(
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
],
);
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {
timeline = grid;
}
return PrimaryScrollController(
controller: _scrollController,
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
final currentOffset = _scrollController.offset.clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
int? targetAssetIndex;
if (segment != null) {
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
} else {
targetAssetIndex = segment.firstAssetIndex;
}
}
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
_scaleRestoreAssetIndex = targetAssetIndex;
});
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
onAssetEnter: _handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
),
);
},
),
);
}
}
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
final List<Segment> _segments;
const _SliverSegmentedList({required List<Segment> segments, required super.delegate}) : _segments = segments;
@override
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
_RenderSliverTimelineBoxAdaptor(childManager: context as SliverMultiBoxAdaptorElement, segments: _segments);
@override
void updateRenderObject(BuildContext context, _RenderSliverTimelineBoxAdaptor renderObject) {
renderObject.segments = _segments;
}
}
/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets
class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
List<Segment> _segments;
set segments(List<Segment> updatedSegments) {
if (_segments.equals(updatedSegments)) {
return;
}
_segments = updatedSegments;
markNeedsLayout();
}
_RenderSliverTimelineBoxAdaptor({required super.childManager, required List<Segment> segments})
: _segments = segments;
int getMinChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? 0;
int getMaxChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ?? 0;
double indexToLayoutOffset(int index) =>
(_segments.findByIndex(index) ?? _segments.lastOrNull)?.indexToLayoutOffset(index) ?? 0;
double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
@override
void performLayout() {
childManager.didStartLayout();
// Assume initially that we have enough children to fill the viewport/cache area.
childManager.setDidUnderflow(false);
final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetScrollOffset = scrollOffset + remainingExtent;
// Find the index of the first child that should be visible or in the leading cache area.
final int firstRequiredChildIndex = getMinChildIndexForScrollOffset(scrollOffset);
// Find the index of the last child that should be visible or in the trailing cache area.
final int? lastRequiredChildIndex = targetScrollOffset.isFinite
? getMaxChildIndexForScrollOffset(targetScrollOffset)
: null;
// Remove children that are no longer visible or within the cache area.
if (firstChild == null) {
collectGarbage(0, 0);
} else {
final int leadingChildrenToRemove = calculateLeadingGarbage(firstIndex: firstRequiredChildIndex);
final int trailingChildrenToRemove = lastRequiredChildIndex == null
? 0
: calculateTrailingGarbage(lastIndex: lastRequiredChildIndex);
collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove);
}
// If there are currently no children laid out (e.g., initial load),
// try to add the first child needed for the current scroll offset.
if (firstChild == null) {
final double firstChildLayoutOffset = indexToLayoutOffset(firstRequiredChildIndex);
final bool childAdded = addInitialChild(index: firstRequiredChildIndex, layoutOffset: firstChildLayoutOffset);
if (!childAdded) {
// There are either no children, or we are past the end of all our children.
final double max = firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset();
geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max);
childManager.didFinishLayout();
return;
}
}
// Layout children that might have scrolled into view from the top (before the current firstChild).
RenderBox? highestLaidOutChild;
final childConstraints = constraints.asBoxConstraints();
for (int currentIndex = indexOf(firstChild!) - 1; currentIndex >= firstRequiredChildIndex; --currentIndex) {
final RenderBox? newLeadingChild = insertAndLayoutLeadingChild(childConstraints);
if (newLeadingChild == null) {
// If a child is missing where we expect one, it indicates
// an inconsistency in offset that needs correction.
final Segment? segment = _segments.findByIndex(currentIndex) ?? _segments.firstOrNull;
geometry = SliverGeometry(
// Request a scroll correction based on where the missing child should have been.
scrollOffsetCorrection: segment?.indexToLayoutOffset(currentIndex) ?? 0.0,
);
// Parent will re-layout everything.
return;
}
final childParentData = newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
assert(childParentData.index == currentIndex);
highestLaidOutChild ??= newLeadingChild;
}
// If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]),
// or even if it did, we need to ensure the first visible child is correctly laid out
// and establish our starting point for laying out trailing children.
// If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children.
// The [firstChild] that existed at the start of performLayout is still the first one we need.
if (highestLaidOutChild == null) {
firstChild!.layout(childConstraints);
final childParentData = firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(firstRequiredChildIndex);
highestLaidOutChild = firstChild;
}
RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild;
// Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children
// until we reach the [lastRequiredChildIndex] or run out of children.
double calculatedMaxScrollOffset = double.infinity;
for (
int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1;
lastRequiredChildIndex == null || currentIndex <= lastRequiredChildIndex;
++currentIndex
) {
RenderBox? child = childAfter(mostRecentlyLaidOutChild!);
if (child == null || indexOf(child) != currentIndex) {
child = insertAndLayoutChild(childConstraints, after: mostRecentlyLaidOutChild);
if (child == null) {
final Segment? segment = _segments.findByIndex(currentIndex) ?? _segments.lastOrNull;
calculatedMaxScrollOffset = segment?.indexToLayoutOffset(currentIndex) ?? computeMaxScrollOffset();
break;
}
} else {
child.layout(childConstraints);
}
mostRecentlyLaidOutChild = child;
final childParentData = mostRecentlyLaidOutChild.parentData! as SliverMultiBoxAdaptorParentData;
assert(childParentData.index == currentIndex);
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
}
final int lastLaidOutChildIndex = indexOf(lastChild!);
final double leadingScrollOffset = indexToLayoutOffset(firstRequiredChildIndex);
final double trailingScrollOffset = indexToLayoutOffset(lastLaidOutChildIndex + 1);
assert(
firstRequiredChildIndex == 0 ||
(childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <= precisionErrorTolerance,
);
assert(debugAssertChildListIsNonEmptyAndContiguous());
assert(indexOf(firstChild!) == firstRequiredChildIndex);
assert(lastRequiredChildIndex == null || lastLaidOutChildIndex <= lastRequiredChildIndex);
calculatedMaxScrollOffset = math.min(calculatedMaxScrollOffset, estimateMaxScrollOffset());
final double paintExtent = calculatePaintOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
final double cacheExtent = calculateCacheOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
final double targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent;
final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
: null;
final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset);
geometry = SliverGeometry(
scrollExtent: calculatedMaxScrollOffset,
paintExtent: paintExtent,
maxPaintExtent: maxPaintExtent,
// Indicates if there's content scrolled off-screen.
// This is true if the last child needed for painting is actually laid out,
// or if the first child is partially visible.
hasVisualOverflow:
(targetLastIndexForPaint != null && lastLaidOutChildIndex >= targetLastIndexForPaint) ||
constraints.scrollOffset > 0.0,
cacheExtent: cacheExtent,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (calculatedMaxScrollOffset == trailingScrollOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
}
class _MultiSelectStatusButton extends ConsumerWidget {
const _MultiSelectStatusButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
return ElevatedButton.icon(
onPressed: () => ref.read(multiSelectProvider.notifier).reset(),
icon: Icon(Icons.close_rounded, color: context.colorScheme.onPrimary),
label: Text(
selectCount.toString(),
style: context.textTheme.titleMedium?.copyWith(height: 2.5, color: context.colorScheme.onPrimary),
),
);
}
}
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}

View file

@ -0,0 +1,212 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TimelineDragRegion extends StatefulWidget {
final Widget child;
final void Function(TimelineAssetIndex valueKey)? onStart;
final void Function(TimelineAssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const TimelineDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _TimelineDragRegionState();
}
class _TimelineDragRegionState extends State<TimelineDragRegion> {
late TimelineAssetIndex? assetUnderPointer;
late TimelineAssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
TimelineAssetIndex? _getValueKeyAtPosition(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _TimelineAssetIndexProxy)?.target
as _TimelineAssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPosition(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPosition(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class TimelineAssetIndexWrapper extends SingleChildRenderObjectWidget {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndexWrapper({
required Widget super.child,
required this.assetIndex,
required this.segmentIndex,
super.key,
});
@override
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy createRenderObject(BuildContext context) {
return _TimelineAssetIndexProxy(
index: TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy renderObject,
) {
renderObject.index = TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex);
}
}
class _TimelineAssetIndexProxy extends RenderProxyBox {
TimelineAssetIndex index;
_TimelineAssetIndexProxy({required this.index});
}
class TimelineAssetIndex {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex});
@override
bool operator ==(covariant TimelineAssetIndex other) {
if (identical(this, other)) return true;
return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex;
}
@override
int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode;
}