shortcut to search page

This commit is contained in:
Thibault Deckers 2020-09-07 11:40:00 +09:00
parent 9da57961fc
commit af9edebf86
12 changed files with 389 additions and 68 deletions

View file

@ -87,21 +87,21 @@ public class MainActivity extends FlutterActivity {
private void setupShortcuts() {
ShortcutManager shortcutManager = getSystemService(ShortcutManager.class);
Intent searchIntent = new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class);
searchIntent.putExtra("page", "search");
// do not use 'route' as extra key, as the Flutter framework acts on it
ShortcutInfo search = new ShortcutInfo.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(Icon.createWithResource(this, R.drawable.ic_outline_search))
.setIntent(searchIntent)
.setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class)
.putExtra("page", "/search"))
.build();
Intent videosIntent = new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class);
videosIntent.putExtra("page", "collection");
videosIntent.putExtra("filters", new String[]{"anyVideo"});
ShortcutInfo videos = new ShortcutInfo.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(Icon.createWithResource(this, R.drawable.ic_outline_movie))
.setIntent(videosIntent)
.setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class)
.putExtra("page", "/collection")
.putExtra("filters", new String[]{"anyVideo"}))
.build();
shortcutManager.setDynamicShortcuts(Arrays.asList(videos, search));
}

View file

@ -1,7 +1,7 @@
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
enum HomePageSetting { collection, albums, search }
enum HomePageSetting { collection, albums }
extension ExtraHomePageSetting on HomePageSetting {
String get name {
@ -10,8 +10,6 @@ extension ExtraHomePageSetting on HomePageSetting {
return 'Collection';
case HomePageSetting.albums:
return 'Albums';
case HomePageSetting.search:
return 'Search';
default:
return toString();
}

View file

@ -295,7 +295,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
collection.clearSelection();
break;
case CollectionAction.stats:
unawaited(_goToStats());
_goToStats();
break;
case CollectionAction.group:
final value = await showDialog<EntryGroupFactor>(
@ -338,14 +338,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
void _goToSearch() {
showSearch(
context: context,
delegate: ImageSearchDelegate(collection.source, collection.addFilter),
);
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
source: collection.source,
parentCollection: collection,
),
));
}
Future<void> _goToStats() {
return Navigator.push(
void _goToStats() {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: StatsPage.routeName),

View file

@ -6,40 +6,46 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/collection/search/expandable_filter_row.dart';
import 'package:aves/widgets/collection/search_page.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
class ImageSearchDelegate {
final CollectionSource source;
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
final FilterCallback onSelection;
final CollectionLens parentCollection;
ImageSearchDelegate(this.source, this.onSelection);
ImageSearchDelegate({@required this.source, this.parentCollection});
@override
ThemeData appBarTheme(BuildContext context) {
return Theme.of(context);
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () => _select(context, null),
tooltip: 'Back',
);
return Navigator.canPop(context)
? IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () => _goBack(context),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
)
: CloseButton(
onPressed: SystemNavigator.pop,
);
}
@override
List<Widget> buildActions(BuildContext context) {
return [
if (query.isNotEmpty)
@ -54,7 +60,6 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
];
}
@override
Widget buildSuggestions(BuildContext context) {
final upQuery = query.trim().toUpperCase();
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
@ -137,7 +142,6 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
);
}
@override
Widget buildResults(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// `buildResults` is called in the build phase,
@ -154,14 +158,160 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
}
void _select(BuildContext context, CollectionFilter filter) {
if (parentCollection != null) {
_applyToParentCollectionPage(context, filter);
} else {
_goToCollectionPage(context, filter);
}
}
void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) {
if (filter != null) {
onSelection(filter);
parentCollection.addFilter(filter);
}
// we post closing the search page after applying the filter selection
// so that hero animation target is ready in the `FilterBar`,
// even when the target is a child of an `AnimatedList`
WidgetsBinding.instance.addPostFrameCallback((_) {
close(context, null);
_goBack(context);
});
}
void _goBack(BuildContext context) {
_clean();
Navigator.of(context).pop();
}
void _goToCollectionPage(BuildContext context, CollectionFilter filter) {
_clean();
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
settings.navRemoveRoutePredicate(CollectionPage.routeName),
);
}
void _clean() {
currentBody = null;
focusNode?.unfocus();
}
// adapted from `SearchDelegate`
void showResults(BuildContext context) {
focusNode?.unfocus();
currentBody = SearchBody.results;
}
void showSuggestions(BuildContext context) {
assert(focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
focusNode.requestFocus();
currentBody = SearchBody.suggestions;
}
Animation<double> get transitionAnimation => proxyAnimation;
FocusNode focusNode;
final TextEditingController queryTextController = TextEditingController();
final ProxyAnimation proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
String get query => queryTextController.text;
set query(String value) {
assert(query != null);
queryTextController.text = value;
}
final ValueNotifier<SearchBody> currentBodyNotifier = ValueNotifier<SearchBody>(null);
SearchBody get currentBody => currentBodyNotifier.value;
set currentBody(SearchBody value) {
currentBodyNotifier.value = value;
}
SearchPageRoute route;
}
// adapted from `SearchDelegate`
enum SearchBody { suggestions, results }
// adapted from `SearchDelegate`
class SearchPageRoute<T> extends PageRoute<T> {
SearchPageRoute({
@required this.delegate,
}) : assert(delegate != null),
super(settings: RouteSettings(name: SearchPage.routeName)) {
assert(
delegate.route == null,
'The ${delegate.runtimeType} instance is currently used by another active '
'search. Please close that search by calling close() on the SearchDelegate '
'before openening another search with the same delegate instance.',
);
delegate.route = this;
}
final ImageSearchDelegate delegate;
@override
Color get barrierColor => null;
@override
String get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
@override
bool get maintainState => false;
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: animation,
child: child,
);
}
@override
Animation<double> createAnimation() {
final animation = super.createAnimation();
delegate.proxyAnimation.parent = animation;
return animation;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SearchPage(
delegate: delegate,
animation: animation,
);
}
@override
void didComplete(T result) {
super.didComplete(result);
assert(delegate.route == this);
delegate.route = null;
delegate.currentBody = null;
}
}

