diff --git a/android/build.gradle b/android/build.gradle index 83edf9619..82eba64c0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { jcenter() } dependencies { - // TODO TLAD upgrade AGP to 4+ when this is fixed: https://github.com/flutter/flutter/issues/58247 + // TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808 classpath 'com.android.tools.build:gradle:3.6.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.3.4' diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 3c179ab25..d3e669e18 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -45,7 +45,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM sortFactor: EntrySortFactor.date, ).sortedEntries; - ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); + ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); List _savedDates; diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 000000000..b41cd90bc --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class Debouncer { + final Duration delay; + + Timer _timer; + + Debouncer({@required this.delay}); + + void call(Function action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } +} diff --git a/lib/utils/durations.dart b/lib/utils/durations.dart index 68394e51b..af41013c9 100644 --- a/lib/utils/durations.dart +++ b/lib/utils/durations.dart @@ -12,8 +12,10 @@ class Durations { static const staggeredAnimation = Duration(milliseconds: 375); static const dialogFieldReachAnimation = Duration(milliseconds: 300); - // collection animations static const appBarTitleAnimation = Duration(milliseconds: 300); + static const appBarActionChangeAnimation = Duration(milliseconds: 200); + + // collection animations static const filterBarRemovalAnimation = Duration(milliseconds: 400); static const collectionOpOverlayAnimation = Duration(milliseconds: 300); static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200); @@ -40,4 +42,5 @@ class Durations { static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const softKeyboardDisplayDelay = Duration(milliseconds: 300); + static const searchDebounceDelay = Duration(milliseconds: 200); } diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index b089d1f89..8823c3efa 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -10,15 +10,14 @@ import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/widgets/collection/collection_actions.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/action_delegates/size_aware.dart'; import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:flutter/foundation.dart'; @@ -70,47 +69,34 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwar } Future _moveSelection(BuildContext context, {@required bool copy}) async { + final filterNotifier = ValueNotifier(''); final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source); final destinationAlbum = await Navigator.push( context, MaterialPageRoute( builder: (context) { + Widget appBar = AlbumPickAppBar( + copy: copy, + actionDelegate: chipSetActionDelegate, + onFilterChanged: (filter) => filterNotifier.value = filter, + ); + return Selector( selector: (context, s) => s.albumSortFactor, builder: (context, sortFactor, child) { - return FilterGridPage( - source: source, - appBar: SliverAppBar( - leading: BackButton(), - title: Text(copy ? 'Copy to Album' : 'Move to Album'), - actions: [ - IconButton( - icon: Icon(AIcons.createAlbum), - onPressed: () async { - final newAlbum = await showDialog( - context: context, - builder: (context) => CreateAlbumDialog(), - ); - if (newAlbum != null && newAlbum.isNotEmpty) { - Navigator.pop(context, newAlbum); - } - }, - tooltip: 'Create album', - ), - IconButton( - icon: Icon(AIcons.sort), - onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort), - ), - ], - floating: true, + return ValueListenableBuilder( + valueListenable: filterNotifier, + builder: (context, filter, child) => FilterGridPage( + source: source, + appBar: appBar, + filterEntries: AlbumListPage.getAlbumEntries(source, filter: filter), + filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: 'No albums', + ), + onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), ), - filterEntries: AlbumListPage.getAlbumEntries(source), - filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: 'No albums', - ), - onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), ); }, ); diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart new file mode 100644 index 000000000..09c3accea --- /dev/null +++ b/lib/widgets/filter_grids/album_pick.dart @@ -0,0 +1,122 @@ +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/utils/durations.dart'; +import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart'; +import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; +import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class AlbumPickAppBar extends StatelessWidget { + final bool copy; + final AlbumChipSetActionDelegate actionDelegate; + final ValueChanged onFilterChanged; + + const AlbumPickAppBar({ + @required this.copy, + @required this.actionDelegate, + @required this.onFilterChanged, + }); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + leading: BackButton(), + title: Text(copy ? 'Copy to Album' : 'Move to Album'), + bottom: AlbumFilterBar( + onChanged: onFilterChanged, + ), + actions: [ + IconButton( + icon: Icon(AIcons.createAlbum), + onPressed: () async { + final newAlbum = await showDialog( + context: context, + builder: (context) => CreateAlbumDialog(), + ); + if (newAlbum != null && newAlbum.isNotEmpty) { + Navigator.pop(context, newAlbum); + } + }, + tooltip: 'Create album', + ), + IconButton( + icon: Icon(AIcons.sort), + onPressed: () => actionDelegate.onActionSelected(context, ChipSetAction.sort), + ), + ], + floating: true, + ); + } +} + +class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget { + final ValueChanged onChanged; + + const AlbumFilterBar({@required this.onChanged}); + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight); + + @override + _AlbumFilterBarState createState() => _AlbumFilterBarState(); +} + +class _AlbumFilterBarState extends State { + final TextEditingController _controller = TextEditingController(text: ''); + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + + @override + Widget build(BuildContext context) { + final clearButton = IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + _controller.clear(); + widget.onChanged(''); + }, + tooltip: 'Clear', + ); + return Container( + height: kToolbarHeight, + alignment: Alignment.topCenter, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon(AIcons.search), + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + icon: Padding( + padding: EdgeInsetsDirectional.only(start: 16), + child: Icon(AIcons.search), + ), + // border: OutlineInputBorder(), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, + ), + textInputAction: TextInputAction.search, + onChanged: (s) => _debouncer(() => widget.onChanged(s)), + ), + ), + AnimatedBuilder( + animation: _controller, + builder: (context, child) => AnimatedSwitcher( + duration: Durations.appBarActionChangeAnimation, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: child, + ), + ), + child: _controller.text.isNotEmpty ? clearButton : SizedBox(width: 16), + ), + ) + ], + ), + ); + } +} diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index b10093d0e..a83481524 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -58,12 +58,16 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static Map getAlbumEntries(CollectionSource source) { + static Map getAlbumEntries(CollectionSource source, {String filter}) { final pinned = settings.pinnedFilters.whereType().map((f) => f.album); final entriesByDate = source.sortedEntriesForFilterList; // albums are initially sorted by name at the source level var sortedAlbums = source.sortedAlbums; + if (filter != null && filter.isNotEmpty) { + filter = filter.toUpperCase(); + sortedAlbums = sortedAlbums.where((album) => source.getUniqueAlbumName(album).toUpperCase().contains(filter)).toList(); + } if (settings.albumSortFactor == ChipSortFactor.name) { final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index f1e57d2ca..1ac44a6b7 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -40,7 +40,7 @@ class ImageView extends StatefulWidget { class _ImageViewState extends State { final PhotoViewController _photoViewController = PhotoViewController(); final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController(); - final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); + final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); StreamSubscription _subscription; Size _photoViewChildSize; diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 1ba7d3503..b7b343bd1 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -259,7 +259,7 @@ class ImageSearchDelegate { queryTextController.text = value; } - final ValueNotifier currentBodyNotifier = ValueNotifier(null); + final ValueNotifier currentBodyNotifier = ValueNotifier(null); SearchBody get currentBody => currentBodyNotifier.value; diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index 5919ffc7f..b1a7698a0 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -1,3 +1,5 @@ +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -18,7 +20,8 @@ class SearchPage extends StatefulWidget { } class _SearchPageState extends State { - FocusNode focusNode = FocusNode(); + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + final FocusNode _focusNode = FocusNode(); @override void initState() { @@ -26,8 +29,8 @@ class _SearchPageState extends State { widget.delegate.queryTextController.addListener(_onQueryChanged); widget.animation.addStatusListener(_onAnimationStatusChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); - focusNode.addListener(_onFocusChanged); - widget.delegate.focusNode = focusNode; + _focusNode.addListener(_onFocusChanged); + widget.delegate.focusNode = _focusNode; } @override @@ -37,7 +40,7 @@ class _SearchPageState extends State { widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); widget.delegate.focusNode = null; - focusNode.dispose(); + _focusNode.dispose(); } void _onAnimationStatusChanged(AnimationStatus status) { @@ -45,7 +48,7 @@ class _SearchPageState extends State { return; } widget.animation.removeStatusListener(_onAnimationStatusChanged); - focusNode.requestFocus(); + _focusNode.requestFocus(); } @override @@ -57,20 +60,20 @@ class _SearchPageState extends State { oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); oldWidget.delegate.focusNode = null; - widget.delegate.focusNode = focusNode; + widget.delegate.focusNode = _focusNode; } } void _onFocusChanged() { - if (focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { + if (_focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { widget.delegate.showSuggestions(context); } } void _onQueryChanged() { - setState(() { - // rebuild ourselves because query changed. - }); + _debouncer(() => setState(() { + // rebuild ourselves because query changed. + })); } void _onSearchBodyChanged() { @@ -106,7 +109,7 @@ class _SearchPageState extends State { leading: widget.delegate.buildLeading(context), title: TextField( controller: widget.delegate.queryTextController, - focusNode: focusNode, + focusNode: _focusNode, style: theme.textTheme.headline6, textInputAction: TextInputAction.search, onSubmitted: (_) => widget.delegate.showResults(context),