mirror of
https://github.com/immich-app/immich.git
synced 2025-08-07 09:04:09 -04:00
feat: drag to select beta timeline (#20456)
This commit is contained in:
parent
1378f22368
commit
c5f14adff0
@ -1,7 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
@ -11,6 +11,7 @@ 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.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.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.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/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.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/timeline.provider.dart';
|
||||||
@ -125,11 +126,15 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
textDirection: Directionality.of(context),
|
textDirection: Directionality.of(context),
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < assets.length; i++)
|
for (int i = 0; i < assets.length; i++)
|
||||||
_AssetTileWidget(
|
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)),
|
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||||
asset: assets[i],
|
asset: assets[i],
|
||||||
assetIndex: assetIndex + i,
|
assetIndex: assetIndex + i,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@ -7,6 +8,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/setting.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/models/timeline.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
@ -16,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
|
|||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.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/segment.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.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/setting.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/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@ -89,6 +92,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
|
|
||||||
|
// Drag selection state
|
||||||
|
bool _dragging = false;
|
||||||
|
TimelineAssetIndex? _dragAnchorIndex;
|
||||||
|
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||||
|
ScrollPhysics? _scrollPhysics;
|
||||||
|
|
||||||
int _perRow = 4;
|
int _perRow = 4;
|
||||||
double _scaleFactor = 3.0;
|
double _scaleFactor = 3.0;
|
||||||
double _baseScaleFactor = 3.0;
|
double _baseScaleFactor = 3.0;
|
||||||
@ -164,6 +173,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
@override
|
||||||
Widget build(BuildContext _) {
|
Widget build(BuildContext _) {
|
||||||
final asyncSegments = ref.watch(timelineSegmentProvider);
|
final asyncSegments = ref.watch(timelineSegmentProvider);
|
||||||
@ -216,6 +290,15 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
child: TimelineDragRegion(
|
||||||
|
onStart: _setDragStartIndex,
|
||||||
|
onAssetEnter: _handleDragAssetEnter,
|
||||||
|
onEnd: _stopDrag,
|
||||||
|
onScroll: _dragScroll,
|
||||||
|
onScrollStart: () {
|
||||||
|
// Minimize the bottom sheet when drag selection starts
|
||||||
|
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||||
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Scrubber(
|
Scrubber(
|
||||||
@ -226,6 +309,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
|
physics: _scrollPhysics,
|
||||||
cacheExtent: maxHeight * 2,
|
cacheExtent: maxHeight * 2,
|
||||||
slivers: [
|
slivers: [
|
||||||
if (isSelectionMode)
|
if (isSelectionMode)
|
||||||
@ -258,6 +342,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -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…
x
Reference in New Issue
Block a user