album picker: added filter field

This commit is contained in:
Thibault Deckers 2020-11-21 12:06:35 +09:00
parent 3fb3cf1f88
commit 318010b66c
10 changed files with 185 additions and 51 deletions

View file

@ -6,7 +6,7 @@ buildscript {
jcenter() jcenter()
} }
dependencies { 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 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.4' classpath 'com.google.gms:google-services:4.3.4'

View file

@ -45,7 +45,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
sortFactor: EntrySortFactor.date, sortFactor: EntrySortFactor.date,
).sortedEntries; ).sortedEntries;
ValueNotifier<SourceState> stateNotifier = ValueNotifier<SourceState>(SourceState.ready); ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
List<DateMetadata> _savedDates; List<DateMetadata> _savedDates;

16
lib/utils/debouncer.dart Normal file
View file

@ -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);
}
}

View file

@ -12,8 +12,10 @@ class Durations {
static const staggeredAnimation = Duration(milliseconds: 375); static const staggeredAnimation = Duration(milliseconds: 375);
static const dialogFieldReachAnimation = Duration(milliseconds: 300); static const dialogFieldReachAnimation = Duration(milliseconds: 300);
// collection animations
static const appBarTitleAnimation = Duration(milliseconds: 300); static const appBarTitleAnimation = Duration(milliseconds: 300);
static const appBarActionChangeAnimation = Duration(milliseconds: 200);
// collection animations
static const filterBarRemovalAnimation = Duration(milliseconds: 400); static const filterBarRemovalAnimation = Duration(milliseconds: 400);
static const collectionOpOverlayAnimation = Duration(milliseconds: 300); static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200); static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
@ -40,4 +42,5 @@ class Durations {
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 200);
} }

View file

@ -10,15 +10,14 @@ import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/collection/collection_actions.dart'; import 'package:aves/widgets/collection/collection_actions.dart';
import 'package:aves/widgets/collection/empty.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/feedback.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.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/action_delegates/size_aware.dart';
import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.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/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/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -70,47 +69,34 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwar
} }
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async { Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
final filterNotifier = ValueNotifier('');
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source); final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
final destinationAlbum = await Navigator.push( final destinationAlbum = await Navigator.push(
context, context,
MaterialPageRoute<String>( MaterialPageRoute<String>(
builder: (context) { builder: (context) {
Widget appBar = AlbumPickAppBar(
copy: copy,
actionDelegate: chipSetActionDelegate,
onFilterChanged: (filter) => filterNotifier.value = filter,
);
return Selector<Settings, ChipSortFactor>( return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.albumSortFactor, selector: (context, s) => s.albumSortFactor,
builder: (context, sortFactor, child) { builder: (context, sortFactor, child) {
return FilterGridPage( return ValueListenableBuilder<String>(
source: source, valueListenable: filterNotifier,
appBar: SliverAppBar( builder: (context, filter, child) => FilterGridPage(
leading: BackButton(), source: source,
title: Text(copy ? 'Copy to Album' : 'Move to Album'), appBar: appBar,
actions: [ filterEntries: AlbumListPage.getAlbumEntries(source, filter: filter),
IconButton( filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
icon: Icon(AIcons.createAlbum), emptyBuilder: () => EmptyContent(
onPressed: () async { icon: AIcons.album,
final newAlbum = await showDialog<String>( text: 'No albums',
context: context, ),
builder: (context) => CreateAlbumDialog(), onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: 'Create album',
),
IconButton(
icon: Icon(AIcons.sort),
onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort),
),
],
floating: true,
), ),
filterEntries: AlbumListPage.getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: 'No albums',
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
); );
}, },
); );

View file

@ -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<String> 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<String>(
context: context,
builder: (context) => CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(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<String> onChanged;
const AlbumFilterBar({@required this.onChanged});
@override
Size get preferredSize => Size.fromHeight(kToolbarHeight);
@override
_AlbumFilterBarState createState() => _AlbumFilterBarState();
}
class _AlbumFilterBarState extends State<AlbumFilterBar> {
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),
),
)
],
),
);
}
}

View file

