Merge branch 'sliver-list'

This commit is contained in:
Thibault Deckers 2020-04-20 08:44:13 +09:00
commit 2ef43c5b24
66 changed files with 1980 additions and 893 deletions

View file

@ -9,6 +9,7 @@ import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
import deckers.thibault.aves.channelhandlers.FileAdapterHandler;
import deckers.thibault.aves.channelhandlers.ImageFileHandler;
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
import deckers.thibault.aves.channelhandlers.MetadataHandler;
@ -39,6 +40,7 @@ public class MainActivity extends FlutterActivity {
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
FlutterView messenger = getFlutterView();
new MethodChannel(messenger, FileAdapterHandler.CHANNEL).setMethodCallHandler(new FileAdapterHandler(this));
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));

View file

@ -0,0 +1,62 @@
package deckers.thibault.aves.channelhandlers;
import android.app.Activity;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import androidx.annotation.NonNull;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.utils.Env;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class FileAdapterHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/file";
private Activity activity;
public FileAdapterHandler(Activity activity) {
this.activity = activity;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getStorageVolumes": {
List<Map<String, Object>> volumes = new ArrayList<>();
StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) {
for (String path : Env.getStorageVolumes(activity)) {
try {
File file = new File(path);
StorageVolume volume = sm.getStorageVolume(file);
if (volume != null) {
Map<String, Object> volumeMap = new HashMap<>();
volumeMap.put("path", path);
volumeMap.put("description", volume.getDescription(activity));
volumeMap.put("isPrimary", volume.isPrimary());
volumeMap.put("isRemovable", volume.isRemovable());
volumeMap.put("isEmulated", volume.isEmulated());
volumeMap.put("state", volume.getState());
volumes.add(volumeMap);
}
} catch (IllegalArgumentException e) {
// ignore
}
}
}
result.success(volumes);
break;
}
default:
result.notImplemented();
break;
}
}
}

View file

@ -23,7 +23,6 @@ import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.function.Consumer;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.ImageEntry;
@ -37,14 +36,12 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ImageEntry entry;
int width, height;
MethodChannel.Result result;
Consumer<String> complete;
Params(ImageEntry entry, int width, int height, MethodChannel.Result result, Consumer<String> complete) {
Params(ImageEntry entry, int width, int height, MethodChannel.Result result) {
this.entry = entry;
this.width = width;
this.height = height;
this.result = result;
this.complete = complete;
}
}
@ -127,10 +124,13 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} else {
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null && entry.orientationDegrees != 0) {
Matrix matrix = new Matrix();
matrix.postRotate(entry.orientationDegrees);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
Integer orientationDegrees = entry.orientationDegrees;
if (orientationDegrees != null && orientationDegrees != 0) {
Matrix matrix = new Matrix();
matrix.postRotate(orientationDegrees);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
}
}
return bitmap;
}
@ -176,7 +176,6 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
protected void onPostExecute(Result result) {
MethodChannel.Result r = result.params.result;
String uri = result.params.entry.uri.toString();
result.params.complete.accept(uri);
if (result.data != null) {
r.success(result.data);
} else {

View file

@ -1,45 +0,0 @@
package deckers.thibault.aves.channelhandlers;
import android.app.Activity;
import android.util.Log;
import java.util.concurrent.LinkedBlockingDeque;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTaskManager {
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTaskManager.class);
private LinkedBlockingDeque<ImageDecodeTask.Params> taskParamsQueue;
private boolean running = true;
ImageDecodeTaskManager(Activity activity) {
taskParamsQueue = new LinkedBlockingDeque<>();
new Thread(() -> {
try {
while (running) {
ImageDecodeTask.Params params = taskParamsQueue.take();
new ImageDecodeTask(activity).execute(params);
Thread.sleep(10);
}
} catch (InterruptedException ex) {
Log.w(LOG_TAG, ex);
}
}).start();
}
void fetch(MethodChannel.Result result, ImageEntry entry, Integer width, Integer height) {
taskParamsQueue.addFirst(new ImageDecodeTask.Params(entry, width, height, result, this::complete));
}
void cancel(String uri) {
boolean removed = taskParamsQueue.removeIf(p -> uri.equals(p.entry.uri.toString()));
if (removed) Log.d(LOG_TAG, "cancelled uri=" + uri);
}
private void complete(String uri) {
// nothing for now
}
}

View file

@ -34,12 +34,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/image";
private Activity activity;
private ImageDecodeTaskManager imageDecodeTaskManager;
private MediaStoreStreamHandler mediaStoreStreamHandler;
public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
this.activity = activity;
imageDecodeTaskManager = new ImageDecodeTaskManager(activity);
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
}
@ -59,9 +57,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "getThumbnail":
new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start();
break;
case "cancelGetThumbnail":
new Thread(() -> cancelGetThumbnail(call, new MethodResultWrapper(result))).start();
break;
case "delete":
new Thread(() -> delete(call, new MethodResultWrapper(result))).start();
break;
@ -145,13 +140,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
return;
}
ImageEntry entry = new ImageEntry(entryMap);
imageDecodeTaskManager.fetch(result, entry, width, height);
}
private void cancelGetThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uri = call.argument("uri");
imageDecodeTaskManager.cancel(uri);
result.success(null);
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, result));
}
private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
@ -275,7 +264,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int len = 0;
int len;
while ((len = inputStream.read(buffer)) != -1) {
byteBuffer.write(buffer, 0, len);
}

View file

@ -19,8 +19,11 @@ import com.drew.lang.GeoLocation;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory;
import com.drew.metadata.xmp.XmpDirectory;
import java.io.FileInputStream;
@ -41,13 +44,30 @@ import io.flutter.plugin.common.MethodChannel;
public class MetadataHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/metadata";
// catalog metadata
private static final String KEY_DATE_MILLIS = "dateMillis";
private static final String KEY_IS_ANIMATED = "isAnimated";
private static final String KEY_LATITUDE = "latitude";
private static final String KEY_LONGITUDE = "longitude";
private static final String KEY_VIDEO_ROTATION = "videoRotation";
private static final String KEY_XMP_SUBJECTS = "xmpSubjects";
private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription";
// overlay metadata
private static final String KEY_APERTURE = "aperture";
private static final String KEY_EXPOSURE_TIME = "exposureTime";
private static final String KEY_FOCAL_LENGTH = "focalLength";
private static final String KEY_ISO = "iso";
// XMP
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
private static final String XMP_SUBJECT_PROP_NAME = "dc:subject";
private static final String XMP_TITLE_PROP_NAME = "dc:title";
private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description";
private static final String XMP_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US";
private static final Pattern videoLocationPattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?");
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?");
private Context context;
@ -179,12 +199,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
if (!MimeTypes.MP2T.equals(mimeType)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// EXIF Sub-IFD
ExifSubIFDDirectory exifSubDir = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (exifSubDir != null) {
if (exifSubDir.containsTag(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)) {
metadataMap.put("dateMillis", exifSubDir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, null, TimeZone.getDefault()).getTime());
}
// EXIF
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME);
}
// GPS
@ -192,8 +210,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
if (gpsDir != null) {
GeoLocation geoLocation = gpsDir.getGeoLocation();
if (geoLocation != null) {
metadataMap.put("latitude", geoLocation.getLatitude());
metadataMap.put("longitude", geoLocation.getLongitude());
metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude());
metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude());
}
}
@ -209,30 +227,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i);
sb.append(";").append(item.getValue());
}
metadataMap.put("xmpSubjects", sb.toString());
metadataMap.put(KEY_XMP_SUBJECTS, sb.toString());
}
// double check retrieved items as the property sometimes is reported to exist but it is actually null
String titleDescription = null;
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);
if (item != null) {
titleDescription = item.getValue();
}
}
if (titleDescription == null && xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);
if (item != null) {
titleDescription = item.getValue();
}
}
if (titleDescription != null) {
metadataMap.put("xmpTitleDescription", titleDescription);
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME);
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME);
}
} catch (XMPException e) {
e.printStackTrace();
}
}
// Animated GIF & WEBP
if (MimeTypes.GIF.equals(mimeType)) {
metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class));
} else if (MimeTypes.WEBP.equals(mimeType)) {
WebpDirectory webpDir = metadata.getFirstDirectoryOfType(WebpDirectory.class);
if (webpDir != null) {
if (webpDir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, webpDir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
}
}
}
}
if (isVideo(mimeType)) {
@ -251,14 +268,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap.put("dateMillis", dateMillis);
metadataMap.put(KEY_DATE_MILLIS, dateMillis);
}
}
if (rotationString != null) {
metadataMap.put("videoRotation", Integer.parseInt(rotationString));
metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString));
}
if (locationString != null) {
Matcher locationMatcher = videoLocationPattern.matcher(locationString);
Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString);
if (locationMatcher.find() && locationMatcher.groupCount() == 2) {
String latitudeString = locationMatcher.group(1);
String longitudeString = locationMatcher.group(2);
@ -267,8 +284,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
double latitude = Double.parseDouble(latitudeString);
double longitude = Double.parseDouble(longitudeString);
if (latitude != 0 && longitude != 0) {
metadataMap.put("latitude", latitude);
metadataMap.put("longitude", longitude);
metadataMap.put(KEY_LATITUDE, latitude);
metadataMap.put(KEY_LONGITUDE, longitude);
}
} catch (NumberFormatException ex) {
// ignore
@ -297,7 +314,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
String path = call.argument("path");
String uri = call.argument("uri");
Map<String, String> metadataMap = new HashMap<>();
Map<String, Object> metadataMap = new HashMap<>();
if (isVideo(mimeType)) {
result.success(metadataMap);
@ -308,17 +325,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Metadata metadata = ImageMetadataReader.readMetadata(is);
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (directory != null) {
if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) {
metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER));
}
if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) {
metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME));
}
if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) {
metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH));
}
putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER);
putStringFromTag(metadataMap, KEY_EXPOSURE_TIME, directory, ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH);
if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) {
metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
}
}
result.success(metadataMap);
@ -330,4 +341,41 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=" + uri + ", path=" + path, e.getMessage());
}
}
// convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
Directory dir = metadata.getFirstDirectoryOfType(dirClass);
if (dir != null) {
putDateFromTag(metadataMap, key, dir, tag);
}
}
private static void putDateFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime());
}
}
private static void putDescriptionFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDescription(tag));
}
}
private static void putStringFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getString(tag));
}
}
private static void putLocalizedTextFromXmp(Map<String, Object> metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
metadataMap.put(key, item.getValue());
}
}
}
}

View file

@ -8,6 +8,7 @@ public class MimeTypes {
public static final String JPEG = "image/jpeg";
public static final String PNG = "image/png";
public static final String SVG = "image/svg+xml";
public static final String WEBP = "image/webp";
public static final String VIDEO = "video";
public static final String AVI = "video/avi";

View file

@ -1,8 +1,8 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/settings.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/utils/viewer_service.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';

View file

@ -162,10 +162,9 @@ class CollectionLens with ChangeNotifier {
break;
case SortFactor.name:
final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory);
final albums = byAlbum.keys.toSet();
final compare = (a, b) {
final ua = CollectionSource.getUniqueAlbumName(a, albums);
final ub = CollectionSource.getUniqueAlbumName(b, albums);
final ua = source.getUniqueAlbumName(a);
final ub = source.getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
};
sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare));

View file

