diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 327ad7ec..f461a74e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -547,6 +547,10 @@ "@sortOptionTimeDescendingLabel": { "description": "Sort by time, in descending order" }, + "albumEditDragRearrangeNotification": "Long press and drag an item to rearrange it manually", + "@albumEditDragRearrangeNotification": { + "description": "Instructions on how to rearrange photos" + }, "changelogTitle": "Changelog", "@changelogTitle": { diff --git a/lib/session_storage.dart b/lib/session_storage.dart index fdc84fae..c53aa1f1 100644 --- a/lib/session_storage.dart +++ b/lib/session_storage.dart @@ -9,5 +9,8 @@ class SessionStorage { /// Whether the range select notification has been shown to user bool hasShowRangeSelectNotification = false; + /// Whether the drag to rearrange notification has been shown + bool hasShowDragRearrangeNotification = false; + static SessionStorage _inst = SessionStorage._(); } diff --git a/lib/widget/album_viewer.dart b/lib/widget/album_viewer.dart index 7d40563e..dbc62336 100644 --- a/lib/widget/album_viewer.dart +++ b/lib/widget/album_viewer.dart @@ -15,11 +15,13 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/list_extension.dart'; +import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/resync_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/widget/album_viewer_mixin.dart'; +import 'package:nc_photos/widget/draggable_item_list_mixin.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/viewer.dart'; @@ -59,6 +61,7 @@ class _AlbumViewerState extends State with WidgetsBindingObserver, SelectableItemStreamListMixin, + DraggableItemListMixin, AlbumViewerMixin { @override initState() { @@ -90,6 +93,15 @@ class _AlbumViewerState extends State enterEditMode() { super.enterEditMode(); _editAlbum = _album.copyWith(); + + if (!SessionStorage().hasShowDragRearrangeNotification) { + SnackBarManager().showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context).albumEditDragRearrangeNotification), + duration: k.snackBarDurationNormal, + )); + SessionStorage().hasShowDragRearrangeNotification = true; + } } @override @@ -158,29 +170,39 @@ class _AlbumViewerState extends State ], ); } + + Widget content = CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8), + sliver: isEditMode + ? buildDraggableItemList( + maxCrossAxisExtent: thumbSize.toDouble(), + onMaxExtentChanged: (value) { + _itemListMaxExtent = value; + }, + ) + : buildItemStreamList( + maxCrossAxisExtent: thumbSize.toDouble(), + ), + ), + ], + ); + if (isEditMode) { + content = Listener( + onPointerMove: _onEditModePointerMove, + child: content, + ); + } return buildItemStreamListOuter( context, child: Theme( data: Theme.of(context).copyWith( accentColor: AppTheme.getOverscrollIndicatorColor(context), ), - child: CustomScrollView( - slivers: [ - _buildAppBar(context), - SliverPadding( - padding: const EdgeInsets.symmetric(vertical: 8), - sliver: SliverIgnorePointer( - ignoring: isEditMode, - sliver: SliverOpacity( - opacity: isEditMode ? .25 : 1, - sliver: buildItemStreamList( - maxCrossAxisExtent: thumbSize.toDouble(), - ), - ), - ), - ), - ], - ), + child: content, ), ); } @@ -342,21 +364,82 @@ class _AlbumViewerState extends State }); } + void _onEditModePointerMove(PointerMoveEvent event) { + assert(isEditMode); + if (!_isDragging) { + return; + } + if (event.position.dy >= MediaQuery.of(context).size.height - 100) { + // near bottom of screen + if (_isDragScrollingDown == true) { + return; + } + final maxExtent = + _itemListMaxExtent ?? _scrollController.position.maxScrollExtent; + _log.fine("[_onEditModePointerMove] Begin scrolling down"); + if (_scrollController.offset < + _scrollController.position.maxScrollExtent) { + _scrollController.animateTo(maxExtent, + duration: Duration( + milliseconds: + ((maxExtent - _scrollController.offset) * 1.6).round()), + curve: Curves.linear); + _isDragScrollingDown = true; + } + } else if (event.position.dy <= 100) { + // near top of screen + if (_isDragScrollingDown == false) { + return; + } + _log.fine("[_onEditModePointerMove] Begin scrolling up"); + if (_scrollController.offset > 0) { + _scrollController.animateTo(0, + duration: Duration( + milliseconds: (_scrollController.offset * 1.6).round()), + curve: Curves.linear); + _isDragScrollingDown = false; + } + } else if (_isDragScrollingDown != null) { + _log.fine("[_onEditModePointerMove] Stop scrolling"); + _scrollController.jumpTo(_scrollController.offset); + _isDragScrollingDown = null; + } + } + + void _onItemMoved(int fromIndex, int toIndex, bool isBefore) { + if (fromIndex == toIndex) { + return; + } + final item = _sortedItems.removeAt(fromIndex); + final newIndex = + toIndex + (isBefore ? 0 : 1) + (fromIndex < toIndex ? -1 : 0); + _sortedItems.insert(newIndex, item); + _editAlbum = _editAlbum.copyWith( + sortProvider: AlbumNullSortProvider(), + // save the current order + provider: AlbumStaticProvider( + items: _sortedItems, + ), + ); + setState(() { + _transformItems(); + }); + } + void _transformItems() { - List sortedItems; if (_editAlbum != null) { // edit mode - sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum)); + _sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum)); } else { - sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album)); + _sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album)); } - _backingFiles = sortedItems + _backingFiles = _sortedItems .whereType() .map((e) => e.file) .where((element) => file_util.isSupportedFormat(element)) .toList(); - itemStreamListItems = () sync* { + final items = () sync* { for (int i = 0; i < _backingFiles.length; ++i) { final f = _backingFiles[i]; @@ -364,16 +447,38 @@ class _AlbumViewerState extends State width: thumbSize, height: thumbSize); if (file_util.isSupportedImageFormat(f)) { yield _ImageListItem( + index: i, file: f, account: widget.account, previewUrl: previewUrl, onTap: () => _onItemTap(i), + onDropBefore: (dropItem) => + _onItemMoved((dropItem as _ListItem).index, i, true), + onDropAfter: (dropItem) => + _onItemMoved((dropItem as _ListItem).index, i, false), + onDragStarted: () { + _isDragging = true; + }, + onDragEndedAny: () { + _isDragging = false; + }, ); } else if (file_util.isSupportedVideoFormat(f)) { yield _VideoListItem( + index: i, account: widget.account, previewUrl: previewUrl, onTap: () => _onItemTap(i), + onDropBefore: (dropItem) => + _onItemMoved((dropItem as _ListItem).index, i, true), + onDropAfter: (dropItem) => + _onItemMoved((dropItem as _ListItem).index, i, false), + onDragStarted: () { + _isDragging = true; + }, + onDragEndedAny: () { + _isDragging = false; + }, ); } else { _log.shout( @@ -382,6 +487,8 @@ class _AlbumViewerState extends State } }() .toList(); + itemStreamListItems = items; + draggableItemList = items; } bool _shouldPropagateResyncedAlbum(Album album) { @@ -415,18 +522,33 @@ class _AlbumViewerState extends State AlbumStaticProvider.of(a).items; Album _album; + var _sortedItems = []; var _backingFiles = []; + final _scrollController = ScrollController(); + double _itemListMaxExtent; + bool _isDragging = false; + // == null if not drag scrolling + bool _isDragScrollingDown; final _editFormKey = GlobalKey(); Album _editAlbum; static final _log = Logger("widget.album_viewer._AlbumViewerState"); } -abstract class _ListItem implements SelectableItem { +abstract class _ListItem implements SelectableItem, DraggableItem { _ListItem({ + @required this.index, VoidCallback onTap, - }) : _onTap = onTap; + DragTargetAccept onDropBefore, + DragTargetAccept onDropAfter, + VoidCallback onDragStarted, + VoidCallback onDragEndedAny, + }) : _onTap = onTap, + _onDropBefore = onDropBefore, + _onDropAfter = onDropAfter, + _onDragStarted = onDragStarted, + _onDragEndedAny = onDragEndedAny; @override get onTap => _onTap; @@ -434,19 +556,59 @@ abstract class _ListItem implements SelectableItem { @override get isSelectable => true; + @override + get isDraggable => true; + + @override + get onDropBefore => _onDropBefore; + + @override + get onDropAfter => _onDropAfter; + + @override + get onDragStarted => _onDragStarted; + + @override + get onDragEndedAny => _onDragEndedAny; + @override get staggeredTile => const StaggeredTile.count(1, 1); + @override + toString() { + return "$runtimeType {" + "index: $index, " + "}"; + } + + final int index; + final VoidCallback _onTap; + final DragTargetAccept _onDropBefore; + final DragTargetAccept _onDropAfter; + final VoidCallback _onDragStarted; + final VoidCallback _onDragEndedAny; } class _ImageListItem extends _ListItem { _ImageListItem({ + @required int index, @required this.file, @required this.account, @required this.previewUrl, VoidCallback onTap, - }) : super(onTap: onTap); + DragTargetAccept onDropBefore, + DragTargetAccept onDropAfter, + VoidCallback onDragStarted, + VoidCallback onDragEndedAny, + }) : super( + index: index, + onTap: onTap, + onDropBefore: onDropBefore, + onDropAfter: onDropAfter, + onDragStarted: onDragStarted, + onDragEndedAny: onDragEndedAny, + ); @override buildWidget(BuildContext context) { @@ -464,10 +626,22 @@ class _ImageListItem extends _ListItem { class _VideoListItem extends _ListItem { _VideoListItem({ + @required int index, @required this.account, @required this.previewUrl, VoidCallback onTap, - }) : super(onTap: onTap); + DragTargetAccept onDropBefore, + DragTargetAccept onDropAfter, + VoidCallback onDragStarted, + VoidCallback onDragEndedAny, + }) : super( + index: index, + onTap: onTap, + onDropBefore: onDropBefore, + onDropAfter: onDropAfter, + onDragStarted: onDragStarted, + onDragEndedAny: onDragEndedAny, + ); @override buildWidget(BuildContext context) { diff --git a/lib/widget/draggable.dart b/lib/widget/draggable.dart new file mode 100644 index 00000000..4469536e --- /dev/null +++ b/lib/widget/draggable.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; + +class Draggable extends StatelessWidget { + Draggable({ + Key key, + @required this.data, + @required this.child, + this.onDropBefore, + this.onDropAfter, + this.onDragStarted, + this.onDragEndedAny, + this.feedbackSize, + }) : super(key: key); + + @override + build(BuildContext context) { + final buildIndicator = (alignment, isActive) { + return Stack( + children: [ + Container(), + Visibility( + visible: isActive, + child: Align( + alignment: alignment, + child: Container( + constraints: BoxConstraints.tightFor(width: 2), + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ); + }; + + return Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: LongPressDraggable( + data: data, + dragAnchorStrategy: pointerDragAnchorStrategy, + onDragStarted: onDragStarted, + onDragEnd: (_) => onDragEndedAny?.call(), + onDragCompleted: onDragEndedAny, + onDraggableCanceled: (v, o) => onDragEndedAny?.call(), + feedback: FractionalTranslation( + translation: const Offset(-.5, -.5), + child: SizedBox( + width: feedbackSize?.width ?? 128, + height: feedbackSize?.height ?? 128, + child: Opacity( + opacity: .5, + child: child, + ), + ), + ), + child: child, + childWhenDragging: Opacity( + opacity: .25, + child: child, + ), + ), + ), + if (onDropBefore != null || onDropAfter != null) + Positioned.fill( + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + if (onDropBefore != null) + Expanded( + child: DragTarget( + builder: (context, candidateItems, rejectedItems) { + return buildIndicator(AlignmentDirectional.centerStart, + candidateItems.isNotEmpty); + }, + onAccept: (item) { + _log.fine("[build] Dropping $item before $data"); + onDropBefore(item); + }, + ), + ), + if (onDropAfter != null) + Expanded( + child: DragTarget( + builder: (context, candidateItems, rejectedItems) { + return buildIndicator(AlignmentDirectional.centerEnd, + candidateItems.isNotEmpty); + }, + onAccept: (item) { + _log.fine("[build] Dropping $item after $data"); + onDropAfter(item); + }, + ), + ), + ], + ), + ), + ], + ); + } + + final T data; + final Widget child; + + /// Called when some item dropped before this item + final DragTargetAccept onDropBefore; + + /// Called when some item dropped after this item + final DragTargetAccept onDropAfter; + + final VoidCallback onDragStarted; + + /// Called when either one of onDragEnd, onDragCompleted or + /// onDraggableCanceled is called. + /// + /// The callback might be called multiple times per each drag event + final VoidCallback onDragEndedAny; + + /// Size of the feedback widget that appears under the pointer. + /// + /// Right now a translucent version of [child] is being shown + final Size feedbackSize; + + static final _log = Logger("widget.draggable.Draggable"); +} diff --git a/lib/widget/draggable_item_list_mixin.dart b/lib/widget/draggable_item_list_mixin.dart new file mode 100644 index 00000000..b7e5dd60 --- /dev/null +++ b/lib/widget/draggable_item_list_mixin.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:nc_photos/widget/draggable.dart' as _; +import 'package:nc_photos/widget/measurable_item_list.dart'; + +abstract class DraggableItem { + Widget buildWidget(BuildContext context); + + bool get isDraggable => false; + DragTargetAccept get onDropBefore => null; + DragTargetAccept get onDropAfter => null; + VoidCallback get onDragStarted => null; + VoidCallback get onDragEndedAny => null; + StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1); +} + +mixin DraggableItemListMixin on State { + @protected + Widget buildDraggableItemList({ + @required double maxCrossAxisExtent, + ValueChanged onMaxExtentChanged, + }) { + _maxCrossAxisExtent = maxCrossAxisExtent; + return MeasurableItemList( + maxCrossAxisExtent: maxCrossAxisExtent, + itemCount: _items.length, + itemBuilder: _buildItem, + staggeredTileBuilder: (index) => _items[index].staggeredTile, + onMaxExtentChanged: onMaxExtentChanged, + ); + } + + @protected + set draggableItemList(List newItems) { + _items = newItems; + } + + Widget _buildItem(BuildContext context, int index) { + final item = _items[index]; + return _.Draggable( + data: item, + child: item.buildWidget(context), + onDropBefore: item.onDropBefore, + onDropAfter: item.onDropAfter, + onDragStarted: item.onDragStarted, + onDragEndedAny: item.onDragEndedAny, + feedbackSize: _maxCrossAxisExtent != null + ? Size(_maxCrossAxisExtent * .65, _maxCrossAxisExtent * .65) + : null, + ); + } + + var _items = []; + double _maxCrossAxisExtent; +}