View file

@ -0,0 +1,127 @@
import 'package:aves/widgets/collection/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class SearchPage extends StatefulWidget {
static const routeName = '/search';
final ImageSearchDelegate delegate;
final Animation<double> animation;
const SearchPage({
this.delegate,
this.animation,
});
@override
_SearchPageState createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
widget.delegate.queryTextController.addListener(_onQueryChanged);
widget.animation.addStatusListener(_onAnimationStatusChanged);
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
focusNode.addListener(_onFocusChanged);
widget.delegate.focusNode = focusNode;
}
@override
void dispose() {
super.dispose();
widget.delegate.queryTextController.removeListener(_onQueryChanged);
widget.animation.removeStatusListener(_onAnimationStatusChanged);
widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate.focusNode = null;
focusNode.dispose();
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status != AnimationStatus.completed) {
return;
}
widget.animation.removeStatusListener(_onAnimationStatusChanged);
focusNode.requestFocus();
}
@override
void didUpdateWidget(SearchPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.delegate != oldWidget.delegate) {
oldWidget.delegate.queryTextController.removeListener(_onQueryChanged);
widget.delegate.queryTextController.addListener(_onQueryChanged);
oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
oldWidget.delegate.focusNode = null;
widget.delegate.focusNode = focusNode;
}
}
void _onFocusChanged() {
if (focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) {
widget.delegate.showSuggestions(context);
}
}
void _onQueryChanged() {
setState(() {
// rebuild ourselves because query changed.
});
}
void _onSearchBodyChanged() {
setState(() {
// rebuild ourselves because search body changed.
});
}
@override
Widget build(BuildContext context) {
final theme = widget.delegate.appBarTheme(context);
Widget body;
switch (widget.delegate.currentBody) {
case SearchBody.suggestions:
body = KeyedSubtree(
key: ValueKey<SearchBody>(SearchBody.suggestions),
child: widget.delegate.buildSuggestions(context),
);
break;
case SearchBody.results:
body = KeyedSubtree(
key: ValueKey<SearchBody>(SearchBody.results),
child: widget.delegate.buildResults(context),
);
break;
}
return Scaffold(
appBar: AppBar(
backgroundColor: theme.primaryColor,
iconTheme: theme.primaryIconTheme,
textTheme: theme.primaryTextTheme,
brightness: theme.primaryColorBrightness,
leading: widget.delegate.buildLeading(context),
title: TextField(
controller: widget.delegate.queryTextController,
focusNode: focusNode,
style: theme.textTheme.headline6,
textInputAction: TextInputAction.search,
onSubmitted: (_) => widget.delegate.showResults(context),
decoration: InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel,
hintStyle: theme.inputDecorationTheme.hintStyle,
),
),
actions: widget.delegate.buildActions(context),
),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
),
);
}
}

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/android_app_service.dart';
@ -12,6 +10,7 @@ import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/debug.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pdf;
@ -154,7 +153,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
}
} else {
// leave viewer
exit(0);
unawaited(SystemNavigator.pop());
}
}