@ -58,12 +58,16 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries // common with album selection page to move/copy entries
static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) { static Map<String, ImageEntry> getAlbumEntries(CollectionSource source, {String filter}) {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album); final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
// albums are initially sorted by name at the source level // albums are initially sorted by name at the source level
var sortedAlbums = source.sortedAlbums; 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) { if (settings.albumSortFactor == ChipSortFactor.name) {
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[]; final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];

View file

@ -40,7 +40,7 @@ class ImageView extends StatefulWidget {
class _ImageViewState extends State<ImageView> { class _ImageViewState extends State<ImageView> {
final PhotoViewController _photoViewController = PhotoViewController(); final PhotoViewController _photoViewController = PhotoViewController();
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController(); final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier<ViewState>(ViewState.zero); final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
StreamSubscription<PhotoViewControllerValue> _subscription; StreamSubscription<PhotoViewControllerValue> _subscription;
Size _photoViewChildSize; Size _photoViewChildSize;

View file

@ -259,7 +259,7 @@ class ImageSearchDelegate {
queryTextController.text = value; queryTextController.text = value;
} }
final ValueNotifier<SearchBody> currentBodyNotifier = ValueNotifier<SearchBody>(null); final ValueNotifier<SearchBody> currentBodyNotifier = ValueNotifier(null);
SearchBody get currentBody => currentBodyNotifier.value; SearchBody get currentBody => currentBodyNotifier.value;

View file

@ -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:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,7 +20,8 @@ class SearchPage extends StatefulWidget {
} }
class _SearchPageState extends State<SearchPage> { class _SearchPageState extends State<SearchPage> {
FocusNode focusNode = FocusNode(); final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
final FocusNode _focusNode = FocusNode();
@override @override
void initState() { void initState() {
@ -26,8 +29,8 @@ class _SearchPageState extends State<SearchPage> {
widget.delegate.queryTextController.addListener(_onQueryChanged); widget.delegate.queryTextController.addListener(_onQueryChanged);
widget.animation.addStatusListener(_onAnimationStatusChanged); widget.animation.addStatusListener(_onAnimationStatusChanged);
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
focusNode.addListener(_onFocusChanged); _focusNode.addListener(_onFocusChanged);
widget.delegate.focusNode = focusNode; widget.delegate.focusNode = _focusNode;
} }
@override @override
@ -37,7 +40,7 @@ class _SearchPageState extends State<SearchPage> {
widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.animation.removeStatusListener(_onAnimationStatusChanged);
widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate.focusNode = null; widget.delegate.focusNode = null;
focusNode.dispose(); _focusNode.dispose();
} }
void _onAnimationStatusChanged(AnimationStatus status) { void _onAnimationStatusChanged(AnimationStatus status) {
@ -45,7 +48,7 @@ class _SearchPageState extends State<SearchPage> {
return; return;
} }
widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.animation.removeStatusListener(_onAnimationStatusChanged);
focusNode.requestFocus(); _focusNode.requestFocus();
} }
@override @override
@ -57,20 +60,20 @@ class _SearchPageState extends State<SearchPage> {
oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
oldWidget.delegate.focusNode = null; oldWidget.delegate.focusNode = null;
widget.delegate.focusNode = focusNode; widget.delegate.focusNode = _focusNode;
} }
} }
void _onFocusChanged() { void _onFocusChanged() {
if (focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { if (_focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) {
widget.delegate.showSuggestions(context); widget.delegate.showSuggestions(context);
} }
} }
void _onQueryChanged() { void _onQueryChanged() {
setState(() { _debouncer(() => setState(() {
// rebuild ourselves because query changed. // rebuild ourselves because query changed.
}); }));
} }
void _onSearchBodyChanged() { void _onSearchBodyChanged() {
@ -106,7 +109,7 @@ class _SearchPageState extends State<SearchPage> {
leading: widget.delegate.buildLeading(context), leading: widget.delegate.buildLeading(context),
title: TextField( title: TextField(
controller: widget.delegate.queryTextController, controller: widget.delegate.queryTextController,
focusNode: focusNode, focusNode: _focusNode,
style: theme.textTheme.headline6, style: theme.textTheme.headline6,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: (_) => widget.delegate.showResults(context), onSubmitted: (_) => widget.delegate.showResults(context),