Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-09-09 21:57:13 +09:00
commit 60a778f50e
68 changed files with 1030 additions and 257 deletions

View file

@ -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'

View file

@ -45,19 +45,21 @@
<application
android:name="io.flutter.app.FlutterApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@ -65,14 +67,17 @@
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.OPENABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
@ -80,6 +85,7 @@
<intent-filter>
<action android:name="android.intent.action.PICK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />

View file

@ -2,15 +2,24 @@ package deckers.thibault.aves;
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<String, String> intentDataMap;
private Map<String, Object> intentDataMap;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -40,6 +49,7 @@ public class MainActivity extends FlutterActivity {
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 @@ public class MainActivity extends FlutterActivity {
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 @@ public class MainActivity extends FlutterActivity {
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();

View file

@ -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<String> filters = call.argument("filters");
pin(label, filters);
result.success(null);
break;
}
default:
result.notImplemented();
break;
}
}
private void pin(String label, @Nullable List<String> 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);
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z"/>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_collection_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_movie_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_search_foreground"/>
</adaptive-icon>

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_shortcut_background">#FFFFFF</color>
</resources>

View file

@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string>
</resources>

View file

@ -18,6 +18,19 @@ class AlbumFilter extends CollectionFilter {
const AlbumFilter(this.album, this.uniqueName);
AlbumFilter.fromJson(Map<String, dynamic> json)
: this(
json['album'],
json['uniqueName'],
);
@override
Map<String, dynamic> toJson() => {
'type': type,
'album': album,
'uniqueName': uniqueName,
};
@override
bool filter(ImageEntry entry) => entry.directory == album;

View file

@ -7,6 +7,11 @@ import 'package:flutter/widgets.dart';
class FavouriteFilter extends CollectionFilter {
static const type = 'favourite';
@override
Map<String, dynamic> toJson() => {
'type': type,
};
@override
bool filter(ImageEntry entry) => entry.isFavourite;

View file

@ -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<CollectionFilter> {
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<String, dynamic> toJson();
bool filter(ImageEntry entry);
bool get isUnique => true;

View file

@ -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<String, dynamic> json)
: this(
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null),
json['location'],
);
@override
Map<String, dynamic> 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));

View file

@ -38,6 +38,17 @@ class MimeFilter extends CollectionFilter {
_icon ??= AIcons.vector;
}
MimeFilter.fromJson(Map<String, dynamic> json)
: this(
json['mime'],
);
@override
Map<String, dynamic> toJson() => {
'type': type,
'mime': mime,
};
static String displayType(String mime) {
return mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
}

View file

@ -32,6 +32,17 @@ class QueryFilter extends CollectionFilter {
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
}
QueryFilter.fromJson(Map<String, dynamic> json)
: this(
json['query'],
);
@override
Map<String, dynamic> toJson() => {
'type': type,
'query': query,
};
@override
bool filter(ImageEntry entry) => _filter(entry);

View file

@ -10,6 +10,17 @@ class TagFilter extends CollectionFilter {
const TagFilter(this.tag);
TagFilter.fromJson(Map<String, dynamic> json)
: this(
json['tag'],
);
@override
Map<String, dynamic> toJson() => {
'type': type,
'tag': tag,
};
@override
bool filter(ImageEntry entry) => entry.xmpSubjects.contains(tag);

View file

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

View file

@ -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<String> lister(String Function(AddressDetails a) f) => List<String>.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<String, ImageEntry> 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];

View file

@ -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<bool> 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<void> pin(String label, Set<CollectionFilter> filters) async {
try {
await platform.invokeMethod('pin', <String, dynamic>{
'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}');
}
}
}

View file

