Merge branch 'sliver-list'
This commit is contained in:
commit
2ef43c5b24
66 changed files with 1980 additions and 893 deletions
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
16
lib/services/android_file_service.dart
Normal file
16
lib/services/android_file_service.dart
Normal 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 [];
|
||||
}
|
||||
}
|
|
@ -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 {
|
|
@ -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 {
|
88
lib/services/service_policy.dart
Normal file
88
lib/services/service_policy.dart
Normal 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 }
|
|
@ -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'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
},
|
|
@ -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),
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
40
lib/widgets/album/grid/header_album.dart
Normal file
40
lib/widgets/album/grid/header_album.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
140
lib/widgets/album/grid/header_generic.dart
Normal file
140
lib/widgets/album/grid/header_generic.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
252
lib/widgets/album/grid/list_known_extent.dart
Normal file
252
lib/widgets/album/grid/list_known_extent.dart
Normal 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();
|
||||
}
|
||||
}
|
183
lib/widgets/album/grid/list_section_layout.dart
Normal file
183
lib/widgets/album/grid/list_section_layout.dart
Normal 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);
|
||||
}
|
||||
}
|
85
lib/widgets/album/grid/list_sliver.dart
Normal file
85
lib/widgets/album/grid/list_sliver.dart
Normal 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);
|
||||
}
|
|
@ -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,
|
||||
],
|
||||
[
|
|
@ -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;
|
|
@ -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',
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(() {});
|
||||
}
|
||||
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
|
|
86
pubspec.lock
86
pubspec.lock
|
@ -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:
|
||||
|
|
41
pubspec.yaml
41
pubspec.yaml
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue