diff --git a/android/app/build.gradle b/android/app/build.gradle index 4b05d4c59..4ddc01781 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -103,6 +103,7 @@ dependencies { // enable support for Java 8 language APIs (stream, optional, etc.) // coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9' + implementation 'androidx.core:core:1.5.0-alpha02' // v1.5.0-alpha02 for ShortcutManagerCompat.setDynamicShortcuts implementation "androidx.exifinterface:exifinterface:1.2.0" implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.14.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1c539b32..7016ee892 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -45,19 +45,21 @@ + @@ -65,14 +67,17 @@ + + + @@ -80,6 +85,7 @@ + diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index f248ce1cf..2ca377c26 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -2,15 +2,24 @@ import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.util.Log; +import androidx.annotation.RequiresApi; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Objects; import app.loup.streams_channel.StreamsChannel; import deckers.thibault.aves.channel.calls.AppAdapterHandler; +import deckers.thibault.aves.channel.calls.AppShortcutHandler; import deckers.thibault.aves.channel.calls.ImageFileHandler; import deckers.thibault.aves.channel.calls.MetadataHandler; import deckers.thibault.aves.channel.calls.StorageHandler; @@ -29,7 +38,7 @@ public class MainActivity extends FlutterActivity { public static final String VIEWER_CHANNEL = "deckers.thibault/aves/viewer"; - private Map intentDataMap; + private Map intentDataMap; @Override protected void onCreate(Bundle savedInstanceState) { @@ -40,6 +49,7 @@ protected void onCreate(Bundle savedInstanceState) { BinaryMessenger messenger = Objects.requireNonNull(getFlutterEngine()).getDartExecutor().getBinaryMessenger(); new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); + new MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(new AppShortcutHandler(this)); new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(new StorageHandler(this)); @@ -69,6 +79,32 @@ protected void onCreate(Bundle savedInstanceState) { finish(); } }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + setupShortcuts(); + } + } + + @RequiresApi(Build.VERSION_CODES.N_MR1) + private void setupShortcuts() { + // do not use 'route' as extra key, as the Flutter framework acts on it + + ShortcutInfoCompat search = new ShortcutInfoCompat.Builder(this, "search") + .setShortLabel(getString(R.string.search_shortcut_short_label)) + .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search)) + .setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class) + .putExtra("page", "/search")) + .build(); + + ShortcutInfoCompat videos = new ShortcutInfoCompat.Builder(this, "videos") + .setShortLabel(getString(R.string.videos_shortcut_short_label)) + .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie)) + .setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class) + .putExtra("page", "/collection") + .putExtra("filters", new String[]{"{\"type\":\"mime\",\"mime\":\"video/*\"}"})) + .build(); + + ShortcutManagerCompat.setDynamicShortcuts(this, Arrays.asList(videos, search)); } private void handleIntent(Intent intent) { @@ -77,6 +113,15 @@ private void handleIntent(Intent intent) { String action = intent.getAction(); if (action == null) return; switch (action) { + case Intent.ACTION_MAIN: + String page = intent.getStringExtra("page"); + if (page != null) { + intentDataMap = new HashMap<>(); + intentDataMap.put("page", page); + String[] filters = intent.getStringArrayExtra("filters"); + intentDataMap.put("filters", filters != null ? new ArrayList<>(Arrays.asList(filters)) : null); + } + break; case Intent.ACTION_VIEW: Uri uri = intent.getData(); String mimeType = intent.getType(); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java new file mode 100644 index 000000000..b4a4a3457 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java @@ -0,0 +1,64 @@ +package deckers.thibault.aves.channel.calls; + +import android.content.Context; +import android.content.Intent; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import java.util.List; + +import deckers.thibault.aves.MainActivity; +import deckers.thibault.aves.R; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +public class AppShortcutHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/shortcut"; + + private Context context; + + public AppShortcutHandler(Context context) { + this.context = context; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "canPin": { + result.success(ShortcutManagerCompat.isRequestPinShortcutSupported(context)); + break; + } + case "pin": { + String label = call.argument("label"); + List filters = call.argument("filters"); + pin(label, filters); + result.success(null); + break; + } + default: + result.notImplemented(); + break; + } + } + + private void pin(String label, @Nullable List filters) { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context) || filters == null) { + return; + } + + ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(context, "collection-" + TextUtils.join("-", filters)) + .setShortLabel(label) + .setIcon(IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection)) + .setIntent(new Intent(Intent.ACTION_MAIN, null, context, MainActivity.class) + .putExtra("page", "/collection") + .putExtra("filters", filters.toArray(new String[0]))) + .build(); + + ShortcutManagerCompat.requestPinShortcut(context, shortcut, null); + } +} diff --git a/android/app/src/main/res/drawable/ic_shortcut_collection_foreground.xml b/android/app/src/main/res/drawable/ic_shortcut_collection_foreground.xml new file mode 100644 index 000000000..45b1c8cb2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_collection_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_movie_foreground.xml b/android/app/src/main/res/drawable/ic_shortcut_movie_foreground.xml new file mode 100644 index 000000000..0955cd1d5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_movie_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_search_foreground.xml b/android/app/src/main/res/drawable/ic_shortcut_search_foreground.xml new file mode 100644 index 000000000..fb2972b41 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_search_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml new file mode 100644 index 000000000..e069568b3 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml new file mode 100644 index 000000000..a31978c2d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml new file mode 100644 index 000000000..1ab11ea64 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/colors.xml similarity index 67% rename from android/app/src/main/res/values/ic_launcher_background.xml rename to android/app/src/main/res/values/colors.xml index c5d5899fd..45ea13c03 100644 --- a/android/app/src/main/res/values/ic_launcher_background.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,5 @@ #FFFFFF + #FFFFFF \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 53cda64da..c296a8709 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Aves + Search + Videos \ No newline at end of file diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 6ad3a8be7..f729f2329 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -18,6 +18,19 @@ class AlbumFilter extends CollectionFilter { const AlbumFilter(this.album, this.uniqueName); + AlbumFilter.fromJson(Map json) + : this( + json['album'], + json['uniqueName'], + ); + + @override + Map toJson() => { + 'type': type, + 'album': album, + 'uniqueName': uniqueName, + }; + @override bool filter(ImageEntry entry) => entry.directory == album; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 9835fa68f..fbf1194de 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -7,6 +7,11 @@ import 'package:flutter/widgets.dart'; class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; + @override + Map toJson() => { + 'type': type, + }; + @override bool filter(ImageEntry entry) => entry.isFavourite; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 2375c25e8..ab519743c 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/location.dart'; @@ -20,8 +22,31 @@ abstract class CollectionFilter implements Comparable { TagFilter.type, ]; + static CollectionFilter fromJson(String jsonString) { + final jsonMap = jsonDecode(jsonString); + final type = jsonMap['type']; + switch (type) { + case AlbumFilter.type: + return AlbumFilter.fromJson(jsonMap); + case FavouriteFilter.type: + return FavouriteFilter(); + case LocationFilter.type: + return LocationFilter.fromJson(jsonMap); + case MimeFilter.type: + return MimeFilter.fromJson(jsonMap); + case QueryFilter.type: + return QueryFilter.fromJson(jsonMap); + case TagFilter.type: + return TagFilter.fromJson(jsonMap); + } + debugPrint('failed to parse filter from json=$jsonString'); + return null; + } + const CollectionFilter(); + Map toJson(); + bool filter(ImageEntry entry); bool get isUnique => true; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 8e905a9f8..1042430d2 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -5,17 +5,31 @@ import 'package:flutter/widgets.dart'; class LocationFilter extends CollectionFilter { static const type = 'country'; + static const locationSeparator = ';'; final LocationLevel level; String _location; String _countryCode; LocationFilter(this.level, this._location) { - final split = _location.split(';'); + final split = _location.split(locationSeparator); if (split.isNotEmpty) _location = split[0]; if (split.length > 1) _countryCode = split[1]; } + LocationFilter.fromJson(Map json) + : this( + LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null), + json['location'], + ); + + @override + Map toJson() => { + 'type': type, + 'level': level.toString(), + 'location': _countryCode != null ? '$_location$locationSeparator$_countryCode' : _location, + }; + @override bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location)); diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9e9664b8e..54b6beb0a 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -38,6 +38,17 @@ class MimeFilter extends CollectionFilter { _icon ??= AIcons.vector; } + MimeFilter.fromJson(Map json) + : this( + json['mime'], + ); + + @override + Map toJson() => { + 'type': type, + 'mime': mime, + }; + static String displayType(String mime) { return mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', ''); } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 1cd687e9d..9714adc2b 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -32,6 +32,17 @@ class QueryFilter extends CollectionFilter { _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); } + QueryFilter.fromJson(Map json) + : this( + json['query'], + ); + + @override + Map toJson() => { + 'type': type, + 'query': query, + }; + @override bool filter(ImageEntry entry) => _filter(entry); diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 6d66c6a45..83c0d5ef1 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -10,6 +10,17 @@ class TagFilter extends CollectionFilter { const TagFilter(this.tag); + TagFilter.fromJson(Map json) + : this( + json['tag'], + ); + + @override + Map toJson() => { + 'type': type, + 'tag': tag, + }; + @override bool filter(ImageEntry entry) => entry.xmpSubjects.contains(tag); diff --git a/lib/model/settings/home_page.dart b/lib/model/settings/home_page.dart index 9cb131bbf..bc5c29b75 100644 --- a/lib/model/settings/home_page.dart +++ b/lib/model/settings/home_page.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/album/collection_page.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; enum HomePageSetting { collection, albums } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 0d60fa61d..74fe62f5d 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; @@ -76,7 +77,7 @@ mixin LocationMixin on SourceBase { void updateLocations() { final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); List lister(String Function(AddressDetails a) f) => List.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); - sortedCountries = lister((address) => '${address.countryName};${address.countryCode}'); + sortedCountries = lister((address) => '${address.countryName}${LocationFilter.locationSeparator}${address.countryCode}'); sortedPlaces = lister((address) => address.place); invalidateFilterEntryCounts(); @@ -86,7 +87,7 @@ mixin LocationMixin on SourceBase { Map getCountryEntries() { final locatedEntries = sortedEntriesForFilterList.where((entry) => entry.isLocated); return Map.fromEntries(sortedCountries.map((countryNameAndCode) { - final split = countryNameAndCode.split(';'); + final split = countryNameAndCode.split(LocationFilter.locationSeparator); ImageEntry entry; if (split.length > 1) { final countryCode = split[1]; diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart new file mode 100644 index 000000000..9008776f3 --- /dev/null +++ b/lib/services/app_shortcut_service.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class AppShortcutService { + static const platform = MethodChannel('deckers.thibault/aves/shortcut'); + + // this ability will not change over the lifetime of the app + static bool _canPin; + + static Future canPin() async { + if (_canPin != null) { + return SynchronousFuture(_canPin); + } + + try { + _canPin = await platform.invokeMethod('canPin'); + return _canPin; + } on PlatformException catch (e) { + debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return false; + } + + static Future pin(String label, Set filters) async { + try { + await platform.invokeMethod('pin', { + 'label': label, + 'filters': filters.map((filter) => jsonEncode(filter.toJson())).toList(), + }); + } on PlatformException catch (e) { + debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + } +} diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/collection/app_bar.dart similarity index 66% rename from lib/widgets/album/app_bar.dart rename to lib/widgets/collection/app_bar.dart index 9c9aa5d88..f83b8dc36 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -3,17 +3,22 @@ import 'dart:async'; import 'package:aves/main.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/services/app_shortcut_service.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/filter_bar.dart'; -import 'package:aves/widgets/album/search/search_delegate.dart'; +import 'package:aves/widgets/collection/collection_actions.dart'; +import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/search/search_delegate.dart'; import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; +import 'package:aves/widgets/filter_grids/search_button.dart'; import 'package:aves/widgets/stats/stats.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -39,9 +44,12 @@ class _CollectionAppBarState extends State with SingleTickerPr final TextEditingController _searchFieldController = TextEditingController(); SelectionActionDelegate _actionDelegate; AnimationController _browseToSelectAnimation; + Future _canAddShortcutsLoader; CollectionLens get collection => widget.collection; + CollectionSource get source => collection.source; + bool get hasFilters => collection.filters.isNotEmpty; @override @@ -54,6 +62,7 @@ class _CollectionAppBarState extends State with SingleTickerPr duration: Durations.iconAnimation, vsync: this, ); + _canAddShortcutsLoader = AppShortcutService.canPin(); _registerWidget(widget); WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight()); } @@ -91,7 +100,6 @@ class _CollectionAppBarState extends State with SingleTickerPr return AnimatedBuilder( animation: collection.filterChangeNotifier, builder: (context, child) => SliverAppBar( - titleSpacing: 0, leading: _buildAppBarLeading(), title: _buildAppBarTitle(), actions: _buildActions(), @@ -101,6 +109,7 @@ class _CollectionAppBarState extends State with SingleTickerPr onPressed: collection.removeFilter, ) : null, + titleSpacing: 0, floating: true, ), ); @@ -135,26 +144,18 @@ class _CollectionAppBarState extends State with SingleTickerPr Widget _buildAppBarTitle() { if (collection.isBrowsing) { Widget title = Text( - AvesApp.mode == AppMode.pick ? 'Select' : 'Collection', + AvesApp.mode == AppMode.pick ? 'Pick' : 'Collection', key: Key('appbar-title'), ); if (AvesApp.mode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, - source: collection.source, + source: source, ); } - return GestureDetector( + return TappableAppBarTitle( onTap: _goToSearch, - // use a `Container` with a dummy color to make it expand - // so that we can also detect taps around the title `Text` - child: Container( - alignment: AlignmentDirectional.centerStart, - padding: EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), - color: Colors.transparent, - height: kToolbarHeight, - child: title, - ), + child: title, ); } else if (collection.isSelecting) { return AnimatedBuilder( @@ -171,10 +172,9 @@ class _CollectionAppBarState extends State with SingleTickerPr List _buildActions() { return [ if (collection.isBrowsing) - IconButton( - key: Key('search-button'), - icon: Icon(AIcons.search), - onPressed: _goToSearch, + SearchButton( + source, + parentCollection: collection, ), if (collection.isSelecting) ...EntryActions.selection.map((action) => AnimatedBuilder( @@ -187,71 +187,80 @@ class _CollectionAppBarState extends State with SingleTickerPr ); }, )), - Builder( - builder: (context) => PopupMenuButton( - key: Key('appbar-menu-button'), - itemBuilder: (context) { - final hasSelection = collection.selection.isNotEmpty; - return [ - PopupMenuItem( - key: Key('menu-sort'), - value: CollectionAction.sort, - child: MenuRow(text: 'Sort...', icon: AIcons.sort), - ), - if (collection.sortFactor == EntrySortFactor.date) + FutureBuilder( + future: _canAddShortcutsLoader, + builder: (context, snapshot) { + final canAddShortcuts = snapshot.data ?? false; + return PopupMenuButton( + key: Key('appbar-menu-button'), + itemBuilder: (context) { + final hasSelection = collection.selection.isNotEmpty; + return [ PopupMenuItem( - key: Key('menu-group'), - value: CollectionAction.group, - child: MenuRow(text: 'Group...', icon: AIcons.group), + key: Key('menu-sort'), + value: CollectionAction.sort, + child: MenuRow(text: 'Sort...', icon: AIcons.sort), ), - if (collection.isBrowsing) ...[ - if (AvesApp.mode == AppMode.main) - if (kDebugMode) + if (collection.sortFactor == EntrySortFactor.date) + PopupMenuItem( + key: Key('menu-group'), + value: CollectionAction.group, + child: MenuRow(text: 'Group...', icon: AIcons.group), + ), + if (collection.isBrowsing) ...[ + if (AvesApp.mode == AppMode.main) + if (kDebugMode) + PopupMenuItem( + value: CollectionAction.refresh, + child: MenuRow(text: 'Refresh', icon: AIcons.refresh), + ), + PopupMenuItem( + value: CollectionAction.select, + child: MenuRow(text: 'Select', icon: AIcons.select), + ), + PopupMenuItem( + value: CollectionAction.stats, + child: MenuRow(text: 'Stats', icon: AIcons.stats), + ), + if (canAddShortcuts) PopupMenuItem( - value: CollectionAction.refresh, - child: MenuRow(text: 'Refresh', icon: AIcons.refresh), + value: CollectionAction.addShortcut, + child: MenuRow(text: 'Add shortcut', icon: AIcons.addShortcut), ), - PopupMenuItem( - value: CollectionAction.select, - child: MenuRow(text: 'Select', icon: AIcons.select), - ), - PopupMenuItem( - value: CollectionAction.stats, - child: MenuRow(text: 'Stats', icon: AIcons.stats), - ), - ], - if (collection.isSelecting) ...[ - PopupMenuDivider(), - PopupMenuItem( - value: CollectionAction.copy, - enabled: hasSelection, - child: MenuRow(text: 'Copy to album'), - ), - PopupMenuItem( - value: CollectionAction.move, - enabled: hasSelection, - child: MenuRow(text: 'Move to album'), - ), - PopupMenuItem( - value: CollectionAction.refreshMetadata, - enabled: hasSelection, - child: MenuRow(text: 'Refresh metadata'), - ), - PopupMenuDivider(), - PopupMenuItem( - value: CollectionAction.selectAll, - child: MenuRow(text: 'Select all'), - ), - PopupMenuItem( - value: CollectionAction.selectNone, - enabled: hasSelection, - child: MenuRow(text: 'Select none'), - ), - ] - ]; - }, - onSelected: _onCollectionActionSelected, - ), + ], + if (collection.isSelecting) ...[ + PopupMenuDivider(), + PopupMenuItem( + value: CollectionAction.copy, + enabled: hasSelection, + child: MenuRow(text: 'Copy to album'), + ), + PopupMenuItem( + value: CollectionAction.move, + enabled: hasSelection, + child: MenuRow(text: 'Move to album'), + ), + PopupMenuItem( + value: CollectionAction.refreshMetadata, + enabled: hasSelection, + child: MenuRow(text: 'Refresh metadata'), + ), + PopupMenuDivider(), + PopupMenuItem( + value: CollectionAction.selectAll, + child: MenuRow(text: 'Select all'), + ), + PopupMenuItem( + value: CollectionAction.selectNone, + enabled: hasSelection, + child: MenuRow(text: 'Select none'), + ), + ] + ]; + }, + onSelected: _onCollectionActionSelected, + ); + }, ), ]; } @@ -279,10 +288,9 @@ class _CollectionAppBarState extends State with SingleTickerPr _actionDelegate.onCollectionActionSelected(context, action); break; case CollectionAction.refresh: - final source = collection.source; if (source is MediaStoreSource) { source.clearEntries(); - unawaited(source.refresh()); + unawaited((source as MediaStoreSource).refresh()); } break; case CollectionAction.select: @@ -295,7 +303,10 @@ class _CollectionAppBarState extends State with SingleTickerPr collection.clearSelection(); break; case CollectionAction.stats: - unawaited(_goToStats()); + _goToStats(); + break; + case CollectionAction.addShortcut: + unawaited(AppShortcutService.pin('Collection', collection.filters)); break; case CollectionAction.group: final value = await showDialog( @@ -338,14 +349,18 @@ class _CollectionAppBarState extends State 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 _goToStats() { - return Navigator.push( + void _goToStats() { + Navigator.push( context, MaterialPageRoute( settings: RouteSettings(name: StatsPage.routeName), @@ -356,16 +371,3 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } } - -enum CollectionAction { - copy, - group, - move, - refresh, - refreshMetadata, - select, - selectAll, - selectNone, - sort, - stats, -} diff --git a/lib/widgets/collection/collection_actions.dart b/lib/widgets/collection/collection_actions.dart new file mode 100644 index 000000000..c71ba093c --- /dev/null +++ b/lib/widgets/collection/collection_actions.dart @@ -0,0 +1,13 @@ +enum CollectionAction { + addShortcut, + copy, + group, + move, + refresh, + refreshMetadata, + select, + selectAll, + selectNone, + sort, + stats, +} diff --git a/lib/widgets/album/collection_page.dart b/lib/widgets/collection/collection_page.dart similarity index 95% rename from lib/widgets/album/collection_page.dart rename to lib/widgets/collection/collection_page.dart index 8be4a541e..35fdb5981 100644 --- a/lib/widgets/album/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,5 +1,5 @@ import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/album/thumbnail_collection.dart'; +import 'package:aves/widgets/collection/thumbnail_collection.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/double_back_pop.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; diff --git a/lib/widgets/album/empty.dart b/lib/widgets/collection/empty.dart similarity index 100% rename from lib/widgets/album/empty.dart rename to lib/widgets/collection/empty.dart diff --git a/lib/widgets/album/filter_bar.dart b/lib/widgets/collection/filter_bar.dart similarity index 100% rename from lib/widgets/album/filter_bar.dart rename to lib/widgets/collection/filter_bar.dart diff --git a/lib/widgets/album/grid/header_album.dart b/lib/widgets/collection/grid/header_album.dart similarity index 94% rename from lib/widgets/album/grid/header_album.dart rename to lib/widgets/collection/grid/header_album.dart index 249a016d4..5afd82fa3 100644 --- a/lib/widgets/album/grid/header_album.dart +++ b/lib/widgets/collection/grid/header_album.dart @@ -1,5 +1,5 @@ import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/album/grid/header_generic.dart'; +import 'package:aves/widgets/collection/grid/header_generic.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/album/grid/header_date.dart b/lib/widgets/collection/grid/header_date.dart similarity index 96% rename from lib/widgets/album/grid/header_date.dart rename to lib/widgets/collection/grid/header_date.dart index 278b077dc..9e136c796 100644 --- a/lib/widgets/album/grid/header_date.dart +++ b/lib/widgets/collection/grid/header_date.dart @@ -1,5 +1,5 @@ import 'package:aves/utils/time_utils.dart'; -import 'package:aves/widgets/album/grid/header_generic.dart'; +import 'package:aves/widgets/collection/grid/header_generic.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/collection/grid/header_generic.dart similarity index 98% rename from lib/widgets/album/grid/header_generic.dart rename to lib/widgets/collection/grid/header_generic.dart index 1cf74d188..e267e2be4 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/collection/grid/header_generic.dart @@ -6,8 +6,8 @@ import 'package:aves/model/source/enums.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/grid/header_album.dart'; -import 'package:aves/widgets/album/grid/header_date.dart'; +import 'package:aves/widgets/collection/grid/header_album.dart'; +import 'package:aves/widgets/collection/grid/header_date.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; diff --git a/lib/widgets/album/grid/list_known_extent.dart b/lib/widgets/collection/grid/list_known_extent.dart similarity index 99% rename from lib/widgets/album/grid/list_known_extent.dart rename to lib/widgets/collection/grid/list_known_extent.dart index 020f4a61d..6ea65c1f8 100644 --- a/lib/widgets/album/grid/list_known_extent.dart +++ b/lib/widgets/collection/grid/list_known_extent.dart @@ -1,6 +1,6 @@ import 'dart:math' as math; -import 'package:aves/widgets/album/grid/list_section_layout.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart similarity index 97% rename from lib/widgets/album/grid/list_section_layout.dart rename to lib/widgets/collection/grid/list_section_layout.dart index b2475c810..fe88bfd51 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -2,8 +2,8 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/album/grid/header_generic.dart'; -import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; +import 'package:aves/widgets/collection/grid/header_generic.dart'; +import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/album/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart similarity index 94% rename from lib/widgets/album/grid/list_sliver.dart rename to lib/widgets/collection/grid/list_sliver.dart index 9e4006779..186df604d 100644 --- a/lib/widgets/album/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -2,9 +2,9 @@ import 'package:aves/main.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; -import 'package:aves/widgets/album/grid/list_known_extent.dart'; -import 'package:aves/widgets/album/grid/list_section_layout.dart'; -import 'package:aves/widgets/album/thumbnail/decorated.dart'; +import 'package:aves/widgets/collection/grid/list_known_extent.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/routes.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/album/grid/scaling.dart b/lib/widgets/collection/grid/scaling.dart similarity index 97% rename from lib/widgets/album/grid/scaling.dart rename to lib/widgets/collection/grid/scaling.dart index 0a6397fd7..b3887ba50 100644 --- a/lib/widgets/album/grid/scaling.dart +++ b/lib/widgets/collection/grid/scaling.dart @@ -3,10 +3,10 @@ import 'dart:ui' as ui; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/grid/list_section_layout.dart'; -import 'package:aves/widgets/album/grid/list_sliver.dart'; -import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; -import 'package:aves/widgets/album/thumbnail/decorated.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:aves/widgets/collection/grid/list_sliver.dart'; +import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; +import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; diff --git a/lib/widgets/album/grid/tile_extent_manager.dart b/lib/widgets/collection/grid/tile_extent_manager.dart similarity index 100% rename from lib/widgets/album/grid/tile_extent_manager.dart rename to lib/widgets/collection/grid/tile_extent_manager.dart diff --git a/lib/widgets/album/search/expandable_filter_row.dart b/lib/widgets/collection/search/expandable_filter_row.dart similarity index 100% rename from lib/widgets/album/search/expandable_filter_row.dart rename to lib/widgets/collection/search/expandable_filter_row.dart diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/collection/search/search_delegate.dart similarity index 56% rename from lib/widgets/album/search/search_delegate.dart rename to lib/widgets/collection/search/search_delegate.dart index 28cc29f7a..b63699f09 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/collection/search/search_delegate.dart @@ -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/album/search/expandable_filter_row.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 { +class ImageSearchDelegate { final CollectionSource source; final ValueNotifier 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 buildActions(BuildContext context) { return [ if (query.isNotEmpty) @@ -54,7 +60,6 @@ class ImageSearchDelegate extends SearchDelegate { ]; } - @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 { ); } - @override Widget buildResults(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { // `buildResults` is called in the build phase, @@ -154,14 +158,160 @@ class ImageSearchDelegate extends SearchDelegate { } 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 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 currentBodyNotifier = ValueNotifier(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 extends PageRoute { + 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 animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: child, + ); + } + + @override + Animation createAnimation() { + final animation = super.createAnimation(); + delegate.proxyAnimation.parent = animation; + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation 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; + } } diff --git a/lib/widgets/collection/search_page.dart b/lib/widgets/collection/search_page.dart new file mode 100644 index 000000000..c131ef739 --- /dev/null +++ b/lib/widgets/collection/search_page.dart @@ -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 animation; + + const SearchPage({ + this.delegate, + this.animation, + }); + + @override + _SearchPageState createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + 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.suggestions), + child: widget.delegate.buildSuggestions(context), + ); + break; + case SearchBody.results: + body = KeyedSubtree( + key: ValueKey(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, + ), + ); + } +} diff --git a/lib/widgets/album/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart similarity index 90% rename from lib/widgets/album/thumbnail/decorated.dart rename to lib/widgets/collection/thumbnail/decorated.dart index c283be3fe..738ba77ab 100644 --- a/lib/widgets/album/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -1,8 +1,8 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/album/thumbnail/overlay.dart'; -import 'package:aves/widgets/album/thumbnail/raster.dart'; -import 'package:aves/widgets/album/thumbnail/vector.dart'; +import 'package:aves/widgets/collection/thumbnail/overlay.dart'; +import 'package:aves/widgets/collection/thumbnail/raster.dart'; +import 'package:aves/widgets/collection/thumbnail/vector.dart'; import 'package:flutter/material.dart'; class DecoratedThumbnail extends StatelessWidget { diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart similarity index 100% rename from lib/widgets/album/thumbnail/overlay.dart rename to lib/widgets/collection/thumbnail/overlay.dart diff --git a/lib/widgets/album/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart similarity index 100% rename from lib/widgets/album/thumbnail/raster.dart rename to lib/widgets/collection/thumbnail/raster.dart diff --git a/lib/widgets/album/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart similarity index 100% rename from lib/widgets/album/thumbnail/vector.dart rename to lib/widgets/collection/thumbnail/vector.dart diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart similarity index 95% rename from lib/widgets/album/thumbnail_collection.dart rename to lib/widgets/collection/thumbnail_collection.dart index 5b79f008b..8d5328121 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -6,12 +6,12 @@ import 'package:aves/model/mime_types.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/app_bar.dart'; -import 'package:aves/widgets/album/empty.dart'; -import 'package:aves/widgets/album/grid/list_section_layout.dart'; -import 'package:aves/widgets/album/grid/list_sliver.dart'; -import 'package:aves/widgets/album/grid/scaling.dart'; -import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; +import 'package:aves/widgets/collection/app_bar.dart'; +import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:aves/widgets/collection/grid/list_sliver.dart'; +import 'package:aves/widgets/collection/grid/scaling.dart'; +import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/scroll_thumb.dart'; import 'package:aves/widgets/common/sloppy_scroll_physics.dart'; diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index 4aac59c72..d3b167365 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -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()); } } diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index b58772ebe..4f5fd8d9a 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -9,8 +9,8 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/app_bar.dart'; -import 'package:aves/widgets/album/empty.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'; diff --git a/lib/widgets/common/app_bar_title.dart b/lib/widgets/common/app_bar_title.dart new file mode 100644 index 000000000..d00745b8c --- /dev/null +++ b/lib/widgets/common/app_bar_title.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class TappableAppBarTitle extends StatelessWidget { + final GestureTapCallback onTap; + final Widget child; + + const TappableAppBarTitle({ + this.onTap, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), + color: Colors.transparent, + height: kToolbarHeight, + child: child, + ), + ); + } +} diff --git a/lib/widgets/common/aves_dialog.dart b/lib/widgets/common/aves_dialog.dart index 2681b9101..e487b9b78 100644 --- a/lib/widgets/common/aves_dialog.dart +++ b/lib/widgets/common/aves_dialog.dart @@ -24,9 +24,16 @@ class AvesDialog extends AlertDialog { // to size itself to the content intrinsic size, // but the `ListView` viewport does not have one width: 1, - child: ListView( - shrinkWrap: true, - children: scrollableContent, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: 1), + ), + ), + child: ListView( + shrinkWrap: true, + children: scrollableContent, + ), ), ), ) @@ -47,20 +54,21 @@ class DialogTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Text( - title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'Concourse Caps', - ), - ), + return Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: 1), + ), + ), + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Concourse Caps', ), - Divider(height: 1), - ], + ), ); } } diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index 6c7f402c1..783973e58 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -96,7 +96,7 @@ class _AvesFilterChipState extends State { mainAxisSize: MainAxisSize.min, children: [ content, - widget.details, + Flexible(child: widget.details), ], ); } diff --git a/lib/widgets/common/entry_actions.dart b/lib/widgets/common/entry_actions.dart index 2e9e93a67..47500e61b 100644 --- a/lib/widgets/common/entry_actions.dart +++ b/lib/widgets/common/entry_actions.dart @@ -1,7 +1,21 @@ import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; -enum EntryAction { delete, edit, info, open, openMap, print, rename, rotateCCW, rotateCW, setAs, share, toggleFavourite, debug } +enum EntryAction { + delete, + edit, + info, + open, + openMap, + print, + rename, + rotateCCW, + rotateCW, + setAs, + share, + toggleFavourite, + debug, +} class EntryActions { static const selection = [ diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 42d2f9f2c..6f7d2b47c 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -24,6 +24,7 @@ class AIcons { static const IconData tag = OMIcons.localOffer; // actions + static const IconData addShortcut = OMIcons.bookmarkBorder; static const IconData clear = OMIcons.clear; static const IconData collapse = OMIcons.expandLess; static const IconData createAlbum = OMIcons.addCircleOutline; diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index aaa1088ab..2f52c00a5 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -208,6 +208,7 @@ class _AppDrawerState extends State { Widget get settingsTile => NavTile( icon: AIcons.settings, title: 'Settings', + topLevel: false, routeName: SettingsPage.routeName, pageBuilder: (_) => SettingsPage(), ); @@ -215,6 +216,7 @@ class _AppDrawerState extends State { Widget get aboutTile => NavTile( icon: AIcons.info, title: 'About', + topLevel: false, routeName: AboutPage.routeName, pageBuilder: (_) => AboutPage(), ); @@ -222,6 +224,7 @@ class _AppDrawerState extends State { Widget get debugTile => NavTile( icon: AIcons.debug, title: 'Debug', + topLevel: false, routeName: DebugPage.routeName, pageBuilder: (_) => DebugPage(source: source), ); diff --git a/lib/widgets/drawer/collection_tile.dart b/lib/widgets/drawer/collection_tile.dart index 2acada773..9cff6347f 100644 --- a/lib/widgets/drawer/collection_tile.dart +++ b/lib/widgets/drawer/collection_tile.dart @@ -2,7 +2,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/widgets/album/collection_page.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index 6ac50d08b..6b044cd98 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -9,6 +9,7 @@ class NavTile extends StatelessWidget { final IconData icon; final String title; final Widget trailing; + final bool topLevel; final String routeName; final WidgetBuilder pageBuilder; @@ -16,6 +17,7 @@ class NavTile extends StatelessWidget { @required this.icon, @required this.title, this.trailing, + this.topLevel = true, @required this.routeName, @required this.pageBuilder, }); @@ -42,14 +44,19 @@ class NavTile extends StatelessWidget { onTap: () { Navigator.pop(context); if (routeName != context.currentRouteName) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: pageBuilder, - ), - settings.navRemoveRoutePredicate(routeName), + final route = MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: pageBuilder, ); + if (topLevel) { + Navigator.pushAndRemoveUntil( + context, + route, + settings.navRemoveRoutePredicate(routeName), + ); + } else { + Navigator.push(context, route); + } } }, selected: context.currentRouteName == routeName, diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index c2b3d34b8..495b3cdf9 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -6,7 +6,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/empty.dart'; +import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; @@ -36,7 +36,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 +51,20 @@ class AlbumListPage extends StatelessWidget { ); } - List _buildActions() { + List _buildActions(BuildContext context) { return [ - Builder( - builder: (context) => PopupMenuButton( - 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), - ), + PopupMenuButton( + 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), ), ]; } diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index c9098f568..d580f51d4 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; -import 'package:aves/widgets/album/empty.dart'; +import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/filter_grids/decorated_filter_chip.dart b/lib/widgets/filter_grids/decorated_filter_chip.dart index 560889721..210311562 100644 --- a/lib/widgets/filter_grids/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/decorated_filter_chip.dart @@ -5,8 +5,8 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/album/thumbnail/raster.dart'; -import 'package:aves/widgets/album/thumbnail/vector.dart'; +import 'package:aves/widgets/collection/thumbnail/raster.dart'; +import 'package:aves/widgets/collection/thumbnail/vector.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; @@ -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) { diff --git a/lib/widgets/filter_grids/filter_grid_page.dart b/lib/widgets/filter_grids/filter_grid_page.dart index 4c198d059..ced774c4b 100644 --- a/lib/widgets/filter_grids/filter_grid_page.dart +++ b/lib/widgets/filter_grids/filter_grid_page.dart @@ -6,13 +6,16 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/collection_page.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/search/search_delegate.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/double_back_pop.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/search_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -39,11 +42,18 @@ class FilterNavigationPage extends StatelessWidget { return FilterGridPage( source: source, appBar: SliverAppBar( - title: SourceStateAwareAppBarTitle( - title: Text(title), - source: source, + title: TappableAppBarTitle( + onTap: () => _goToSearch(context), + child: SourceStateAwareAppBarTitle( + title: Text(title), + source: source, + ), ), - actions: actions, + actions: [ + SearchButton(source), + ...(actions ?? []), + ], + titleSpacing: 0, floating: true, ), filterEntries: filterEntries, @@ -69,6 +79,16 @@ class FilterNavigationPage extends StatelessWidget { ), ); } + + void _goToSearch(BuildContext context) { + Navigator.push( + context, + SearchPageRoute( + delegate: ImageSearchDelegate( + source: source, + ), + )); + } } class FilterGridPage extends StatelessWidget { @@ -119,6 +139,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], diff --git a/lib/widgets/filter_grids/search_button.dart b/lib/widgets/filter_grids/search_button.dart new file mode 100644 index 000000000..82628913f --- /dev/null +++ b/lib/widgets/filter_grids/search_button.dart @@ -0,0 +1,32 @@ +import 'package:aves/model/source/collection_lens.dart'; +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; + final CollectionLens parentCollection; + + const SearchButton(this.source, {this.parentCollection}); + + @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, + parentCollection: parentCollection, + ), + )); + } +} diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 4b4693f89..be5bfd8f8 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/tag.dart'; -import 'package:aves/widgets/album/empty.dart'; +import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 81e29e9d0..8e3515585 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -7,7 +7,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/album/collection_page.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; @@ -325,11 +325,12 @@ class FullscreenBodyState extends State 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 diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 49f7ecefe..82235c827 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -86,11 +86,14 @@ class SingleImagePageState extends State with AutomaticKeepAliv Widget build(BuildContext context) { super.build(context); - return ImageView( - entry: widget.entry, - onScaleChanged: widget.onScaleChanged, - onTap: widget.onTap, - videoControllers: widget.videoControllers, + return PhotoViewGestureDetectorScope( + axis: [Axis.vertical], + child: ImageView( + entry: widget.entry, + onScaleChanged: widget.onScaleChanged, + onTap: widget.onTap, + videoControllers: widget.videoControllers, + ), ); } diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 15b94d533..55e377e02 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,6 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/album/empty.dart'; +import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 70ce9abf6..665379805 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -80,7 +80,7 @@ class _LocationSectionState extends State { final address = entry.addressDetails; location = address.addressLine; final country = address.countryName; - if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country;${address.countryCode}')); + if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); final place = address.place; if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index a2181ee51..753e3ab94 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -1,4 +1,5 @@ import 'package:aves/main.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/settings.dart'; @@ -6,7 +7,9 @@ import 'package:aves/model/source/collection_lens.dart'; 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/album/collection_page.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'; @@ -29,6 +32,10 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { MediaStoreSource _mediaStore; ImageEntry _viewerEntry; + String _shortcutRouteName; + List _shortcutFilters; + + static const allowedShortcutRoutes = [CollectionPage.routeName, AlbumListPage.routeName, SearchPage.routeName]; @override void initState() { @@ -77,6 +84,14 @@ class _HomePageState extends State { String pickMimeTypes = intentData['mimeType']; debugPrint('pick mimeType=$pickMimeTypes'); break; + default: + // do not use 'route' as extra key, as the Flutter framework acts on it + final extraRoute = intentData['page']; + if (allowedShortcutRoutes.contains(extraRoute)) { + _shortcutRouteName = extraRoute; + } + final extraFilters = intentData['filters']; + _shortcutFilters = extraFilters != null ? (extraFilters as List).cast() : null; } } @@ -100,35 +115,44 @@ class _HomePageState extends State { } Route _getRedirectRoute() { - switch (AvesApp.mode) { - case AppMode.view: + if (AvesApp.mode == AppMode.view) { + return DirectMaterialPageRoute( + settings: RouteSettings(name: SingleFullscreenPage.routeName), + builder: (_) => SingleFullscreenPage(entry: _viewerEntry), + ); + } + + String routeName; + Iterable filters; + if (AvesApp.mode == AppMode.pick) { + routeName = CollectionPage.routeName; + } else { + routeName = _shortcutRouteName ?? settings.homePage.routeName; + filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson); + } + switch (routeName) { + case AlbumListPage.routeName: return DirectMaterialPageRoute( - settings: RouteSettings(name: SingleFullscreenPage.routeName), - builder: (_) => SingleFullscreenPage(entry: _viewerEntry), + settings: RouteSettings(name: AlbumListPage.routeName), + builder: (_) => AlbumListPage(source: _mediaStore), + ); + case SearchPage.routeName: + return SearchPageRoute( + delegate: ImageSearchDelegate(source: _mediaStore), + ); + case CollectionPage.routeName: + default: + return DirectMaterialPageRoute( + settings: RouteSettings(name: CollectionPage.routeName), + builder: (_) => CollectionPage( + CollectionLens( + source: _mediaStore, + filters: filters, + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + ), + ), ); - case AppMode.main: - case AppMode.pick: - if (_mediaStore != null) { - switch (settings.homePage) { - case HomePageSetting.albums: - return DirectMaterialPageRoute( - settings: RouteSettings(name: AlbumListPage.routeName), - builder: (_) => AlbumListPage(source: _mediaStore), - ); - case HomePageSetting.collection: - return DirectMaterialPageRoute( - settings: RouteSettings(name: CollectionPage.routeName), - builder: (_) => CollectionPage( - CollectionLens( - source: _mediaStore, - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - ), - ), - ); - } - } } - return null; } } diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index ec3fb536c..ed946bae8 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -2,7 +2,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/color_utils.dart'; -import 'package:aves/widgets/album/collection_page.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 2370e7261..3ac7bcf85 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -9,8 +9,8 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/album/collection_page.dart'; -import 'package:aves/widgets/album/empty.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/stats/filter_table.dart'; @@ -36,7 +36,7 @@ class StatsPage extends StatelessWidget { final address = entry.addressDetails; var country = address.countryName; if (country != null && country.isNotEmpty) { - country += ';${address.countryCode}'; + country += '${LocationFilter.locationSeparator}${address.countryCode}'; entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1; } final place = address.place; diff --git a/pubspec.yaml b/pubspec.yaml index f5c521fa7..9fbb97e27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.1.9+21 +version: 1.1.10+22 # video_player (as of v0.10.8+2, backed by ExoPlayer): # - does not support content URIs (by default, but trivial by fork) diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart new file mode 100644 index 000000000..7ebf5fe35 --- /dev/null +++ b/test/model/filters_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/favourite.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/location.dart'; +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:test/test.dart'; + +void main() { + test('Filter serialization', () { + CollectionFilter jsonRoundTrip(filter) => CollectionFilter.fromJson(jsonEncode(filter.toJson())); + + final album = AlbumFilter('path/to/album', 'album'); + expect(album, jsonRoundTrip(album)); + + final fav = FavouriteFilter(); + expect(fav, jsonRoundTrip(fav)); + + final location = LocationFilter(LocationLevel.country, 'France${LocationFilter.locationSeparator}FR'); + expect(location, jsonRoundTrip(location)); + + final mime = MimeFilter(MimeTypes.anyVideo); + expect(mime, jsonRoundTrip(mime)); + + final query = QueryFilter('some query'); + expect(query, jsonRoundTrip(query)); + + final tag = TagFilter('some tag'); + expect(tag, jsonRoundTrip(tag)); + }); +}