@ -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<CollectionAppBar> with SingleTickerPr
final TextEditingController _searchFieldController = TextEditingController();
SelectionActionDelegate _actionDelegate;
AnimationController _browseToSelectAnimation;
Future<bool> _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<CollectionAppBar> with SingleTickerPr
duration: Durations.iconAnimation,
vsync: this,
);
_canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight());
}
@ -91,7 +100,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
onPressed: collection.removeFilter,
)
: null,
titleSpacing: 0,
floating: true,
),
);
@ -135,26 +144,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
List<Widget> _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<CollectionAppBar> with SingleTickerPr
);
},
)),
Builder(
builder: (context) => PopupMenuButton<CollectionAction>(
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<bool>(
future: _canAddShortcutsLoader,
builder: (context, snapshot) {
final canAddShortcuts = snapshot.data ?? false;
return PopupMenuButton<CollectionAction>(
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<CollectionAppBar> 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<CollectionAppBar> 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<EntryGroupFactor>(
@ -338,14 +349,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),
@ -356,16 +371,3 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
}
enum CollectionAction {
copy,
group,
move,
refresh,
refreshMetadata,
select,
selectAll,
selectNone,
sort,
stats,
}

View file

@ -0,0 +1,13 @@
enum CollectionAction {
addShortcut,
copy,
group,
move,
refresh,
refreshMetadata,
select,
selectAll,
selectNone,
sort,
stats,
}

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

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/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<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,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 {

View file

@ -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';

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

@ -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';

View file

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

View file

@ -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),
),
Divider(height: 1),
],
),
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'Concourse Caps',
),
),
);
}
}

View file

@ -96,7 +96,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
mainAxisSize: MainAxisSize.min,
children: [
content,
widget.details,
Flexible(child: widget.details),
],
);
}

View file

@ -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 = [

View file

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

View file

@ -208,6 +208,7 @@ class _AppDrawerState extends State<AppDrawer> {
Widget get settingsTile => NavTile(
icon: AIcons.settings,
title: 'Settings',
topLevel: false,
routeName: SettingsPage.routeName,
pageBuilder: (_) => SettingsPage(),
);
@ -215,6 +216,7 @@ class _AppDrawerState extends State<AppDrawer> {
Widget get aboutTile => NavTile(
icon: AIcons.info,
title: 'About',
topLevel: false,
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
);
@ -222,6 +224,7 @@ class _AppDrawerState extends State<AppDrawer> {
Widget get debugTile => NavTile(
icon: AIcons.debug,
title: 'Debug',
topLevel: false,
routeName: DebugPage.routeName,
pageBuilder: (_) => DebugPage(source: source),
);

View file

@ -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';

View file

@ -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,

View file

@ -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<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),
),
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

@ -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';

View file

@ -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) {

View file

@ -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],

View file

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

View file

@ -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';

View file

@ -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<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

@ -86,11 +86,14 @@ class SingleImagePageState extends State<SingleImagePage> 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,
),
);
}

View file

@ -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';

View file

@ -80,7 +80,7 @@ class _LocationSectionState extends State<LocationSection> {
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));
}

View file

@ -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<HomePage> {
MediaStoreSource _mediaStore;
ImageEntry _viewerEntry;
String _shortcutRouteName;
List<String> _shortcutFilters;
static const allowedShortcutRoutes = [CollectionPage.routeName, AlbumListPage.routeName, SearchPage.routeName];
@override
void initState() {
@ -77,6 +84,14 @@ class _HomePageState extends State<HomePage> {
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<String>() : null;
}
}
@ -100,35 +115,44 @@ class _HomePageState extends State<HomePage> {
}
Route _getRedirectRoute() {
switch (AvesApp.mode) {
case AppMode.view:
return DirectMaterialPageRoute(
settings: RouteSettings(name: SingleFullscreenPage.routeName),
builder: (_) => SingleFullscreenPage(entry: _viewerEntry),
);
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,
),
),
);
}
}
if (AvesApp.mode == AppMode.view) {
return DirectMaterialPageRoute(
settings: RouteSettings(name: SingleFullscreenPage.routeName),
builder: (_) => SingleFullscreenPage(entry: _viewerEntry),
);
}
String routeName;
Iterable<CollectionFilter> 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: 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,
),
),
);
}
return null;
}
}

View file

@ -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';

View file

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

View file

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

View file

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