View file

@ -11,6 +11,7 @@ import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/search_button.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -36,7 +37,7 @@ class AlbumListPage extends StatelessWidget {
return FilterNavigationPage(
source: source,
title: 'Albums',
actions: _buildActions(),
actions: _buildActions(context),
filterEntries: getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent(
@ -51,22 +52,21 @@ class AlbumListPage extends StatelessWidget {
);
}
List<Widget> _buildActions() {
List<Widget> _buildActions(BuildContext context) {
return [
Builder(
builder: (context) => PopupMenuButton<ChipAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: Key('menu-sort'),
value: ChipAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
];
},
onSelected: (action) => _onChipActionSelected(context, action),
),
SearchButton(source),
PopupMenuButton<ChipAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: Key('menu-sort'),
value: ChipAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
];
},
onSelected: (action) => _onChipActionSelected(context, action),
),
];
}

View file

@ -19,11 +19,12 @@ class DecoratedFilterChip extends StatelessWidget {
final FilterCallback onPressed;
const DecoratedFilterChip({
Key key,
@required this.source,
@required this.filter,
@required this.entry,
@required this.onPressed,
});
}) : super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -119,6 +119,7 @@ class FilterGridPage extends StatelessWidget {
(context, i) {
final key = filterKeys[i];
final child = DecoratedFilterChip(
key: Key(key),
source: source,
filter: filterBuilder(key),
entry: filterEntries[key],

View file

@ -0,0 +1,29 @@
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/collection/search/search_delegate.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
class SearchButton extends StatelessWidget {
final CollectionSource source;
const SearchButton(this.source);
@override
Widget build(BuildContext context) {
return IconButton(
key: Key('search-button'),
icon: Icon(AIcons.search),
onPressed: () => _goToSearch(context),
);
}
void _goToSearch(BuildContext context) {
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
source: source,
),
));
}
}

View file

@ -325,11 +325,12 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
}
void _onLeave() {
if (!Navigator.canPop(context)) {
if (Navigator.canPop(context)) {
_showSystemUI();
} else {
// exit app when trying to pop a fullscreen page that is a viewer for a single entry
exit(0);
SystemNavigator.pop();
}
_showSystemUI();
}
// system UI

View file

@ -10,6 +10,8 @@ import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/collection/search/search_delegate.dart';
import 'package:aves/widgets/collection/search_page.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/routes.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
@ -32,7 +34,7 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> {
MediaStoreSource _mediaStore;
ImageEntry _viewerEntry;
HomePageSetting _shortcutPage;
String _shortcutRouteName;
List<String> _shortcutFilters;
@override
@ -83,8 +85,14 @@ class _HomePageState extends State<HomePage> {
debugPrint('pick mimeType=$pickMimeTypes');
break;
default:
final extraPage = intentData['page'];
_shortcutPage = HomePageSetting.values.firstWhere((v) => v.toString().split('.')[1] == extraPage, orElse: () => null);
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData['page'];
switch (extraRoute) {
case CollectionPage.routeName:
case AlbumListPage.routeName:
case SearchPage.routeName:
_shortcutRouteName = extraRoute;
}
final extraFilters = intentData['filters'];
_shortcutFilters = extraFilters != null ? (extraFilters as List).cast<String>() : null;
}
@ -117,12 +125,12 @@ class _HomePageState extends State<HomePage> {
);
}
HomePageSetting startPage;
String routeName;
Iterable<CollectionFilter> filters;
if (AvesApp.mode == AppMode.pick) {
startPage = HomePageSetting.collection;
routeName = CollectionPage.routeName;
} else {
startPage = _shortcutPage ?? settings.homePage;
routeName = _shortcutRouteName ?? settings.homePage.routeName;
filters = (_shortcutFilters ?? []).map((filterString) {
switch (filterString) {
case 'anyVideo':
@ -132,14 +140,17 @@ class _HomePageState extends State<HomePage> {
return null;
});
}
switch (startPage) {
case HomePageSetting.albums:
switch (routeName) {
case AlbumListPage.routeName:
return DirectMaterialPageRoute(
settings: RouteSettings(name: AlbumListPage.routeName),
builder: (_) => AlbumListPage(source: _mediaStore),
);
case HomePageSetting.search:
case HomePageSetting.collection:
case SearchPage.routeName:
return SearchPageRoute(
delegate: ImageSearchDelegate(source: _mediaStore),
);
case CollectionPage.routeName:
default:
return DirectMaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),