@ -8,11 +8,12 @@ import 'package:path/path.dart';
class CollectionSource {
final List<ImageEntry> _rawEntries;
final Set<String> _folderPaths = {};
final EventBus _eventBus = EventBus();
List<String> sortedAlbums = List.unmodifiable(const Iterable.empty());
List<String> sortedCities = List.unmodifiable(const Iterable.empty());
List<String> sortedCountries = List.unmodifiable(const Iterable.empty());
List<String> sortedPlaces = List.unmodifiable(const Iterable.empty());
List<String> sortedTags = List.unmodifiable(const Iterable.empty());
List<ImageEntry> get entries => List.unmodifiable(_rawEntries);
@ -106,11 +107,10 @@ class CollectionSource {
}
void updateAlbums() {
final albums = _rawEntries.map((entry) => entry.directory).toSet();
final sorted = albums.toList()
final sorted = _folderPaths.toList()
..sort((a, b) {
final ua = getUniqueAlbumName(a, albums);
final ub = getUniqueAlbumName(b, albums);
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
});
sortedAlbums = List.unmodifiable(sorted);
@ -124,8 +124,8 @@ class CollectionSource {
void updateLocations() {
final locations = _rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails);
final 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);
sortedCities = lister((address) => address.city);
sortedCountries = lister((address) => '${address.countryName};${address.countryCode}');
sortedPlaces = lister((address) => address.place);
}
void addAll(Iterable<ImageEntry> entries) {
@ -134,6 +134,7 @@ class CollectionSource {
entry.catalogDateMillis = savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
});
_rawEntries.addAll(entries);
_folderPaths.addAll(_rawEntries.map((entry) => entry.directory).toSet());
eventBus.fire(const EntryAddedEvent());
}
@ -146,8 +147,8 @@ class CollectionSource {
return success;
}
static String getUniqueAlbumName(String album, Iterable<String> albums) {
final otherAlbums = albums?.where((item) => item != album) ?? [];
String getUniqueAlbumName(String album) {
final otherAlbums = _folderPaths.where((item) => item != album);
final parts = album.split(separator);
var partCount = 0;
String testName;

View file

@ -1,8 +1,8 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class FavouriteFilter extends CollectionFilter {
static const type = 'favourite';
@ -14,7 +14,7 @@ class FavouriteFilter extends CollectionFilter {
String get label => 'Favourite';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.favoriteBorder, size: size);
Widget iconBuilder(context, size) => Icon(AIcons.favourite, size: size);
@override
String get typeKey => type;

View file

@ -1,24 +1,33 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class LocationFilter extends CollectionFilter {
static const type = 'country';
final LocationLevel level;
final String location;
String _location;
String _countryCode;
const LocationFilter(this.level, this.location);
LocationFilter(this.level, this._location) {
final split = _location.split(';');
if (split.isNotEmpty) _location = split[0];
if (split.length > 1) _countryCode = split[1];
}
@override
bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == location) || (level == LocationLevel.city && entry.addressDetails.city == location));
bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location));
@override
String get label => location;
String get label => _location;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.place, size: size);
Widget iconBuilder(context, size) {
final flag = countryCodeToFlag(_countryCode);
if (flag != null) return Text(flag, style: TextStyle(fontSize: size));
return Icon(AIcons.location, size: size);
}
@override
String get typeKey => type;
@ -26,11 +35,19 @@ class LocationFilter extends CollectionFilter {
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is LocationFilter && other.location == location;
return other is LocationFilter && other._location == _location;
}
@override
int get hashCode => hashValues('LocationFilter', location);
int get hashCode => hashValues('LocationFilter', _location);
// U+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;
static String countryCodeToFlag(String code) {
return code?.length == 2 ? String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff)) : null;
}
}
enum LocationLevel { city, country }
enum LocationLevel { place, country }

View file

