Skip to content

Commit c5f14ad

Browse files
authored
feat: drag to select beta timeline (#20456)
1 parent 1378f22 commit c5f14ad

File tree

3 files changed

+343
-41
lines changed

3 files changed

+343
-41
lines changed

mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'dart:math' as math;
22

33
import 'package:auto_route/auto_route.dart';
4-
import 'package:flutter/widgets.dart';
4+
import 'package:flutter/material.dart';
55
import 'package:hooks_riverpod/hooks_riverpod.dart';
66
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
77
import 'package:immich_mobile/domain/services/timeline.service.dart';
@@ -11,6 +11,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
1111
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
1212
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
1313
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
14+
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
1415
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
1516
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
1617
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -125,10 +126,14 @@ class _FixedSegmentRow extends ConsumerWidget {
125126
textDirection: Directionality.of(context),
126127
children: [
127128
for (int i = 0; i < assets.length; i++)
128-
_AssetTileWidget(
129-
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
130-
asset: assets[i],
129+
TimelineAssetIndexWrapper(
131130
assetIndex: assetIndex + i,
131+
segmentIndex: 0, // For simplicity, using 0 for now
132+
child: _AssetTileWidget(
133+
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
134+
asset: assets[i],
135+
assetIndex: assetIndex + i,
136+
),
132137
),
133138
],
134139
);

mobile/lib/presentation/widgets/timeline/timeline.widget.dart

Lines changed: 122 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:collection';
23
import 'dart:math' as math;
34

45
import 'package:collection/collection.dart';
@@ -7,6 +8,7 @@ import 'package:flutter/gestures.dart';
78
import 'package:flutter/material.dart';
89
import 'package:flutter/rendering.dart';
910
import 'package:hooks_riverpod/hooks_riverpod.dart';
11+
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
1012
import 'package:immich_mobile/domain/models/setting.model.dart';
1113
import 'package:immich_mobile/domain/models/timeline.model.dart';
1214
import 'package:immich_mobile/domain/utils/event_stream.dart';
@@ -16,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
1618
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
1719
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
1820
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
21+
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
1922
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
2023
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
2124
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -89,6 +92,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
8992
final _scrollController = ScrollController();
9093
StreamSubscription? _eventSubscription;
9194

95+
// Drag selection state
96+
bool _dragging = false;
97+
TimelineAssetIndex? _dragAnchorIndex;
98+
final Set<BaseAsset> _draggedAssets = HashSet();
99+
ScrollPhysics? _scrollPhysics;
100+
92101
int _perRow = 4;
93102
double _scaleFactor = 3.0;
94103
double _baseScaleFactor = 3.0;
@@ -164,6 +173,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
164173
});
165174
}
166175

