diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8d923104..2199f8ee 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -356,6 +356,10 @@ "@previousTooltip": { "description": "Tooltip of the previous button" }, + "webSelectRangeNotification": "Hold shift + click to select all in between", + "@webSelectRangeNotification": { + "description": "Inform web user how to select items in range" + }, "changelogTitle": "Changelog", "@changelogTitle": { "description": "Title of the changelog dialog" diff --git a/lib/session_storage.dart b/lib/session_storage.dart new file mode 100644 index 00000000..fdc84fae --- /dev/null +++ b/lib/session_storage.dart @@ -0,0 +1,13 @@ +/// Hold non-persisted global variables +class SessionStorage { + factory SessionStorage() { + return _inst; + } + + SessionStorage._(); + + /// Whether the range select notification has been shown to user + bool hasShowRangeSelectNotification = false; + + static SessionStorage _inst = SessionStorage._(); +} diff --git a/lib/widget/album_viewer.dart b/lib/widget/album_viewer.dart index 138b0216..5943bbd4 100644 --- a/lib/widget/album_viewer.dart +++ b/lib/widget/album_viewer.dart @@ -1,4 +1,7 @@ +import 'dart:math' as math; + import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -15,6 +18,7 @@ import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/list_extension.dart'; import 'package:nc_photos/pref.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/update_album.dart'; @@ -61,13 +65,16 @@ class _AlbumViewerState extends State _transformItems(); _initCover(); _thumbZoomLevel = Pref.inst().getAlbumViewerZoomLevel(0); + _keyboardFocus.requestFocus(); } @override build(BuildContext context) { return AppTheme( child: Scaffold( - body: Builder(builder: (context) => _buildContent(context)), + body: Builder( + builder: (context) => + kIsWeb ? _buildWebContent(context) : _buildContent(context)), ), ); } @@ -84,6 +91,18 @@ class _AlbumViewerState extends State } catch (_) {} } + Widget _buildWebContent(BuildContext context) { + assert(kIsWeb); + // support switching pages with keyboard on web + return RawKeyboardListener( + onKey: (ev) { + _isRangeSelectionMode = ev.isShiftPressed; + }, + focusNode: _keyboardFocus, + child: _buildContent(context), + ); + } + Widget _buildContent(BuildContext context) { return Theme( data: Theme.of(context).copyWith( @@ -231,12 +250,23 @@ class _AlbumViewerState extends State // unselect setState(() { _selectedItems.remove(item); + _lastSelectPosition = null; }); } else { // select - setState(() { - _selectedItems.add(item); - }); + if (_isRangeSelectionMode && _lastSelectPosition != null) { + setState(() { + _selectedItems.addAll(_items.sublist( + math.min(_lastSelectPosition, index), + math.max(_lastSelectPosition, index) + 1)); + _lastSelectPosition = index; + }); + } else { + setState(() { + _lastSelectPosition = index; + _selectedItems.add(item); + }); + } } } else { Navigator.pushNamed(context, Viewer.routeName, @@ -246,8 +276,17 @@ class _AlbumViewerState extends State void _onItemLongPress(_GridItem item, int index) { setState(() { + _lastSelectPosition = index; _selectedItems.add(item); }); + + if (kIsWeb && !SessionStorage().hasShowRangeSelectNotification) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).webSelectRangeNotification), + duration: k.snackBarDurationNormal, + )); + SessionStorage().hasShowRangeSelectNotification = true; + } } void _onSelectionAppBarRemovePressed() { @@ -329,6 +368,8 @@ class _AlbumViewerState extends State } bool get _isSelectionMode => _selectedItems.isNotEmpty; + int _lastSelectPosition; + bool _isRangeSelectionMode = false; Album _album; final _items = <_GridItem>[]; @@ -337,7 +378,10 @@ class _AlbumViewerState extends State String _coverPreviewUrl; var _thumbZoomLevel = 0; - final _selectedItems = <_GridItem>[]; + final _selectedItems = <_GridItem>{}; + + /// used to gain focus on web for keyboard support + final _keyboardFocus = FocusNode(); static final _log = Logger("widget.album_viewer._AlbumViewerState"); } diff --git a/lib/widget/home_photos.dart b/lib/widget/home_photos.dart index dd5c6ca5..58b9506d 100644 --- a/lib/widget/home_photos.dart +++ b/lib/widget/home_photos.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -18,6 +20,7 @@ import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/metadata_task_manager.dart'; import 'package:nc_photos/pref.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/remove.dart'; @@ -46,6 +49,7 @@ class _HomePhotosState extends State { super.initState(); _initBloc(); _thumbZoomLevel = Pref.inst().getHomePhotosZoomLevel(0); + _keyboardFocus.requestFocus(); } @override @@ -55,7 +59,9 @@ class _HomePhotosState extends State { listener: (context, state) => _onStateChange(context, state), child: BlocBuilder( bloc: _bloc, - builder: (context, state) => _buildContent(context, state), + builder: (context, state) => kIsWeb + ? _buildWebContent(context, state) + : _buildContent(context, state), ), ); } @@ -71,6 +77,18 @@ class _HomePhotosState extends State { } } + Widget _buildWebContent(BuildContext context, ScanDirBlocState state) { + assert(kIsWeb); + // support switching pages with keyboard on web + return RawKeyboardListener( + onKey: (ev) { + _isRangeSelectionMode = ev.isShiftPressed; + }, + focusNode: _keyboardFocus, + child: _buildContent(context, state), + ); + } + Widget _buildContent(BuildContext context, ScanDirBlocState state) { return Stack( children: [ @@ -254,12 +272,24 @@ class _HomePhotosState extends State { // unselect setState(() { _selectedItems.remove(item); + _lastSelectPosition = null; }); } else { // select - setState(() { - _selectedItems.add(item); - }); + if (_isRangeSelectionMode && _lastSelectPosition != null) { + setState(() { + _selectedItems.addAll(_items + .sublist(math.min(_lastSelectPosition, index), + math.max(_lastSelectPosition, index) + 1) + .whereType<_GridFileItem>()); + _lastSelectPosition = index; + }); + } else { + setState(() { + _lastSelectPosition = index; + _selectedItems.add(item); + }); + } } } else { final fileIndex = _itemIndexToFileIndex(index); @@ -275,8 +305,17 @@ class _HomePhotosState extends State { return; } setState(() { + _lastSelectPosition = index; _selectedItems.add(item); }); + + if (kIsWeb && !SessionStorage().hasShowRangeSelectNotification) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context).webSelectRangeNotification), + duration: k.snackBarDurationNormal, + )); + SessionStorage().hasShowRangeSelectNotification = true; + } } void _onSelectionAppBarAddToAlbumPressed(BuildContext context) { @@ -404,6 +443,8 @@ class _HomePhotosState extends State { .where((element) => file_util.isSupportedFormat(element)) .sorted(compareFileDateTimeDescending); + final lastSelectedItem = + _lastSelectPosition != null ? _items[_lastSelectPosition] : null; _items.clear(); String currentDateStr; for (final f in _backingFiles) { @@ -424,6 +465,18 @@ class _HomePhotosState extends State { } _transformSelectedItems(); + + // Keep _lastSelectPosition if no changes, drop otherwise + int newLastSelectPosition; + try { + if (lastSelectedItem != null && + lastSelectedItem is _GridFileItem && + (_items[_lastSelectPosition] as _GridFileItem).file.path == + lastSelectedItem.file.path) { + newLastSelectPosition = _lastSelectPosition; + } + } catch (_) {} + _lastSelectPosition = newLastSelectPosition; } /// Map selected items to the new item list @@ -488,6 +541,8 @@ class _HomePhotosState extends State { } bool get _isSelectionMode => _selectedItems.isNotEmpty; + int _lastSelectPosition; + bool _isRangeSelectionMode = false; ScanDirBloc _bloc; @@ -496,7 +551,10 @@ class _HomePhotosState extends State { var _thumbZoomLevel = 0; - final _selectedItems = <_GridFileItem>[]; + final _selectedItems = <_GridFileItem>{}; + + /// used to gain focus on web for keyboard support + final _keyboardFocus = FocusNode(); static final _log = Logger("widget.home_photos._HomePhotosState"); static const _menuValueRefresh = 0;