@ -1,12 +1,16 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class MimeFilter extends CollectionFilter {
static const type = 'mime';
// fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
final String mime;
bool Function(ImageEntry) _filter;
String _label;
@ -14,19 +18,21 @@ class MimeFilter extends CollectionFilter {
MimeFilter(this.mime) {
var lowMime = mime.toLowerCase();
if (lowMime.endsWith('/*')) {
if (mime == animated) {
_filter = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_filter = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') {
_label = 'Video';
_icon = OMIcons.movie;
_icon = AIcons.video;
}
_label ??= lowMime.split('/')[0].toUpperCase();
} else {
_filter = (entry) => entry.mimeType == lowMime;
if (lowMime == MimeTypes.GIF) {
_icon = OMIcons.gif;
} else if (lowMime == MimeTypes.SVG) {
if (lowMime == MimeTypes.SVG) {
_label = 'SVG';
}
_label ??= lowMime.split('/')[1].toUpperCase();

View file

@ -6,13 +6,26 @@ import 'package:outline_material_icons/outline_material_icons.dart';
class QueryFilter extends CollectionFilter {
static const type = 'query';
static final exactRegex = RegExp('^"(.*)"\$');
final String query;
bool Function(ImageEntry) _filter;
QueryFilter(this.query) {
var upQuery = query.toUpperCase();
// allow NOT queries starting with `-`
final not = upQuery.startsWith('-');
if (not) upQuery = upQuery.substring(1);
if (not) {
upQuery = upQuery.substring(1);
}
// allow untrimmed queries wrapped with `"..."`
final matches = exactRegex.allMatches(upQuery);
if (matches.length == 1) {
upQuery = matches.elementAt(0).group(1);
}
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
}

View file

@ -1,7 +1,7 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class TagFilter extends CollectionFilter {
static const type = 'tag';
@ -20,7 +20,7 @@ class TagFilter extends CollectionFilter {
String get label => tag;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.localOffer, size: size);
Widget iconBuilder(context, size) => Icon(AIcons.tag, size: size);
@override
String get typeKey => type;

View file

@ -1,7 +1,8 @@
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:flutter/foundation.dart';
@ -106,14 +107,17 @@ class ImageEntry {
bool get isFavourite => favourites.isFavourite(this);
bool get isGif => mimeType == MimeTypes.GIF;
bool get isSvg => mimeType == MimeTypes.SVG;
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.HEIC, MimeTypes.HEIF, MimeTypes.JPEG].contains(mimeType);
bool get isVideo => mimeType.startsWith('video');
bool get isCatalogued => _catalogMetadata != null;
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get canEdit => path != null;
bool get canPrint => !isVideo;
@ -217,12 +221,17 @@ class ImageEntry {
final coordinates = Coordinates(latitude, longitude);
try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
final addresses = await servicePolicy.call(
() => Geocoder.local.findAddressesFromCoordinates(coordinates),
priority: ServiceCallPriority.background,
debugLabel: 'findAddressesFromCoordinates-$path',
);
if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first;
addressDetails = AddressDetails(
contentId: contentId,
addressLine: address.addressLine,
countryCode: address.countryCode,
countryName: address.countryName,
adminArea: address.adminArea,
locality: address.locality,

View file

@ -29,6 +29,7 @@ class DateMetadata {
class CatalogMetadata {
final int contentId, dateMillis, videoRotation;
final bool isAnimated;
final String xmpSubjects, xmpTitleDescription;
final double latitude, longitude;
Address address;
@ -36,6 +37,7 @@ class CatalogMetadata {
CatalogMetadata({
this.contentId,
this.dateMillis,
this.isAnimated,
this.videoRotation,
this.xmpSubjects,
this.xmpTitleDescription,
@ -46,10 +48,12 @@ class CatalogMetadata {
: latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
factory CatalogMetadata.fromMap(Map map) {
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) {
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
return CatalogMetadata(
contentId: map['contentId'],
dateMillis: map['dateMillis'] ?? 0,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
videoRotation: map['videoRotation'] ?? 0,
xmpSubjects: map['xmpSubjects'] ?? '',
xmpTitleDescription: map['xmpTitleDescription'] ?? '',
@ -58,9 +62,10 @@ class CatalogMetadata {
);
}
Map<String, dynamic> toMap() => {
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
'contentId': contentId,
'dateMillis': dateMillis,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
'videoRotation': videoRotation,
'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription,
@ -70,7 +75,7 @@ class CatalogMetadata {
@override
String toString() {
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
}
}
@ -103,13 +108,14 @@ class OverlayMetadata {
class AddressDetails {
final int contentId;
final String addressLine, countryName, adminArea, locality;
final String addressLine, countryCode, countryName, adminArea, locality;
String get city => locality != null && locality.isNotEmpty ? locality : adminArea;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
AddressDetails({
this.contentId,
this.addressLine,
this.countryCode,
this.countryName,
this.adminArea,
this.locality,
@ -119,6 +125,7 @@ class AddressDetails {
return AddressDetails(
contentId: map['contentId'],
addressLine: map['addressLine'] ?? '',
countryCode: map['countryCode'] ?? '',
countryName: map['countryName'] ?? '',
adminArea: map['adminArea'] ?? '',
locality: map['locality'] ?? '',
@ -128,6 +135,7 @@ class AddressDetails {
Map<String, dynamic> toMap() => {
'contentId': contentId,
'addressLine': addressLine,
'countryCode': countryCode,
'countryName': countryName,
'adminArea': adminArea,
'locality': locality,
@ -135,7 +143,7 @@ class AddressDetails {
@override
String toString() {
return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
}
}

View file

@ -24,10 +24,32 @@ class MetadataDb {
_database = openDatabase(
await path,
onCreate: (db, version) async {
await db.execute('CREATE TABLE $dateTakenTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER)');
await db.execute('CREATE TABLE $metadataTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, xmpTitleDescription TEXT, latitude REAL, longitude REAL)');
await db.execute('CREATE TABLE $addressTable(contentId INTEGER PRIMARY KEY, addressLine TEXT, countryName TEXT, adminArea TEXT, locality TEXT)');
await db.execute('CREATE TABLE $favouriteTable(contentId INTEGER PRIMARY KEY, path TEXT)');
await db.execute('CREATE TABLE $dateTakenTable('
'contentId INTEGER PRIMARY KEY'
', dateMillis INTEGER'
')');
await db.execute('CREATE TABLE $metadataTable('
'contentId INTEGER PRIMARY KEY'
', dateMillis INTEGER'
', isAnimated INTEGER'
', videoRotation INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.execute('CREATE TABLE $addressTable('
'contentId INTEGER PRIMARY KEY'
', addressLine TEXT'
', countryCode TEXT'
', countryName TEXT'
', adminArea TEXT'
', locality TEXT'
')');
await db.execute('CREATE TABLE $favouriteTable('
'contentId INTEGER PRIMARY KEY'
', path TEXT'
')');
},
version: 1,
);
@ -74,7 +96,7 @@ class MetadataDb {
// final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(metadataTable);
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList();
// debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
return metadataEntries;
}
@ -94,7 +116,7 @@ class MetadataDb {
}
batch.insert(
metadataTable,
metadata.toMap(),
metadata.toMap(boolAsInteger: true),
conflictAlgorithm: ConflictAlgorithm.replace,
);
});

View file

@ -1,8 +1,12 @@
class MimeTypes {
static const String ANY_IMAGE = 'image/*';
static const String GIF = 'image/gif';
static const String HEIC = 'image/heic';
static const String HEIF = 'image/heif';
static const String JPEG = 'image/jpeg';
static const String PNG = 'image/png';
static const String SVG = 'image/svg+xml';
static const String WEBP = 'image/webp';
static const String ANY_VIDEO = 'video/*';
static const String AVI = 'video/avi';

View file

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class AndroidFileService {
static const platform = MethodChannel('deckers.thibault/aves/file');
static Future<List<Map>> getStorageVolumes() async {
try {
final result = await platform.invokeMethod('getStorageVolumes');
return (result as List).cast<Map>();
} on PlatformException catch (e) {
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return [];
}
}

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -42,31 +43,28 @@ class ImageFileService {
return Uint8List(0);
}
static Future<Uint8List> getThumbnail(ImageEntry entry, int width, int height) async {
if (width > 0 && height > 0) {
static Future<Uint8List> getThumbnail(ImageEntry entry, int width, int height, {Object cancellationKey}) {
return servicePolicy.call(
() async {
if (width > 0 && height > 0) {
// debugPrint('getThumbnail width=$width path=${entry.path}');
try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': entry.toMap(),
'width': width,
'height': height,
});
return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
return Uint8List(0);
}
static Future<void> cancelGetThumbnail(String uri) async {
try {
await platform.invokeMethod('cancelGetThumbnail', <String, dynamic>{
'uri': uri,
});
} on PlatformException catch (e) {
debugPrint('cancelGetThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': entry.toMap(),
'width': width,
'height': height,
});
return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
return Uint8List(0);
},
priority: ServiceCallPriority.asap,
debugLabel: 'getThumbnail-${entry.path}',
cancellationKey: cancellationKey,
);
}
static Future<bool> delete(ImageEntry entry) async {

View file

@ -1,5 +1,6 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -26,24 +27,32 @@ class MetadataService {
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry) async {
if (entry.isSvg) return null;
try {
// return map with:
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'latitude': latitude (double)
// 'longitude': longitude (double)
// 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'path': entry.path,
'uri': entry.uri,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
return servicePolicy.call(
() async {
try {
// return map with:
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool)
// 'latitude': latitude (double)
// 'longitude': longitude (double)
// 'videoRotation': video rotation degrees (int)
// 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'path': entry.path,
'uri': entry.uri,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
},
priority: ServiceCallPriority.background,
debugLabel: 'getCatalogMetadata-${entry.path}',
);
}
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {

View file

@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy {
final Queue<_Task> _asapQueue, _normalQueue, _backgroundQueue;
List<Queue<_Task>> _queues;
_Task _running;
ServicePolicy._private()
: _asapQueue = Queue(),
_normalQueue = Queue(),
_backgroundQueue = Queue() {
_queues = [_asapQueue, _normalQueue, _backgroundQueue];
}
Future<T> call<T>(
Future<T> Function() platformCall, {
ServiceCallPriority priority = ServiceCallPriority.normal,
String debugLabel,
Object cancellationKey,
}) {
Queue<_Task> queue;
switch (priority) {
case ServiceCallPriority.asap:
queue = _asapQueue;
break;
case ServiceCallPriority.background:
queue = _backgroundQueue;
break;
case ServiceCallPriority.normal:
default:
queue = _normalQueue;
break;
}
final completer = Completer<T>();
final wrapped = _Task(
() async {
// if (debugLabel != null) debugPrint('$runtimeType $debugLabel start');
final result = await platformCall();
completer.complete(result);
// if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
_running = null;
_pickNext();
},
completer,
cancellationKey,
);
queue.addLast(wrapped);
_pickNext();
return completer.future;
}
void _pickNext() {
if (_running != null) return;
final queue = _queues.firstWhere((q) => q.isNotEmpty, orElse: () => null);
_running = queue?.removeFirst();
_running?.callback?.call();
}
bool cancel(Object cancellationKey) {
var cancelled = false;
final tasks = _queues.expand((q) => q.where((task) => task.cancellationKey == cancellationKey)).toList();
tasks.forEach((task) => _queues.forEach((q) {
if (q.remove(task)) {
cancelled = true;
task.completer.completeError(CancelledException());
}
}));
return cancelled;
}
}
class _Task {
final VoidCallback callback;
final Completer completer;
final Object cancellationKey;
const _Task(this.callback, this.completer, this.cancellationKey);
}
class CancelledException {}
enum ServiceCallPriority { asap, normal, background }

View file

@ -1,4 +1,5 @@
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
@ -6,11 +7,13 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils {
String externalStorage, dcimPath, downloadPath, moviesPath, picturesPath;
static List<StorageVolume> storageVolumes = [];
static Map appNameMap = {};
AndroidFileUtils._private();
Future<void> init() async {
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList();
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
externalStorage = '/storage/emulated/0';
dcimPath = join(externalStorage, 'DCIM');
@ -29,6 +32,10 @@ class AndroidFileUtils {
bool isDownloadPath(String path) => path == downloadPath;
StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null);
bool isOnSD(String path) => getStorageVolume(path).isRemovable;
AlbumType getAlbumType(String albumDirectory) {
if (albumDirectory != null) {
if (androidFileUtils.isCameraPath(albumDirectory)) return AlbumType.Camera;
@ -56,3 +63,28 @@ enum AlbumType {
ScreenRecordings,
Screenshots,
}
class StorageVolume {
final String description, path, state;
final bool isEmulated, isPrimary, isRemovable;
const StorageVolume({
this.description,
this.isEmulated,
this.isPrimary,
this.isRemovable,
this.path,
this.state,
});
factory StorageVolume.fromMap(Map map) {
return StorageVolume(
description: map['description'] ?? '',
isEmulated: map['isEmulated'] ?? false,
isPrimary: map['isPrimary'] ?? false,
isRemovable: map['isRemovable'] ?? false,
path: map['path'] ?? '',
state: map['string'] ?? '',
);
}
}

View file

@ -290,9 +290,9 @@ class SearchField extends StatelessWidget {
),
autofocus: true,
onSubmitted: (query) {
query = query.trim();
if (query.isNotEmpty) {
collection.addFilter(QueryFilter(query));
final cleanQuery = query.trim();
if (cleanQuery.isNotEmpty) {
collection.addFilter(QueryFilter(cleanQuery));
}
stateNotifier.value = PageState.browse;
},

View file

@ -5,8 +5,8 @@ import 'package:aves/model/collection_source.dart';
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/mime.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings.dart';
@ -31,7 +31,7 @@ class CollectionDrawer extends StatefulWidget {
}
class _CollectionDrawerState extends State<CollectionDrawer> {
bool _albumsExpanded = false, _citiesExpanded = false, _countriesExpanded = false, _tagsExpanded = false;
bool _albumsExpanded = false, _placesExpanded = false, _countriesExpanded = false, _tagsExpanded = false;
CollectionSource get source => widget.source;
@ -77,51 +77,79 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
);
final videoEntry = _FilteredCollectionNavTile(
source: source,
leading: const Icon(OMIcons.movie),
leading: const Icon(AIcons.video),
title: 'Videos',
filter: MimeFilter(MimeTypes.ANY_VIDEO),
);
final gifEntry = _FilteredCollectionNavTile(
final animatedEntry = _FilteredCollectionNavTile(
source: source,
leading: const Icon(OMIcons.gif),
title: 'GIFs',
filter: MimeFilter(MimeTypes.GIF),
leading: const Icon(AIcons.animated),
title: 'Animated',
filter: MimeFilter(MimeFilter.animated),
);
final favouriteEntry = _FilteredCollectionNavTile(
source: source,
leading: const Icon(OMIcons.favoriteBorder),
leading: const Icon(AIcons.favourite),
title: 'Favourites',
filter: FavouriteFilter(),
);
final buildAlbumEntry = (album) => _FilteredCollectionNavTile(
source: source,
leading: IconUtils.getAlbumIcon(context: context, album: album),
title: CollectionSource.getUniqueAlbumName(album, source.sortedAlbums),
dense: true,
filter: AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, source.sortedAlbums)),
);
final buildTagEntry = (tag) => _FilteredCollectionNavTile(
final buildAlbumEntry = (String album) {
final uniqueName = source.getUniqueAlbumName(album);
return _FilteredCollectionNavTile(
source: source,
leading: IconUtils.getAlbumIcon(context: context, album: album),
title: uniqueName,
trailing: androidFileUtils.isOnSD(album)
? const Icon(
OMIcons.sdStorage,
size: 16,
color: Colors.grey,
)
: null,
dense: true,
filter: AlbumFilter(album, uniqueName),
);
};
final buildTagEntry = (String tag) => _FilteredCollectionNavTile(
source: source,
leading: Icon(
OMIcons.localOffer,
AIcons.tag,
color: stringToColor(tag),
),
title: tag,
dense: true,
filter: TagFilter(tag),
);
final buildLocationEntry = (level, location) => _FilteredCollectionNavTile(
source: source,
leading: Icon(
OMIcons.place,
color: stringToColor(location),
),
title: location,
dense: true,
filter: LocationFilter(level, location),
);
final buildLocationEntry = (LocationLevel level, String location) {
String title;
String flag;
if (level == LocationLevel.country) {
final split = location.split(';');
String countryCode;
if (split.isNotEmpty) title = split[0];
if (split.length > 1) countryCode = split[1];
flag = LocationFilter.countryCodeToFlag(countryCode);
} else {
title = location;
}
return _FilteredCollectionNavTile(
source: source,
leading: flag != null
? Text(
flag,
style: TextStyle(fontSize: IconTheme.of(context).size),
)
: Icon(
AIcons.location,
color: stringToColor(title),
),
title: title,
dense: true,
filter: LocationFilter(level, location),
);
};
final regularAlbums = [], appAlbums = [], specialAlbums = [];
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in source.sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.Default:
@ -135,15 +163,15 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
break;
}
}
final cities = source.sortedCities;
final countries = source.sortedCountries;
final places = source.sortedPlaces;
final tags = source.sortedTags;
final drawerItems = <Widget>[
header,
allMediaEntry,
videoEntry,
gifEntry,
animatedEntry,
favouriteEntry,
if (specialAlbums.isNotEmpty) ...[
const Divider(),
@ -175,34 +203,12 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
],
),
),
if (cities.isNotEmpty)
SafeArea(
top: false,
bottom: false,
child: ExpansionTile(
leading: const Icon(OMIcons.place),
title: Row(
children: [
const Text('Cities'),
const Spacer(),
Text(
'${cities.length}',
style: TextStyle(
color: (_citiesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6),
),
),
],
),
onExpansionChanged: (expanded) => setState(() => _citiesExpanded = expanded),
children: cities.map((s) => buildLocationEntry(LocationLevel.city, s)).toList(),
),
),
if (countries.isNotEmpty)
SafeArea(
top: false,
bottom: false,
child: ExpansionTile(
leading: const Icon(OMIcons.place),
leading: const Icon(AIcons.location),
title: Row(
children: [
const Text('Countries'),
@ -219,12 +225,34 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
children: countries.map((s) => buildLocationEntry(LocationLevel.country, s)).toList(),
),
),
if (places.isNotEmpty)
SafeArea(
top: false,
bottom: false,
child: ExpansionTile(
leading: const Icon(AIcons.location),
title: Row(
children: [
const Text('Places'),
const Spacer(),
Text(
'${places.length}',
style: TextStyle(
color: (_placesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6),
),
),
],
),
onExpansionChanged: (expanded) => setState(() => _placesExpanded = expanded),
children: places.map((s) => buildLocationEntry(LocationLevel.place, s)).toList(),
),
),
if (tags.isNotEmpty)
SafeArea(
top: false,
bottom: false,
child: ExpansionTile(
leading: const Icon(OMIcons.localOffer),
leading: const Icon(AIcons.tag),
title: Row(
children: [
const Text('Tags'),
@ -293,6 +321,7 @@ class _FilteredCollectionNavTile extends StatelessWidget {
final CollectionSource source;
final Widget leading;
final String title;
final Widget trailing;
final bool dense;
final CollectionFilter filter;
@ -300,6 +329,7 @@ class _FilteredCollectionNavTile extends StatelessWidget {
@required this.source,
@required this.leading,
@required this.title,
this.trailing,
bool dense,
@required this.filter,
}) : dense = dense ?? false;
@ -312,6 +342,7 @@ class _FilteredCollectionNavTile extends StatelessWidget {
child: ListTile(
leading: leading,
title: Text(title),
trailing: trailing,
dense: dense,
onTap: () => _goToCollection(context),
),

View file

@ -1,182 +0,0 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/sections.dart';
import 'package:aves/widgets/album/thumbnail.dart';
import 'package:aves/widgets/album/transparent_material_page_route.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
class SectionSliver extends StatelessWidget {
final CollectionLens collection;
final dynamic sectionKey;
final double tileExtent;
final bool showHeader;
const SectionSliver({
Key key,
@required this.collection,
@required this.sectionKey,
@required this.tileExtent,
@required this.showHeader,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final sections = collection.sections;
final sectionEntries = sections[sectionKey];
final childCount = sectionEntries.length;
final sliver = SliverGrid(
delegate: SliverChildBuilderDelegate(
// TODO TLAD thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild`
// called by `RenderSliverGrid.performLayout` (line 547)
(context, index) => index < childCount
? GridThumbnail(
collection: collection,
index: index,
entry: sectionEntries[index],
tileExtent: tileExtent,
)
: null,
childCount: childCount,
addAutomaticKeepAlives: false,
),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: tileExtent,
),
);
return showHeader
? SliverStickyHeader(
header: SectionHeader(
collection: collection,
sections: sections,
sectionKey: sectionKey,
),
sliver: sliver,
overlapsContent: false,
)
: sliver;
}
}
class GridThumbnail extends StatelessWidget {
final CollectionLens collection;
final int index;
final ImageEntry entry;
final double tileExtent;
final GestureTapCallback onTap;
const GridThumbnail({
Key key,
this.collection,
this.index,
this.entry,
this.tileExtent,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey(entry.uri),
onTap: () => _goToFullscreen(context),
child: MetaData(
metaData: ThumbnailMetadata(index, entry),
child: Thumbnail(
entry: entry,
extent: tileExtent,
heroTag: collection.heroTag(entry),
),
),
);
}
void _goToFullscreen(BuildContext context) {
Navigator.push(
context,
TransparentMaterialPageRoute(
pageBuilder: (c, a, sa) => MultiFullscreenPage(
collection: collection,
initialEntry: entry,
),
),
);
}
}
// metadata to identify entry from RenderObject hit test during collection scaling
class ThumbnailMetadata {
final int index;
final ImageEntry entry;
const ThumbnailMetadata(this.index, this.entry);
}
class SectionHeader extends StatelessWidget {
final CollectionLens collection;
final Map<dynamic, List<ImageEntry>> sections;
final dynamic sectionKey;
const SectionHeader({
Key key,
@required this.collection,
@required this.sections,
@required this.sectionKey,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget header;
switch (collection.sortFactor) {
case SortFactor.date:
if (collection.sortFactor == SortFactor.date) {
switch (collection.groupFactor) {
case GroupFactor.album:
header = _buildAlbumSectionHeader(context);
break;
case GroupFactor.month:
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
case GroupFactor.day:
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
}
}
break;
case SortFactor.size:
break;
case SortFactor.name:
header = _buildAlbumSectionHeader(context);
break;
}
return header != null
? IgnorePointer(
child: header,
)
: const SizedBox.shrink();
}
Widget _buildAlbumSectionHeader(BuildContext context) {
var albumIcon = IconUtils.getAlbumIcon(context: context, album: sectionKey as String);
if (albumIcon != null) {
albumIcon = Material(
type: MaterialType.circle,
elevation: 3,
color: Colors.transparent,
shadowColor: Colors.black,
child: albumIcon,
);
}
final title = CollectionSource.getUniqueAlbumName(sectionKey as String, sections.keys.cast<String>());
return TitleSectionHeader(
key: ValueKey(title),
leading: albumIcon,
title: title,
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/album/grid/header_generic.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class AlbumSectionHeader extends StatelessWidget {
final String folderPath, albumName;
const AlbumSectionHeader({
Key key,
@required this.folderPath,
@required this.albumName,
}) : super(key: key);
@override
Widget build(BuildContext context) {
var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath);
if (albumIcon != null) {
albumIcon = Material(
type: MaterialType.circle,
elevation: 3,
color: Colors.transparent,
shadowColor: Colors.black,
child: albumIcon,
);
}
return TitleSectionHeader(
leading: albumIcon,
title: albumName,
trailing: androidFileUtils.isOnSD(folderPath)
? const Icon(
OMIcons.sdStorage,
size: 16,
color: Color(0xFF757575),
)
: null,
);
}
}

View file

@ -1,6 +1,5 @@
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/fx/outlined_text.dart';
import 'package:aves/widgets/album/grid/header_generic.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@ -48,33 +47,3 @@ class MonthSectionHeader extends StatelessWidget {
return TitleSectionHeader(title: text);
}
}
class TitleSectionHeader extends StatelessWidget {
final Widget leading;
final String title;
const TitleSectionHeader({Key key, this.leading, this.title}) : super(key: key);
static const leadingDimension = 32.0;
static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: OutlinedText(
leadingBuilder: leading != null
? (context, isShadow) => Container(
padding: leadingPadding,
width: leadingDimension,
height: leadingDimension,
child: isShadow ? null : leading,
)
: null,
text: title,
style: Constants.titleTextStyle,
outlineWidth: 2,
),
);
}
}

View file

@ -0,0 +1,140 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/grid/header_album.dart';
import 'package:aves/widgets/album/grid/header_date.dart';
import 'package:aves/widgets/common/fx/outlined_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class SectionHeader extends StatelessWidget {
final CollectionLens collection;
final dynamic sectionKey;
const SectionHeader({
Key key,
@required this.collection,
@required this.sectionKey,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget header;
switch (collection.sortFactor) {
case SortFactor.date:
if (collection.sortFactor == SortFactor.date) {
switch (collection.groupFactor) {
case GroupFactor.album:
header = _buildAlbumSectionHeader();
break;
case GroupFactor.month:
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
case GroupFactor.day:
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
}
}
break;
case SortFactor.size:
break;
case SortFactor.name:
header = _buildAlbumSectionHeader();
break;
}
return header != null
? IgnorePointer(
child: header,
)
: const SizedBox.shrink();
}
Widget _buildAlbumSectionHeader() {
final folderPath = sectionKey as String;
return AlbumSectionHeader(
key: ValueKey(folderPath),
folderPath: folderPath,
albumName: collection.source.getUniqueAlbumName(folderPath),
);
}
// TODO TLAD cache header extent computation?
static double computeHeaderHeight(CollectionSource source, dynamic sectionKey, double scrollableWidth) {
var headerExtent = 0.0;
if (sectionKey is String) {
// only compute height for album headers, as they're the only likely ones to split on multiple lines
final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.Default;
final hasTrailing = androidFileUtils.isOnSD(sectionKey);
final text = source.getUniqueAlbumName(sectionKey);
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
final para = RenderParagraph(
TextSpan(
children: [
if (hasLeading)
// `RenderParagraph` fails to lay out `WidgetSpan` offscreen as of Flutter v1.17.0
// so we use a hair space times a magic number to match leading width
TextSpan(text: '\u200A' * 23), // 23 hair spaces match a width of 40.0
if (hasTrailing)
TextSpan(text: '\u200A' * 17),
TextSpan(
text: text,
style: Constants.titleTextStyle,
),
],
),
textDirection: TextDirection.ltr,
)..layout(BoxConstraints(maxWidth: maxWidth), parentUsesSize: true);
headerExtent = para.getMaxIntrinsicHeight(maxWidth);
}
headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension) + TitleSectionHeader.padding.vertical;
return headerExtent;
}
}
class TitleSectionHeader extends StatelessWidget {
final Widget leading, trailing;
final String title;
const TitleSectionHeader({
Key key,
this.leading,
this.title,
this.trailing,
}) : super(key: key);
static const leadingDimension = 32.0;
static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4);
static const trailingPadding = EdgeInsets.only(left: 8, bottom: 4);
static const padding = EdgeInsets.all(16);
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: padding,
constraints: const BoxConstraints(minHeight: leadingDimension),
child: OutlinedText(
leadingBuilder: leading != null
? (context, isShadow) => Container(
padding: leadingPadding,
width: leadingDimension,
height: leadingDimension,
child: isShadow ? null : leading,
)
: null,
text: title,
trailingBuilder: trailing != null
? (context, isShadow) => Container(
padding: trailingPadding,
child: isShadow ? null : trailing,
)
: null,
style: Constants.titleTextStyle,
outlineWidth: 2,
),
);
}
}

View file

@ -0,0 +1,252 @@
import 'dart:math' as math;
import 'package:aves/widgets/album/grid/list_section_layout.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
final List<SectionLayout> sectionLayouts;
const SliverKnownExtentList({
Key key,
@required SliverChildDelegate delegate,
@required this.sectionLayouts,
}) : super(key: key, delegate: delegate);
@override
RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
final element = context as SliverMultiBoxAdaptorElement;
return RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
}
@override
void updateRenderObject(BuildContext context, RenderSliverKnownExtentBoxAdaptor renderObject) {
renderObject.sectionLayouts = sectionLayouts;
}
}
class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
List<SectionLayout> _sectionLayouts;
List<SectionLayout> get sectionLayouts => _sectionLayouts;
set sectionLayouts(List<SectionLayout> value) {
assert(value != null);
if (_sectionLayouts == value) return;
_sectionLayouts = value;
markNeedsLayout();
}
RenderSliverKnownExtentBoxAdaptor({
@required RenderSliverBoxChildManager childManager,
@required List<SectionLayout> sectionLayouts,
}) : _sectionLayouts = sectionLayouts,
super(childManager: childManager);
SectionLayout sectionAtIndex(int index) => sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
SectionLayout sectionAtOffset(double scrollOffset) => sectionLayouts.firstWhere((section) => section.hasChildAtOffset(scrollOffset), orElse: () => null);
double indexToLayoutOffset(int index) {
return (sectionAtIndex(index) ?? sectionLayouts.last).indexToLayoutOffset(index);
}
int getMinChildIndexForScrollOffset(double scrollOffset) {
return sectionAtOffset(scrollOffset)?.getMinChildIndexForScrollOffset(scrollOffset) ?? 0;
}
int getMaxChildIndexForScrollOffset(double scrollOffset) {
return (sectionAtOffset(scrollOffset) ?? sectionLayouts.last).getMaxChildIndexForScrollOffset(scrollOffset);
}
double estimateMaxScrollOffset(
SliverConstraints constraints, {
int firstIndex,
int lastIndex,
double leadingScrollOffset,
double trailingScrollOffset,
}) {
return childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
);
}
double computeMaxScrollOffset(SliverConstraints constraints) {
return sectionLayouts.last.maxOffset;
}
int _calculateLeadingGarbage(int firstIndex) {
var walker = firstChild;
var leadingGarbage = 0;
while (walker != null && indexOf(walker) < firstIndex) {
leadingGarbage += 1;
walker = childAfter(walker);
}
return leadingGarbage;
}
int _calculateTrailingGarbage(int targetLastIndex) {
var walker = lastChild;
var trailingGarbage = 0;
while (walker != null && indexOf(walker) > targetLastIndex) {
trailingGarbage += 1;
walker = childBefore(walker);
}
return trailingGarbage;
}
@override
void performLayout() {
final constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final targetEndScrollOffset = scrollOffset + remainingExtent;
final childConstraints = constraints.asBoxConstraints();
final firstIndex = getMinChildIndexForScrollOffset(scrollOffset);
final targetLastIndex = targetEndScrollOffset.isFinite ? getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;
if (firstChild != null) {
final leadingGarbage = _calculateLeadingGarbage(firstIndex);
final trailingGarbage = _calculateTrailingGarbage(targetLastIndex);
collectGarbage(leadingGarbage, trailingGarbage);
} else {
collectGarbage(0, 0);
}
if (firstChild == null) {
if (!addInitialChild(index: firstIndex, layoutOffset: indexToLayoutOffset(firstIndex))) {
// There are either no children, or we are past the end of all our children.
// If it is the latter, we will need to find the first available child.
double max;
if (childManager.childCount != null) {
max = computeMaxScrollOffset(constraints);
} else if (firstIndex <= 0) {
max = 0.0;
} else {
// We will have to find it manually.
var possibleFirstIndex = firstIndex - 1;
while (possibleFirstIndex > 0 &&
!addInitialChild(
index: possibleFirstIndex,
layoutOffset: indexToLayoutOffset(possibleFirstIndex),
)) {
possibleFirstIndex -= 1;
}
max = sectionAtIndex(possibleFirstIndex).indexToLayoutOffset(possibleFirstIndex);
}
geometry = SliverGeometry(
scrollExtent: max,
maxPaintExtent: max,
);
childManager.didFinishLayout();
return;
}
}
RenderBox trailingChildWithLayout;
for (var index = indexOf(firstChild) - 1; index >= firstIndex; --index) {
final child = insertAndLayoutLeadingChild(childConstraints);
if (child == null) {
// Items before the previously first child are no longer present.
// Reset the scroll offset to offset all items prior and up to the
// missing item. Let parent re-layout everything.
final layout = sectionAtIndex(index) ?? sectionLayouts.first;
geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToMaxScrollOffset(index));
return;
}
final childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(index);
assert(childParentData.index == index);
trailingChildWithLayout ??= child;
}
if (trailingChildWithLayout == null) {
firstChild.layout(childConstraints);
final childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = indexToLayoutOffset(firstIndex);
trailingChildWithLayout = firstChild;
}
var estimatedMaxScrollOffset = double.infinity;
for (var index = indexOf(trailingChildWithLayout) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) {
var child = childAfter(trailingChildWithLayout);
if (child == null || indexOf(child) != index) {
child = insertAndLayoutChild(childConstraints, after: trailingChildWithLayout);
if (child == null) {
// We have run out of children.
final layout = sectionAtIndex(index) ?? sectionLayouts.last;
estimatedMaxScrollOffset = layout.indexToMaxScrollOffset(index);
break;
}
} else {
child.layout(childConstraints);
}
trailingChildWithLayout = child;
assert(child != null);
final childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
assert(childParentData.index == index);
childParentData.layoutOffset = indexToLayoutOffset(childParentData.index);
}
final lastIndex = indexOf(lastChild);
final leadingScrollOffset = indexToLayoutOffset(firstIndex);
final trailingScrollOffset = indexToLayoutOffset(lastIndex + 1);
assert(firstIndex == 0 || childScrollOffset(firstChild) - scrollOffset <= precisionErrorTolerance);
assert(debugAssertChildListIsNonEmptyAndContiguous());
assert(indexOf(firstChild) == firstIndex);
assert(targetLastIndex == null || lastIndex <= targetLastIndex);
estimatedMaxScrollOffset = math.min(
estimatedMaxScrollOffset,
estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
),
);
final paintExtent = calculatePaintOffset(
constraints,
from: leadingScrollOffset,
to: trailingScrollOffset,
);
final cacheExtent = calculateCacheOffset(
constraints,
from: leadingScrollOffset,
to: trailingScrollOffset,
);
final targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent;
final targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite ? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint) : null;
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
hasVisualOverflow: (targetLastIndexForPaint != null && lastIndex >= targetLastIndexForPaint) || constraints.scrollOffset > 0.0,
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (estimatedMaxScrollOffset == trailingScrollOffset) childManager.setDidUnderflow(true);
childManager.didFinishLayout();
}
}

View file

@ -0,0 +1,183 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/grid/header_generic.dart';
import 'package:aves/widgets/album/grid/list_sliver.dart';
import 'package:aves/widgets/album/grid/tile_extent_manager.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SectionedListLayoutProvider extends StatelessWidget {
final CollectionLens collection;
final int columnCount;
final double scrollableWidth;
final double tileExtent;
final Widget child;
SectionedListLayoutProvider({
@required this.collection,
@required this.scrollableWidth,
@required this.tileExtent,
@required this.child,
}) : columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin);
@override
Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout>(
update: (context, __) => _updateLayouts(context),
child: child,
);
}
SectionedListLayout _updateLayouts(BuildContext context) {
debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent');
final sectionLayouts = <SectionLayout>[];
final showHeaders = collection.showHeaders;
final source = collection.source;
final sections = collection.sections;
final sectionKeys = sections.keys.toList();
var currentIndex = 0, currentOffset = 0.0;
sectionKeys.forEach((sectionKey) {
final sectionEntryCount = sections[sectionKey].length;
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
final headerExtent = showHeaders ? SectionHeader.computeHeaderHeight(source, sectionKey, scrollableWidth) : 0.0;
final sectionFirstIndex = currentIndex;
currentIndex += sectionChildCount;
final sectionLastIndex = currentIndex - 1;
final sectionMinOffset = currentOffset;
currentOffset += headerExtent + tileExtent * (sectionChildCount - 1);
final sectionMaxOffset = currentOffset;
sectionLayouts.add(
SectionLayout(
sectionKey: sectionKey,
firstIndex: sectionFirstIndex,
lastIndex: sectionLastIndex,
minOffset: sectionMinOffset,
maxOffset: sectionMaxOffset,
headerExtent: headerExtent,
tileExtent: tileExtent,
builder: (context, listIndex) => _buildInSection(listIndex - sectionFirstIndex, collection, sectionKey),
),
);
});
return SectionedListLayout(
collection: collection,
columnCount: columnCount,
tileExtent: tileExtent,
sectionLayouts: sectionLayouts,
);
}
Widget _buildInSection(int sectionChildIndex, CollectionLens collection, dynamic sectionKey) {
if (sectionChildIndex == 0) {
return collection.showHeaders
? SectionHeader(
collection: collection,
sectionKey: sectionKey,
)
: const SizedBox.shrink();
}
sectionChildIndex--;
final section = collection.sections[sectionKey];
final sectionEntryCount = section.length;
final minEntryIndex = sectionChildIndex * columnCount;
final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount);
final children = <Widget>[];
for (var i = minEntryIndex; i < maxEntryIndex; i++) {
final entry = section[i];
children.add(GridThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
index: i,
entry: entry,
tileExtent: tileExtent,
));
}
return Row(
mainAxisSize: MainAxisSize.min,
children: children,
);
}
}
class SectionedListLayout {
final CollectionLens collection;
final int columnCount;
final double tileExtent;
final List<SectionLayout> sectionLayouts;
const SectionedListLayout({
@required this.collection,
@required this.columnCount,
@required this.tileExtent,
@required this.sectionLayouts,
});
Rect getTileRect(ImageEntry entry) {
final section = collection.sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
if (section == null) return null;
final sectionKey = section.key;
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
if (sectionLayout == null) return null;
final showHeaders = collection.showHeaders;
final sectionEntryIndex = section.value.indexOf(entry);
final column = sectionEntryIndex % columnCount;
final row = (sectionEntryIndex / columnCount).floor();
final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row;
final left = tileExtent * column;
final top = sectionLayout.indexToLayoutOffset(listIndex);
debugPrint('TLAD getTileRect sectionKey=$sectionKey sectionOffset=${sectionLayout.minOffset} top=$top row=$row column=$column for title=${entry.bestTitle}');
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
}
}
class SectionLayout {
final dynamic sectionKey;
final int firstIndex, lastIndex;
final double minOffset, maxOffset;
final double headerExtent, tileExtent;
final IndexedWidgetBuilder builder;
const SectionLayout({
@required this.sectionKey,
@required this.firstIndex,
@required this.lastIndex,
@required this.minOffset,
@required this.maxOffset,
@required this.headerExtent,
@required this.tileExtent,
@required this.builder,
});
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
double indexToLayoutOffset(int index) {
return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent);
}
double indexToMaxScrollOffset(int index) {
return minOffset + headerExtent + (index - firstIndex) * tileExtent;
}
int getMinChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= minOffset + headerExtent;
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor());
}
int getMaxChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= minOffset + headerExtent;
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
}
}

View file

@ -0,0 +1,85 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.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.dart';
import 'package:aves/widgets/common/transparent_material_page_route.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up
// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0
class CollectionListSliver extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sectionLayouts = Provider.of<SectionedListLayout>(context).sectionLayouts;
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
return SliverKnownExtentList(
sectionLayouts: sectionLayouts,
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= childCount) return null;
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
return sectionLayout?.builder(context, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
),
);
}
}
class GridThumbnail extends StatelessWidget {
final CollectionLens collection;
final int index;
final ImageEntry entry;
final double tileExtent;
final GestureTapCallback onTap;
const GridThumbnail({
Key key,
this.collection,
this.index,
this.entry,
this.tileExtent,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey(entry.uri),
onTap: () => _goToFullscreen(context),
child: MetaData(
metaData: ThumbnailMetadata(index, entry),
child: DecoratedThumbnail(
entry: entry,
extent: tileExtent,
heroTag: collection.heroTag(entry),
),
),
);
}
void _goToFullscreen(BuildContext context) {
Navigator.push(
context,
TransparentMaterialPageRoute(
pageBuilder: (c, a, sa) => MultiFullscreenPage(
collection: collection,
initialEntry: entry,
),
),
);
}
}
// metadata to identify entry from RenderObject hit test during collection scaling
class ThumbnailMetadata {
final int index;
final ImageEntry entry;
const ThumbnailMetadata(this.index, this.entry);
}

View file

@ -2,26 +2,27 @@ import 'dart:math';
import 'dart:ui' as ui;
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/collection_section.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.dart';
import 'package:aves/widgets/album/tile_extent_manager.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:provider/provider.dart';
class GridScaleGestureDetector extends StatefulWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> extentNotifier;
final Size mqSize;
final EdgeInsets mqPadding;
final double mqHorizontalPadding;
final Widget child;
const GridScaleGestureDetector({
this.scrollableKey,
@required this.extentNotifier,
@required this.mqSize,
@required this.mqPadding,
@required this.mqHorizontalPadding,
@required this.child,
});
@ -34,7 +35,6 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
ValueNotifier<double> _scaledExtentNotifier;
OverlayEntry _overlayEntry;
ThumbnailMetadata _metadata;
RenderSliver _renderSliver;
RenderViewport _renderViewport;
ValueNotifier<double> get tileExtentNotifier => widget.extentNotifier;
@ -42,6 +42,10 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: (details) {
// if `onHorizontalDragStart` callback is not defined,
// horizontal drag gestures are interpreted as scaling
},
onScaleStart: (details) {
final scrollableContext = widget.scrollableKey.currentContext;
final RenderBox scrollableBox = scrollableContext.findRenderObject();
@ -53,7 +57,6 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
final renderMetaData = firstOf<RenderMetaData>(result);
// abort if we cannot find an image to show on overlay
if (renderMetaData == null) return;
_renderSliver = firstOf<RenderSliverStickyHeader>(result) ?? firstOf<RenderSliverGrid>(result);
_renderViewport = firstOf<RenderViewport>(result);
_metadata = renderMetaData.metaData;
_startExtent = tileExtentNotifier.value;
@ -93,26 +96,27 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
// sanitize and update grid layout if necessary
final newExtent = TileExtentManager.applyTileExtent(
widget.mqSize,
widget.mqPadding,
widget.mqHorizontalPadding,
tileExtentNotifier,
newExtent: _scaledExtentNotifier.value,
);
_scaledExtentNotifier = null;
if (newExtent == oldExtent) return;
// TODO TLAD fix scroll to specific thumbnail with custom SliverList
// scroll to show the focal point thumbnail at its new position
final sliverClosure = _renderSliver;
final viewportClosure = _renderViewport;
final index = _metadata.index;
WidgetsBinding.instance.addPostFrameCallback((_) {
final scrollableContext = widget.scrollableKey.currentContext;
final gridSize = (scrollableContext.findRenderObject() as RenderBox).size;
final newColumnCount = gridSize.width / newExtent;
final row = index ~/ newColumnCount;
final sectionLayout = Provider.of<SectionedListLayout>(context, listen: false);
final tileRect = sectionLayout.getTileRect(_metadata.entry);
final scrollOffset = (tileRect?.top ?? 0) - gridSize.height / 2;
viewportClosure.offset.jumpTo(scrollOffset.clamp(.0, double.infinity));
// about scrolling & offset retrieval:
// `Scrollable.ensureVisible` only works on already rendered objects
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
final scrollOffset = viewportClosure.scrollOffsetOf(sliverClosure, (row + 1) * newExtent - gridSize.height / 2);
viewportClosure.offset.jumpTo(scrollOffset.clamp(.0, double.infinity));
// `RenderViewport.scrollOffsetOf` is a good alternative
});
},
child: widget.child,
@ -200,7 +204,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
Positioned(
left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2,
child: Thumbnail(
child: DecoratedThumbnail(
entry: widget.imageEntry,
extent: extent,
),
@ -228,12 +232,12 @@ class GridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..strokeWidth = Thumbnail.borderWidth
..strokeWidth = DecoratedThumbnail.borderWidth
..shader = ui.Gradient.radial(
center,
size.width / 2,
[
Thumbnail.borderColor,
DecoratedThumbnail.borderColor,
Colors.transparent,
],
[

View file

@ -7,9 +7,13 @@ class TileExtentManager {
static const int columnCountMin = 2;
static const int columnCountDefault = 4;
static const double tileExtentMin = 46.0;
static const screenDimensionMin = tileExtentMin * columnCountMin;
static double applyTileExtent(Size mqSize, EdgeInsets mqPadding, ValueNotifier<double> extentNotifier, {double newExtent}) {
final availableWidth = mqSize.width - mqPadding.horizontal;
static double applyTileExtent(Size mqSize, double mqHorizontalPadding, ValueNotifier<double> extentNotifier, {double newExtent}) {
// sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size)
mqSize = Size(max(mqSize.width, screenDimensionMin), max(mqSize.height, screenDimensionMin));
final availableWidth = mqSize.width - mqHorizontalPadding;
var numColumns;
if ((newExtent ?? 0) == 0) {
newExtent = extentNotifier.value;

View file

@ -1,5 +1,4 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
@ -63,23 +62,23 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
children: [
_buildFilterRow(
context: context,
filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeTypes.GIF), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)),
filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeFilter.animated), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)),
),
_buildFilterRow(
context: context,
title: 'Albums',
filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, CollectionSource.getUniqueAlbumName(s, source.sortedAlbums))).where((f) => containQuery(f.uniqueName)),
),
_buildFilterRow(
context: context,
title: 'Cities',
filters: source.sortedCities.where(containQuery).map((s) => LocationFilter(LocationLevel.city, s)),
filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)),
),
_buildFilterRow(
context: context,
title: 'Countries',
filters: source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)),
),
_buildFilterRow(
context: context,
title: 'Places',
filters: source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)),
),
_buildFilterRow(
context: context,
title: 'Tags',

View file

@ -10,7 +10,7 @@ import 'package:aves/widgets/common/transition_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class Thumbnail extends StatelessWidget {
class DecoratedThumbnail extends StatelessWidget {
final ImageEntry entry;
final double extent;
final Object heroTag;
@ -18,7 +18,7 @@ class Thumbnail extends StatelessWidget {
static final Color borderColor = Colors.grey.shade700;
static const double borderWidth = .5;
const Thumbnail({
const DecoratedThumbnail({
Key key,
@required this.entry,
@required this.extent,
@ -39,7 +39,13 @@ class Thumbnail extends StatelessWidget {
child: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
entry.isSvg ? _buildVectorImage() : _buildRasterImage(),
entry.isSvg
? _buildVectorImage()
: ThumbnailRasterImage(
entry: entry,
extent: extent,
heroTag: heroTag,
),
_ThumbnailOverlay(
entry: entry,
extent: extent,
@ -49,38 +55,6 @@ class Thumbnail extends StatelessWidget {
);
}
Widget _buildRasterImage() {
final thumbnailProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent);
final image = Image(
image: thumbnailProvider,
width: extent,
height: extent,
fit: BoxFit.cover,
);
return heroTag == null
? image
: Hero(
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
ImageProvider heroImageProvider = thumbnailProvider;
if (!entry.isVideo && !entry.isSvg) {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {
heroImageProvider = imageProvider;
}
}
return TransitionImage(
image: heroImageProvider,
animation: animation,
);
},
child: image,
);
}
Widget _buildVectorImage() {
final child = Container(
// center `SvgPicture` inside `Container` with the thumbnail dimensions
@ -106,6 +80,89 @@ class Thumbnail extends StatelessWidget {
}
}
class ThumbnailRasterImage extends StatefulWidget {
final ImageEntry entry;
final double extent;
final Object heroTag;
const ThumbnailRasterImage({
Key key,
@required this.entry,
@required this.extent,
this.heroTag,
}) : super(key: key);
@override
_ThumbnailRasterImageState createState() => _ThumbnailRasterImageState();
}
class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
ThumbnailProvider _imageProvider;
ImageEntry get entry => widget.entry;
double get extent => widget.extent;
Object get heroTag => widget.heroTag;
@override
void initState() {
super.initState();
_initProvider();
}
@override
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) {
_cancelProvider();
_initProvider();
}
}
@override
void dispose() {
_cancelProvider();
super.dispose();
}
void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent);
void _cancelProvider() => _imageProvider?.cancel();
@override
Widget build(BuildContext context) {
final image = Image(
image: _imageProvider,
width: extent,
height: extent,
fit: BoxFit.cover,
);
return heroTag == null
? image
: Hero(
tag: heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
ImageProvider heroImageProvider = _imageProvider;
if (!entry.isVideo && !entry.isSvg) {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {
heroImageProvider = imageProvider;
}
}
return TransitionImage(
image: heroImageProvider,
animation: animation,
);
},
child: image,
);
}
}
class _ThumbnailOverlay extends StatelessWidget {
final ImageEntry entry;
final double extent;
@ -125,8 +182,8 @@ class _ThumbnailOverlay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.hasGps) GpsIcon(iconSize: iconSize),
if (entry.isGif)
GifIcon(iconSize: iconSize)
if (entry.isAnimated)
AnimatedImageIcon(iconSize: iconSize)
else if (entry.isVideo)
DefaultTextStyle(
style: TextStyle(

View file

@ -2,16 +2,17 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/album/collection_app_bar.dart';
import 'package:aves/widgets/album/app_bar.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/album/collection_scaling.dart';
import 'package:aves/widgets/album/collection_section.dart';
import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/album/tile_extent_manager.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/common/icons.dart';
import 'package:aves/widgets/common/scroll_thumb.dart';
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -30,85 +31,36 @@ class ThumbnailCollection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Selector<MediaQueryData, Tuple3<Size, EdgeInsets, double>>(
selector: (c, mq) => Tuple3(mq.size, mq.padding, mq.viewInsets.bottom),
builder: (c, mq, child) {
child: Selector<MediaQueryData, Tuple2<Size, double>>(
selector: (context, mq) => Tuple2(mq.size, mq.padding.horizontal),
builder: (context, mq, child) {
final mqSize = mq.item1;
final mqPadding = mq.item2;
final mqViewInsetsBottom = mq.item3;
TileExtentManager.applyTileExtent(mqSize, mqPadding, _tileExtentNotifier);
final mqHorizontalPadding = mq.item2;
TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier);
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
return Consumer<CollectionLens>(
builder: (context, collection, child) {
// debugPrint('$runtimeType collection builder entries=${collection.entryCount}');
final sectionKeys = collection.sections.keys.toList();
final showHeaders = collection.showHeaders;
return GridScaleGestureDetector(
final scrollView = _buildScrollView(collection);
final draggable = _buildDraggableScrollView(scrollView);
final scaler = GridScaleGestureDetector(
scrollableKey: _scrollableKey,
extentNotifier: _tileExtentNotifier,
mqSize: mqSize,
mqPadding: mqPadding,
child: ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) {
debugPrint('$runtimeType tileExtent builder entries=${collection.entryCount} tileExtent=$tileExtent');
final scrollView = CustomScrollView(
key: _scrollableKey,
primary: true,
// workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null,
slivers: [
CollectionAppBar(
stateNotifier: stateNotifier,
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
if (collection.isEmpty)
SliverFillRemaining(
child: _buildEmptyCollectionPlaceholder(collection),
hasScrollBody: false,
),
...sectionKeys.map((sectionKey) => SectionSliver(
collection: collection,
sectionKey: sectionKey,
tileExtent: tileExtent,
showHeader: showHeaders,
)),
SliverToBoxAdapter(
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.viewInsets.bottom,
builder: (c, mqViewInsetsBottom, child) {
return SizedBox(height: mqViewInsetsBottom);
},
),
),
],
);
return ValueListenableBuilder<double>(
valueListenable: _appBarHeightNotifier,
builder: (context, appBarHeight, child) {
return DraggableScrollbar(
heightScrollThumb: avesScrollThumbHeight,
backgroundColor: Colors.white,
scrollThumbBuilder: avesScrollThumbBuilder(
height: avesScrollThumbHeight,
backgroundColor: Colors.white,
),
controller: PrimaryScrollController.of(context),
padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below
top: appBarHeight,
bottom: mqViewInsetsBottom,
),
child: child,
);
},
child: scrollView,
);
},
mqHorizontalPadding: mqHorizontalPadding,
child: draggable,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
collection: collection,
scrollableWidth: mqSize.width - mqHorizontalPadding,
tileExtent: tileExtent,
child: scaler,
),
);
return sectionedListLayoutProvider;
},
);
},
@ -116,15 +68,71 @@ class ThumbnailCollection extends StatelessWidget {
);
}
ScrollView _buildScrollView(CollectionLens collection) {
return CustomScrollView(
key: _scrollableKey,
primary: true,
// workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null,
slivers: [
CollectionAppBar(
stateNotifier: stateNotifier,
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
collection.isEmpty
? SliverFillRemaining(
child: _buildEmptyCollectionPlaceholder(collection),
hasScrollBody: false,
)
: CollectionListSliver(),
SliverToBoxAdapter(
child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom,
builder: (context, mqViewInsetsBottom, child) {
return SizedBox(height: mqViewInsetsBottom);
},
),
),
],
);
}
Widget _buildDraggableScrollView(ScrollView scrollView) {
return ValueListenableBuilder<double>(
valueListenable: _appBarHeightNotifier,
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom,
builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar(
heightScrollThumb: avesScrollThumbHeight,
backgroundColor: Colors.white,
scrollThumbBuilder: avesScrollThumbBuilder(
height: avesScrollThumbHeight,
backgroundColor: Colors.white,
),
controller: PrimaryScrollController.of(context),
padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below
top: appBarHeight,
bottom: mqViewInsetsBottom,
),
child: scrollView,
),
child: child,
),
);
}
Widget _buildEmptyCollectionPlaceholder(CollectionLens collection) {
return collection.filters.any((filter) => filter is FavouriteFilter)
? const EmptyContent(
icon: OMIcons.favoriteBorder,
icon: AIcons.favourite,
text: 'No favourites!',
)
: collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO)
? const EmptyContent(
icon: OMIcons.movie,
icon: AIcons.video,
)
: const EmptyContent();
}

View file

@ -4,8 +4,6 @@ import 'package:outline_material_icons/outline_material_icons.dart';
typedef FilterCallback = void Function(CollectionFilter filter);
typedef FilterBuilder = CollectionFilter Function(String label);
class AvesFilterChip extends StatefulWidget {
final CollectionFilter filter;
final bool removable;

View file

@ -4,9 +4,9 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';

View file

@ -3,18 +3,19 @@ import 'package:flutter/material.dart';
typedef OutlinedWidgetBuilder = Widget Function(BuildContext context, bool isShadow);
class OutlinedText extends StatelessWidget {
final OutlinedWidgetBuilder leadingBuilder;
final OutlinedWidgetBuilder leadingBuilder, trailingBuilder;
final String text;
final TextStyle style;
final double outlineWidth;
final Color outlineColor;
static const leadingAlignment = PlaceholderAlignment.middle;
static const widgetSpanAlignment = PlaceholderAlignment.middle;
const OutlinedText({
Key key,
this.leadingBuilder,
@required this.text,
this.trailingBuilder,
@required this.style,
double outlineWidth,
Color outlineColor,
@ -31,7 +32,7 @@ class OutlinedText extends StatelessWidget {
children: [
if (leadingBuilder != null)
WidgetSpan(
alignment: leadingAlignment,
alignment: widgetSpanAlignment,
child: leadingBuilder(context, true),
),
TextSpan(
@ -43,6 +44,11 @@ class OutlinedText extends StatelessWidget {
..color = outlineColor,
),
),
if (trailingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: trailingBuilder(context, true),
),
],
),
),
@ -51,13 +57,18 @@ class OutlinedText extends StatelessWidget {
children: [
if (leadingBuilder != null)
WidgetSpan(
alignment: leadingAlignment,
alignment: widgetSpanAlignment,
child: leadingBuilder(context, false),
),
TextSpan(
text: text,
style: style,
),
if (trailingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: trailingBuilder(context, false),
),
],
),
),

View file

@ -6,6 +6,16 @@ import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class AIcons {
static const IconData animated = Icons.slideshow;
static const IconData video = OMIcons.movie;
static const IconData favourite = OMIcons.favoriteBorder;
static const IconData favouriteActive = OMIcons.favorite;
static const IconData date = OMIcons.calendarToday;
static const IconData location = OMIcons.place;
static const IconData tag = OMIcons.localOffer;
}
class VideoIcon extends StatelessWidget {
final ImageEntry entry;
final double iconSize;
@ -16,22 +26,23 @@ class VideoIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: OMIcons.playCircleOutline,
iconSize: iconSize,
size: iconSize,
text: entry.durationText,
);
}
}
class GifIcon extends StatelessWidget {
class AnimatedImageIcon extends StatelessWidget {
final double iconSize;
const GifIcon({Key key, this.iconSize}) : super(key: key);
const AnimatedImageIcon({Key key, this.iconSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: OMIcons.gif,
iconSize: iconSize,
icon: AIcons.animated,
size: iconSize,
iconSize: iconSize * .8,
);
}
}
@ -44,44 +55,57 @@ class GpsIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: OMIcons.place,
iconSize: iconSize,
icon: AIcons.location,
size: iconSize,
);
}
}
class OverlayIcon extends StatelessWidget {
final IconData icon;
final double iconSize;
final double size, iconSize;
final String text;
const OverlayIcon({Key key, this.icon, this.iconSize, this.text}) : super(key: key);
const OverlayIcon({
Key key,
@required this.icon,
@required this.size,
double iconSize,
this.text,
}) : iconSize = iconSize ?? size,
super(key: key);
@override
Widget build(BuildContext context) {
final iconChild = SizedBox(
width: size,
height: size,
child: Icon(
icon,
size: iconSize,
),
);
return Container(
margin: const EdgeInsets.all(1),
padding: text != null ? EdgeInsets.only(right: iconSize / 4) : null,
padding: text != null ? EdgeInsets.only(right: size / 4) : null,
decoration: BoxDecoration(
color: const Color(0xBB000000),
borderRadius: BorderRadius.all(
Radius.circular(iconSize),
Radius.circular(size),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: iconSize,
),
if (text != null) ...[
const SizedBox(width: 2),
Text(text),
]
],
),
child: text == null
? iconChild
: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
iconChild,
const SizedBox(width: 2),
Text(text),
],
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -38,11 +39,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.sizePixels);
if (bytes.lengthInBytes == 0) {
return null;
}
return await decode(bytes);
return await decode(bytes ?? Uint8List(0));
}
}

View file

@ -1,12 +1,15 @@
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
const ThumbnailProvider({
ThumbnailProvider({
@required this.entry,
@required this.extent,
this.scale = 1.0,
@ -18,6 +21,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final double extent;
final double scale;
final Object _cancellationKey = Uuid();
@override
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
// configuration can be empty (e.g. when obtaining key for eviction)
@ -33,7 +38,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
return CancellableMultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
@ -44,12 +49,14 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
final dimPixels = (extent * key.devicePixelRatio).round();
final bytes = await ImageFileService.getThumbnail(key.entry, dimPixels, dimPixels);
if (bytes.lengthInBytes == 0) {
return null;
}
final bytes = await ImageFileService.getThumbnail(key.entry, dimPixels, dimPixels, cancellationKey: _cancellationKey);
return await decode(bytes ?? Uint8List(0));
}
return await decode(bytes);
Future<void> cancel() async {
if (servicePolicy.cancel(_cancellationKey)) {
await evict();
}
}
}
@ -74,4 +81,30 @@ class ThumbnailProviderKey {
@override
int get hashCode => hashValues(entry.uri, extent, scale);
@override
String toString() {
return 'ThumbnailProviderKey{uri=${entry.uri}, extent=$extent, scale=$scale}';
}
}
class CancellableMultiFrameImageStreamCompleter extends MultiFrameImageStreamCompleter {
CancellableMultiFrameImageStreamCompleter({
@required Future<ui.Codec> codec,
@required double scale,
Stream<ImageChunkEvent> chunkEvents,
InformationCollector informationCollector,
}) : super(
codec: codec,
scale: scale,
chunkEvents: chunkEvents,
informationCollector: informationCollector,
);
@override
void reportError({DiagnosticsNode context, dynamic exception, StackTrace stack, informationCollector, bool silent = false}) {
// prevent default error reporting in case of planned cancellation
if (exception is CancelledException) return;
super.reportError(context: context, exception: exception, stack: stack, informationCollector: informationCollector, silent: silent);
}
}

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -36,11 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
assert(key == this);
final bytes = await ImageFileService.getImage(uri, mimeType);
if (bytes.lengthInBytes == 0) {
return null;
}
return await decode(bytes);
return await decode(bytes ?? Uint8List(0));
}
@override

View file

@ -1,4 +1,4 @@
import 'package:aves/model/image_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

View file

@ -4,6 +4,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:flutter/material.dart';
@ -48,6 +49,9 @@ class DebugPageState extends State<DebugPage> {
child: ListView(
padding: const EdgeInsets.all(8),
children: [
const Text('Storage'),
...AndroidFileUtils.storageVolumes.map((v) => Text('${v.description}: ${v.path} (removable: ${v.isRemovable})')),
const Divider(),
Row(
children: [
const Text('Settings'),

View file

@ -68,6 +68,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
if (data != null)
InfoRowGroup({
'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}',
'videoRotation': '${data.videoRotation}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
@ -91,7 +92,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Text('DB address:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'dateMillis': '${data.addressLine}',
'addressLine': '${data.addressLine}',
'countryCode': '${data.countryCode}',
'countryName': '${data.countryName}',
'adminArea': '${data.adminArea}',
'locality': '${data.locality}',
@ -100,6 +102,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
);
},
),
const Divider(),
Text('Catalog metadata: ${widget.entry.catalogMetadata}'),
],
),
),

View file

@ -2,8 +2,8 @@ import 'dart:io';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/debug.dart';
import 'package:aves/widgets/fullscreen/fullscreen_actions.dart';

View file

@ -2,9 +2,11 @@ import 'dart:io';
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart';
@ -17,10 +19,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class FullscreenBody extends StatefulWidget {
final CollectionLens collection;
@ -48,7 +50,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding;
FullscreenActionDelegate _actionDelegate;
final List<Tuple2<String, VideoPlayerController>> _videoControllers = [];
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
CollectionLens get collection => widget.collection;
@ -112,6 +114,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
_videoControllers.clear();
_verticalPager.removeListener(_onVerticalPageControllerChange);
_unregisterWidget(widget);
super.dispose();
@ -207,22 +210,28 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_onLeave();
return SynchronousFuture(true);
},
child: Stack(
children: [
FullscreenVerticalPageView(
collection: collection,
entry: _entry,
videoControllers: _videoControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
),
topOverlay,
bottomOverlay,
],
child: NotificationListener(
onNotification: (notification) {
if (notification is FilterNotification) _goToCollection(notification.filter);
return false;
},
child: Stack(
children: [
FullscreenVerticalPageView(
collection: collection,
entry: _entry,
videoControllers: _videoControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
),
topOverlay,
bottomOverlay,
],
),
),
);
}
@ -231,6 +240,17 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
_verticalScrollNotifier.notifyListeners();
}
void _goToCollection(CollectionFilter filter) {
_showSystemUI();
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(collection.derive(filter)),
),
(route) => false,
);
}
Future<void> _goToVerticalPage(int page) {
return _verticalPager.animateToPage(
page,
@ -275,9 +295,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
// system UI
void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]);
static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]);
// overlay
@ -311,7 +331,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
void _initVideoController() {
Future<void> _initVideoController() async {
if (_entry == null || !_entry.isVideo) return;
final uri = _entry.uri;
@ -319,9 +339,8 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
} else {
// unsupported by video_player 0.10.8+2 (backed by ExoPlayer): AVI
final controller = VideoPlayerController.uri(uri)..initialize();
controllerEntry = Tuple2(uri, controller);
// do not set data source of IjkMediaController here
controllerEntry = Tuple2(uri, IjkMediaController());
}
_videoControllers.insert(0, controllerEntry);
while (_videoControllers.length > 3) {
@ -333,7 +352,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
class FullscreenVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ImageEntry entry;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested;
@ -380,8 +399,8 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
@override
void dispose() {
super.dispose();
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(FullscreenVerticalPageView widget) {

View file

@ -1,10 +1,10 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class MultiImagePage extends StatefulWidget {
final CollectionLens collection;
@ -12,7 +12,7 @@ class MultiImagePage extends StatefulWidget {
final ValueChanged<int> onPageChanged;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final List<Tuple2<String, IjkMediaController>> videoControllers;
const MultiImagePage({
this.collection,
@ -65,7 +65,7 @@ class SingleImagePage extends StatefulWidget {
final ImageEntry entry;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final List<Tuple2<String, IjkMediaController>> videoControllers;
const SingleImagePage({
this.entry,

View file

@ -4,18 +4,18 @@ import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class ImageView extends StatelessWidget {
final ImageEntry entry;
final Object heroTag;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final List<Tuple2<String, IjkMediaController>> videoControllers;
const ImageView({
this.entry,

View file

@ -1,5 +1,4 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
@ -29,7 +28,7 @@ class BasicSection extends StatelessWidget {
Widget build(BuildContext context) {
final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : '?';
final showMegaPixels = !entry.isVideo && !entry.isGif && entry.megaPixels != null && entry.megaPixels > 0;
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
@ -51,9 +50,9 @@ class BasicSection extends StatelessWidget {
final album = entry.directory;
final filters = [
if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO),
if (entry.isGif) MimeFilter(MimeTypes.GIF),
if (entry.isAnimated) MimeFilter(MimeFilter.animated),
if (isFavourite) FavouriteFilter(),
if (album != null) AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, collection?.source?.sortedAlbums)),
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)),
]..sort();
if (filters.isEmpty) return const SizedBox.shrink();

View file

@ -1,7 +1,6 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/collection_page.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/fullscreen/info/basic_section.dart';
@ -155,13 +154,7 @@ class InfoPageState extends State<InfoPage> {
void _goToCollection(CollectionFilter filter) {
if (collection == null) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(collection.derive(filter)),
),
(route) => false,
);
FilterNotification(filter).dispatch(context);
}
}
@ -228,3 +221,9 @@ class InfoRowGroup extends StatelessWidget {
}
class BackUpNotification extends Notification {}
class FilterNotification extends Notification {
final CollectionFilter filter;
const FilterNotification(this.filter);
}

View file

@ -2,9 +2,10 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/map_initializer.dart';
import 'package:flutter/material.dart';
@ -80,9 +81,9 @@ 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));
final city = address.city;
if (city != null && city.isNotEmpty) filters.add(LocationFilter(LocationLevel.city, city));
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country;${address.countryCode}'));
final place = address.place;
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
} else if (entry.hasGps) {
location = toDMS(entry.latLng).join(', ');
}
@ -93,7 +94,7 @@ class _LocationSectionState extends State<LocationSection> {
if (widget.showTitle)
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: SectionRow(OMIcons.place),
child: SectionRow(AIcons.location),
),
ImageMap(
markerId: entry.uri ?? entry.path,
@ -170,9 +171,10 @@ class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
children: [
Expanded(
child: GestureDetector(
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
onScaleStart: (d) {},
onScaleStart: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(16),

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
@ -26,6 +26,7 @@ class MetadataSectionSliver extends StatefulWidget {
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
List<_MetadataDirectory> _metadata = [];
String _loadedMetadataUri;
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
bool get isVisible => widget.visibleNotifier.value;
@ -81,6 +82,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount];
return ExpansionTileCard(
value: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
title: _DirectoryTitle(dir.name),
children: [
const Divider(thickness: 1.0, height: 1.0),
@ -91,6 +94,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
),
],
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
);
},
childCount: 1 + _metadata.length,
@ -119,6 +123,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
_metadata = [];
_loadedMetadataUri = null;
}
_expandedDirectoryNotifier.value = null;
if (mounted) setState(() {});
}

View file

@ -3,10 +3,11 @@ import 'dart:ui';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
@ -216,7 +217,7 @@ class _LocationRow extends AnimatedWidget {
}
return Row(
children: [
const Icon(OMIcons.place, size: _iconSize),
const Icon(AIcons.location, size: _iconSize),
const SizedBox(width: _iconPadding),
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
],
@ -236,7 +237,7 @@ class _DateRow extends StatelessWidget {
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row(
children: [
const Icon(OMIcons.calendarToday, size: _iconSize),
const Icon(AIcons.date, size: _iconSize),
const SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)),

View file

@ -2,12 +2,12 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/fullscreen/fullscreen_actions.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:provider/provider.dart';
class FullscreenTopOverlay extends StatelessWidget {
@ -130,12 +130,12 @@ class FullscreenTopOverlay extends StatelessWidget {
alignment: Alignment.center,
children: [
IconButton(
icon: Icon(isFavourite ? OMIcons.favorite : OMIcons.favoriteBorder),
icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite),
onPressed: onPressed,
tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites',
),
Sweeper(
builder: (context) => Icon(OMIcons.favoriteBorder, color: Colors.redAccent),
builder: (context) => Icon(AIcons.favourite, color: Colors.redAccent),
toggledNotifier: entry.isFavouriteNotifier,
),
],
@ -181,11 +181,11 @@ class FullscreenTopOverlay extends StatelessWidget {
child = entry.isFavouriteNotifier.value
? const MenuRow(
text: 'Remove from favourites',
icon: OMIcons.favorite,
icon: AIcons.favouriteActive,
)
: const MenuRow(
text: 'Add to favourites',
icon: OMIcons.favoriteBorder,
icon: AIcons.favourite,
);
break;
case FullscreenAction.info:

View file

@ -1,18 +1,20 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_app_service.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class VideoControlOverlay extends StatefulWidget {
final ImageEntry entry;
final Animation<double> scale;
final VideoPlayerController controller;
final IjkMediaController controller;
final EdgeInsets viewInsets, viewPadding;
const VideoControlOverlay({
@ -32,16 +34,28 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
final GlobalKey _progressBarKey = GlobalKey();
bool _playingOnDragStart = false;
AnimationController _playPauseAnimation;
final List<StreamSubscription> _subscriptions = [];
double _seekTargetPercent;
// video info is not refreshed by default, so we use a timer to do so
Timer _progressTimer;
ImageEntry get entry => widget.entry;
Animation<double> get scale => widget.scale;
VideoPlayerController get controller => widget.controller;
IjkMediaController get controller => widget.controller;
VideoPlayerValue get value => widget.controller.value;
// `videoInfo` is never null (even if `toString` prints `null`)
// check presence with `hasData` instead
VideoInfo get videoInfo => controller.videoInfo;
double get progress => value.position != null && value.duration != null ? value.position.inMilliseconds / value.duration.inMilliseconds : 0;
// we check whether video info is ready instead of checking for `noDatasource` status,
// as the controller could also be uninitialized with the `pause` status
// (e.g. when switching between video entries without playing them the first time)
bool get isInitialized => videoInfo.hasData;
bool get isPlaying => controller.ijkStatus == IjkStatus.playing;
@override
void initState() {
@ -51,7 +65,6 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
vsync: this,
);
_registerWidget(widget);
_onValueChange();
}
@override
@ -69,11 +82,17 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
}
void _registerWidget(VideoControlOverlay widget) {
widget.controller.addListener(_onValueChange);
_subscriptions.add(widget.controller.ijkStatusStream.listen(_onStatusChange));
_subscriptions.add(widget.controller.textureIdStream.listen(_onTextureIdChange));
_onStatusChange(widget.controller.ijkStatus);
_onTextureIdChange(widget.controller.textureId);
}
void _unregisterWidget(VideoControlOverlay widget) {
widget.controller.removeListener(_onValueChange);
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
_stopTimer();
}
@override
@ -93,37 +112,43 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
padding: safePadding,
child: SizedBox(
width: mqWidth - safePadding.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: value.hasError
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(OMIcons.openInNew),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open',
),
),
]
: [
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: _playPause,
tooltip: value.isPlaying ? 'Pause' : 'Play',
),
),
],
),
child: StreamBuilder<IjkStatus>(
stream: controller.ijkStatusStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final status = controller.ijkStatus;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: status == IjkStatus.error
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(OMIcons.openInNew),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open',
),
),
]
: [
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: _playPause,
tooltip: isPlaying ? 'Pause' : 'Play',
),
),
],
);
}),
),
);
},
@ -138,14 +163,14 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
borderRadius: progressBarBorderRadius,
child: GestureDetector(
onTapDown: (TapDownDetails details) {
_seek(details.globalPosition);
_seekFromTap(details.globalPosition);
},
onHorizontalDragStart: (DragStartDetails details) {
_playingOnDragStart = controller.value.isPlaying;
_playingOnDragStart = isPlaying;
if (_playingOnDragStart) controller.pause();
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
_seek(details.globalPosition);
_seekFromTap(details.globalPosition);
},
onHorizontalDragEnd: (DragEndDetails details) {
if (_playingOnDragStart) controller.play();
@ -164,12 +189,25 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
children: [
Row(
children: [
Text(formatDuration(value.position ?? Duration.zero)),
StreamBuilder<VideoInfo>(
stream: controller.videoInfoStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = videoInfo.currentPosition?.floor() ?? 0;
return Text(formatDuration(Duration(seconds: position)));
}),
const Spacer(),
Text(formatDuration(value.duration ?? Duration.zero)),
Text(entry.durationText),
],
),
LinearProgressIndicator(value: progress),
StreamBuilder<VideoInfo>(
stream: controller.videoInfoStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = videoInfo.progress;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(value: progress);
}),
],
),
),
@ -178,23 +216,44 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
);
}
void _onValueChange() {
setState(() {});
updatePlayPauseIcon();
void _startTimer() {
if (controller.textureId == null) return;
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) {
controller.refreshVideoInfo();
});
}
void _stopTimer() {
_progressTimer?.cancel();
}
void _onTextureIdChange(int textureId) {
if (textureId != null) {
_startTimer();
} else {
_stopTimer();
}
}
void _onStatusChange(IjkStatus status) {
if (status == IjkStatus.playing && _seekTargetPercent != null) {
_seekFromTarget();
}
_updatePlayPauseIcon();
}
Future<void> _playPause() async {
if (value.isPlaying) {
if (isPlaying) {
await controller.pause();
} else {
if (!value.initialized) await controller.initialize();
} else if (isInitialized) {
await controller.play();
} else {
await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true);
}
setState(() {});
}
void updatePlayPauseIcon() {
final isPlaying = value.isPlaying;
void _updatePlayPauseIcon() {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
@ -203,10 +262,29 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
}
}
void _seek(Offset globalPosition) {
void _seekFromTap(Offset globalPosition) async {
final keyContext = _progressBarKey.currentContext;
final RenderBox box = keyContext.findRenderObject();
final localPosition = box.globalToLocal(globalPosition);
controller.seekTo(value.duration * (localPosition.dx / box.size.width));
_seekTargetPercent = (localPosition.dx / box.size.width);
if (isInitialized) {
await _seekFromTarget();
} else {
// autoplay when seeking on uninitialized player, otherwise the texture is not updated
// as a workaround, pausing after a brief duration is possible, but fiddly
await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true);
}
}
Future _seekFromTarget() async {
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
// so we make sure the video info is up to date first
if (videoInfo.duration == null) {
await controller.refreshVideoInfo();
} else {
await controller.seekToProgress(_seekTargetPercent);
_seekTargetPercent = null;
}
}
}

