1
1
import 'dart:async' ;
2
+ import 'dart:collection' ;
2
3
import 'dart:math' as math;
3
4
4
5
import 'package:collection/collection.dart' ;
@@ -7,6 +8,7 @@ import 'package:flutter/gestures.dart';
7
8
import 'package:flutter/material.dart' ;
8
9
import 'package:flutter/rendering.dart' ;
9
10
import 'package:hooks_riverpod/hooks_riverpod.dart' ;
11
+ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' ;
10
12
import 'package:immich_mobile/domain/models/setting.model.dart' ;
11
13
import 'package:immich_mobile/domain/models/timeline.model.dart' ;
12
14
import 'package:immich_mobile/domain/utils/event_stream.dart' ;
@@ -16,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
16
18
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart' ;
17
19
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart' ;
18
20
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' ;
21
+ import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart' ;
19
22
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart' ;
20
23
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart' ;
21
24
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart' ;
@@ -89,6 +92,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
89
92
final _scrollController = ScrollController ();
90
93
StreamSubscription ? _eventSubscription;
91
94
95
+ // Drag selection state
96
+ bool _dragging = false ;
97
+ TimelineAssetIndex ? _dragAnchorIndex;
98
+ final Set <BaseAsset > _draggedAssets = HashSet ();
99
+ ScrollPhysics ? _scrollPhysics;
100
+
92
101
int _perRow = 4 ;
93
102
double _scaleFactor = 3.0 ;
94
103
double _baseScaleFactor = 3.0 ;
@@ -164,6 +173,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
164
173
});
165
174
}
166
175
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
+
167
241
@override
168
242
Widget build (BuildContext _) {
169
243
final asyncSegments = ref.watch (timelineSegmentProvider);
@@ -216,46 +290,57 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
216
290
},
217
291
),
218
292
},
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
+ ),
248
333
),
249
- ),
250
- const SliverPadding (padding : EdgeInsets . only (bottom : scrubberBottomPadding)) ,
251
- ] ,
334
+ const SliverPadding (padding : EdgeInsets . only (bottom : scrubberBottomPadding) ),
335
+ ] ,
336
+ ) ,
252
337
),
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
+ ] ,
257
342
],
258
- ] ,
343
+ ) ,
259
344
),
260
345
),
261
346
);
0 commit comments