176+
// Drag selection methods
177+
void _setDragStartIndex(TimelineAssetIndex index) {
178+
setState(() {
179+
_scrollPhysics = const ClampingScrollPhysics();
180+
_dragAnchorIndex = index;
181+
_dragging = true;
182+
});
183+
}
184+
185+
void _stopDrag() {
186+
WidgetsBinding.instance.addPostFrameCallback((_) {
187+
// Update the physics post frame to prevent sudden change in physics on iOS.
188+
setState(() {
189+
_scrollPhysics = null;
190+
});
191+
});
192+
setState(() {
193+
_dragging = false;
194+
_draggedAssets.clear();
195+
});
196+
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
197+
Future.delayed(const Duration(milliseconds: 300), () {
198+
if (mounted) {
199+
ref.read(timelineStateProvider.notifier).setScrolling(false);
200+
}
201+
});
202+
}
203+
204+
void _dragScroll(ScrollDirection direction) {
205+
_scrollController.animateTo(
206+
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
207+
duration: const Duration(milliseconds: 125),
208+
curve: Curves.easeOut,
209+
);
210+
}
211+
212+
void _handleDragAssetEnter(TimelineAssetIndex index) {
213+
if (_dragAnchorIndex == null || !_dragging) return;
214+
215+
final timelineService = ref.read(timelineServiceProvider);
216+
final dragAnchorIndex = _dragAnchorIndex!;
217+
218+
// Calculate the range of assets to select
219+
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
220+
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
221+
final count = endIndex - startIndex + 1;
222+
223+
// Load the assets in the range
224+
if (timelineService.hasRange(startIndex, count)) {
225+
final selectedAssets = timelineService.getAssets(startIndex, count);
226+
227+
// Clear previous drag selection and add new range
228+
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
229+
for (final asset in _draggedAssets) {
230+
multiSelectNotifier.deselectAsset(asset);
231+
}
232+
_draggedAssets.clear();
233+
234+
for (final asset in selectedAssets) {
235+
multiSelectNotifier.selectAsset(asset);
236+
_draggedAssets.add(asset);
237+
}
238+
}
239+
}
240+
167241
@override
168242
Widget build(BuildContext _) {
169243
final asyncSegments = ref.watch(timelineSegmentProvider);
@@ -216,46 +290,57 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
216290
},
217291
),
218292
},
219-
child: Stack(
220-
children: [
221-
Scrubber(
222-
layoutSegments: segments,
223-
timelineHeight: maxHeight,
224-
topPadding: topPadding,
225-
bottomPadding: bottomPadding,
226-
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
227-
child: CustomScrollView(
228-
primary: true,
229-
cacheExtent: maxHeight * 2,
230-
slivers: [
231-
if (isSelectionMode)
232-
const SelectionSliverAppBar()
233-
else if (widget.appBar != null)
234-
widget.appBar!,
235-
if (widget.topSliverWidget != null) widget.topSliverWidget!,
236-
_SliverSegmentedList(
237-
segments: segments,
238-
delegate: SliverChildBuilderDelegate(
239-
(ctx, index) {
240-
if (index >= childCount) return null;
241-
final segment = segments.findByIndex(index);
242-
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
243-
},
244-
childCount: childCount,
245-
addAutomaticKeepAlives: false,
246-
// We add repaint boundary around tiles, so skip the auto boundaries
247-
addRepaintBoundaries: false,
293+
child: TimelineDragRegion(
294+
onStart: _setDragStartIndex,
295+
onAssetEnter: _handleDragAssetEnter,
296+
onEnd: _stopDrag,
297+
onScroll: _dragScroll,
298+
onScrollStart: () {
299+
// Minimize the bottom sheet when drag selection starts
300+
ref.read(timelineStateProvider.notifier).setScrolling(true);
301+
},
302+
child: Stack(
303+
children: [
304+
Scrubber(
305+
layoutSegments: segments,
306+
timelineHeight: maxHeight,
307+
topPadding: topPadding,
308+
bottomPadding: bottomPadding,
309+
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
310+
child: CustomScrollView(
311+
primary: true,
312+
physics: _scrollPhysics,
313+
cacheExtent: maxHeight * 2,
314+
slivers: [
315+
if (isSelectionMode)
316+
const SelectionSliverAppBar()
317+
else if (widget.appBar != null)
318+
widget.appBar!,
319+
if (widget.topSliverWidget != null) widget.topSliverWidget!,
320+
_SliverSegmentedList(
321+
segments: segments,
322+
delegate: SliverChildBuilderDelegate(
323+
(ctx, index) {
324+
if (index >= childCount) return null;
325+
final segment = segments.findByIndex(index);
326+
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
327+
},
328+
childCount: childCount,
329+
addAutomaticKeepAlives: false,
330+
// We add repaint boundary around tiles, so skip the auto boundaries
331+
addRepaintBoundaries: false,
332+
),
248333
),
249-
),
250-
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
251-
],
334+
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
335+
],
336+
),
252337
),
253-
),
254-
if (!isSelectionMode && isMultiSelectEnabled) ...[
255-
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
256-
if (widget.bottomSheet != null) widget.bottomSheet!,
338+
if (!isSelectionMode && isMultiSelectEnabled) ...[
339+
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
340+
if (widget.bottomSheet != null) widget.bottomSheet!,
341+
],
257342
],
258-
],
343+
),
259344
),
260345
),
261346
);

0 commit comments

Comments
 (0)