View file

@ -1,13 +1,14 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
class AvesVideo extends StatefulWidget {
final ImageEntry entry;
final VideoPlayerController controller;
final IjkMediaController controller;
const AvesVideo({
Key key,
@ -20,15 +21,16 @@ class AvesVideo extends StatefulWidget {
}
class AvesVideoState extends State<AvesVideo> {
final List<StreamSubscription> _subscriptions = [];
ImageEntry get entry => widget.entry;
VideoPlayerValue get value => widget.controller.value;
IjkMediaController get controller => widget.controller;
@override
void initState() {
super.initState();
_registerWidget(widget);
_onValueChange();
}
@override
@ -45,38 +47,78 @@ class AvesVideoState extends State<AvesVideo> {
}
void _registerWidget(AvesVideo widget) {
widget.controller.addListener(_onValueChange);
_subscriptions.add(widget.controller.playFinishStream.listen(_onPlayFinish));
}
void _unregisterWidget(AvesVideo widget) {
widget.controller.removeListener(_onValueChange);
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
bool isPlayable(IjkStatus status) => [IjkStatus.prepared, IjkStatus.playing, IjkStatus.pause, IjkStatus.complete].contains(status);
@override
Widget build(BuildContext context) {
if (value == null) return const SizedBox();
if (value.hasError) {
return Image(
image: UriImage(uri: entry.uri, mimeType: entry.mimeType),
width: entry.width.toDouble(),
height: entry.height.toDouble(),
);
}
return Center(
child: AspectRatio(
aspectRatio: entry.displayAspectRatio,
child: VideoPlayer(widget.controller),
),
);
if (controller == null) return const SizedBox();
return StreamBuilder<IjkStatus>(
stream: widget.controller.ijkStatusStream,
builder: (context, snapshot) {
final status = snapshot.data;
return isPlayable(status)
? IjkPlayer(
mediaController: controller,
controllerWidgetBuilder: (controller) => const SizedBox.shrink(),
statusWidgetBuilder: (context, controller, status) => const SizedBox.shrink(),
textureBuilder: (context, controller, info) {
var id = controller.textureId;
if (id == null) {
return AspectRatio(
aspectRatio: entry.displayAspectRatio,
child: Container(
color: Colors.green,
),
);
}
Widget child = Container(
color: Colors.blue,
child: Texture(
textureId: id,
),
);
if (!controller.autoRotate) {
return child;
}
final degree = entry.catalogMetadata?.videoRotation ?? 0;
if (degree != 0) {
child = RotatedBox(
quarterTurns: degree ~/ 90,
child: child,
);
}
child = AspectRatio(
aspectRatio: entry.displayAspectRatio,
child: child,
);
return Container(
child: child,
alignment: Alignment.center,
color: Colors.transparent,
);
},
)
: Image(
image: UriImage(uri: entry.uri, mimeType: entry.mimeType),
width: entry.width.toDouble(),
height: entry.height.toDouble(),
);
});
}
void _onValueChange() {
if (!value.isPlaying && value.position == value.duration) _goToStart();
setState(() {});
}
Future<void> _goToStart() async {
await widget.controller.seekTo(Duration.zero);
await widget.controller.pause();
}
void _onPlayFinish(IjkMediaController controller) => controller.seekTo(0);
}

View file

@ -11,16 +11,16 @@ import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/album/empty.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/icons.dart';
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
class StatsPage extends StatelessWidget {
final CollectionLens collection;
final Map<String, int> entryCountPerCity = {}, entryCountPerCountry = {}, entryCountPerTag = {};
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
List<ImageEntry> get entries => collection.sortedEntries;
@ -30,14 +30,15 @@ class StatsPage extends StatelessWidget {
entries.forEach((entry) {
if (entry.isLocated) {
final address = entry.addressDetails;
final city = address.city;
if (city != null && city.isNotEmpty) {
entryCountPerCity[city] = (entryCountPerCity[city] ?? 0) + 1;
}
final country = address.countryName;
var country = address.countryName;
if (country != null && country.isNotEmpty) {
country += ';${address.countryCode}';
entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1;
}
final place = address.place;
if (place != null && place.isNotEmpty) {
entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1;
}
}
entry.xmpSubjects.forEach((tag) {
entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1;
@ -76,7 +77,7 @@ class StatsPage extends StatelessWidget {
backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor,
animation: true,
leading: const Icon(OMIcons.place),
leading: const Icon(AIcons.location),
// right padding to match leading, so that inside label is aligned with outside label below
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(right: 24),
center: Text(NumberFormat.percentPattern().format(withGpsPercent)),
@ -86,8 +87,8 @@ class StatsPage extends StatelessWidget {
],
),
),
..._buildTopFilters(context, 'Top cities', entryCountPerCity, (s) => LocationFilter(LocationLevel.city, s)),
..._buildTopFilters(context, 'Top countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)),
..._buildTopFilters(context, 'Top places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)),
..._buildTopFilters(context, 'Top tags', entryCountPerTag, (s) => TagFilter(s)),
],
);
@ -193,7 +194,12 @@ class StatsPage extends StatelessWidget {
});
}
List<Widget> _buildTopFilters(BuildContext context, String title, Map<String, int> entryCountMap, FilterBuilder filterBuilder) {
List<Widget> _buildTopFilters(
BuildContext context,
String title,
Map<String, int> entryCountMap,
CollectionFilter Function(String key) filterBuilder,
) {
if (entryCountMap.isEmpty) return [];
final maxCount = collection.entryCount;
@ -214,7 +220,8 @@ class StatsPage extends StatelessWidget {
padding: const EdgeInsetsDirectional.only(start: AvesFilterChip.buttonBorderWidth / 2 + 6, end: 8),
child: Table(
children: sortedEntries.take(5).map((kv) {
final label = kv.key;
final filter = filterBuilder(kv.key);
final label = filter.label;
final count = kv.value;
final percent = count / maxCount;
return TableRow(
@ -222,7 +229,7 @@ class StatsPage extends StatelessWidget {
Align(
alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip(
filter: filterBuilder(label),
filter: filter,
onPressed: (filter) => _goToCollection(context, filter),
),
),

View file

@ -35,7 +35,7 @@ packages:
name: barcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.0"
version: "1.6.0"
boolean_selector:
dependency: transitive
description:
@ -88,11 +88,9 @@ packages:
draggable_scrollbar:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "3b823ae0a9def4edec62771f18e6348312bfce15"
url: "git://github.com/deckerst/flutter-draggable-scrollbar.git"
source: git
path: "../flutter-draggable-scrollbar"
relative: true
source: path
version: "0.0.4"
event_bus:
dependency: "direct main"
@ -104,24 +102,29 @@ packages:
expansion_tile_card:
dependency: "direct main"
description:
name: expansion_tile_card
url: "https://pub.dartlang.org"
source: hosted
path: "../expansion_tile_card"
relative: true
source: path
version: "1.0.3"
flushbar:
dependency: "direct main"
description:
path: "."
ref: "13c55a8"
resolved-ref: "13c55a888c1693f1c8269ea30d55c614a1bfee16"
url: "https://github.com/AndreHaueisen/flushbar.git"
source: git
version: "1.9.1"
name: flushbar
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_ijkplayer:
dependency: "direct main"
description:
path: "../flutter_ijkplayer"
relative: true
source: path
version: "0.3.6"
flutter_native_timezone:
dependency: "direct main"
description:
@ -136,22 +139,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.6"
flutter_sticky_header:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "14be154f50f5d14e88cc05b93b12377012b8905a"
url: "git://github.com/deckerst/flutter_sticky_header.git"
source: git
version: "0.4.2"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.3+1"
version: "0.17.4"
flutter_test:
dependency: "direct dev"
description: flutter
@ -175,7 +169,7 @@ packages:
name: google_maps_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.25+2"
version: "0.5.26"
image:
dependency: transitive
description:
@ -196,7 +190,7 @@ packages:
name: io
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3"
version: "0.3.4"
logger:
dependency: "direct main"
description:
@ -273,7 +267,7 @@ packages:
name: pdf
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.0"
version: "1.6.1"
pedantic:
dependency: "direct main"
description:
@ -287,14 +281,14 @@ packages:
name: percent_indicator
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1+1"
version: "2.1.3"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0+hotfix.2"
version: "5.0.0+hotfix.3"
permission_handler_platform_interface:
dependency: transitive
description:
@ -329,14 +323,14 @@ packages:
name: printing
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1"
version: "3.3.1"
provider:
dependency: "direct main"
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.4"
version: "4.0.5"
qr:
dependency: transitive
description:
@ -482,6 +476,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0+5"
uuid:
dependency: "direct main"
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
vector_math:
dependency: transitive
description:
@ -489,27 +490,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
video_player:
dependency: "direct main"
description:
path: "../plugins/packages/video_player/video_player"
relative: true
source: path
version: "0.10.8+2"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
video_player_web:
dependency: transitive
description:
name: video_player_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2+1"
xml:
dependency: transitive
description:

View file

@ -13,6 +13,25 @@ description: A new Flutter application.
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - no URI handling by default but trivial by fork
# - support: AVI/XVID/MP3 nothing, MP2T nothing
# - cannot support more formats
# - playable only when both the video and audio streams are supported
# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg):
# - URI handling
# - support: AVI/XVID/MP3 audio only, MP2T video only
# - possible support for more formats by customizing ffmpeg build,
# - playable when only the video or audio stream is supported
# - crash when calling `seekTo` for some files (e.g. TED talk videos)
# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg):
# - URI handling (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android <Q)
# - support: AVI/XVID/MP3 video/audio, MP2T video only
# - possible support for more formats by TODO TLAD customizing ffmpeg build?
# - playable when only the video or audio stream is supported
dependencies:
flutter:
sdk: flutter
@ -20,19 +39,20 @@ dependencies:
charts_flutter:
collection:
draggable_scrollbar:
git:
url: git://github.com/deckerst/flutter-draggable-scrollbar.git
path: ../flutter-draggable-scrollbar
# git:
# url: git://github.com/deckerst/flutter-draggable-scrollbar.git
event_bus:
expansion_tile_card:
path: ../expansion_tile_card
# git:
# url: git://github.com/deckerst/expansion_tile_card.git
flutter_ijkplayer:
path: ../flutter_ijkplayer
# git:
# url: git://github.com/deckerst/flutter_ijkplayer.git
flushbar:
# flushbar-1.9.1 cannot be built with Flutter 1.15.17
git:
url: https://github.com/AndreHaueisen/flushbar.git
ref: 13c55a8
flutter_native_timezone:
flutter_sticky_header:
git:
url: git://github.com/deckerst/flutter_sticky_header.git
flutter_svg:
geocoder:
google_maps_flutter:
@ -53,8 +73,7 @@ dependencies:
sqflite:
transparent_image:
tuple:
video_player:
path: ../plugins/packages/video_player/video_player
uuid:
dev_dependencies:
flutter_test: