Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
12
mobile/lib/presentation/widgets/timeline/constants.dart
Normal file
12
mobile/lib/presentation/widgets/timeline/constants.dart
Normal 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
|
||||
149
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal file
149
mobile/lib/presentation/widgets/timeline/fixed/row.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
116
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal file
116
mobile/lib/presentation/widgets/timeline/header.widget.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
615
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal file
615
mobile/lib/presentation/widgets/timeline/scrubber.widget.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
95
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal file
95
mobile/lib/presentation/widgets/timeline/segment.model.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
110
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal file
110
mobile/lib/presentation/widgets/timeline/timeline.state.dart
Normal 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);
|
||||
696
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
696
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue