Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-09-22 23:47:55 +09:00
commit 440d6da046
66 changed files with 1499 additions and 430 deletions

View file

@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate": case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start(); new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break; break;
case "renameDirectory":
new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start();
break;
default: default:
result.notImplemented(); result.notImplemented();
break; break;
@ -179,4 +182,26 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
}); });
} }
private void renameDirectory(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String dirPath = call.argument("path");
String newName = call.argument("newName");
if (dirPath == null || newName == null) {
result.error("renameDirectory-args", "failed because of missing arguments", null);
return;
}
ImageProvider provider = new MediaStoreImageProvider();
provider.renameDirectory(activity, dirPath, newName, new ImageProvider.AlbumRenameOpCallback() {
@Override
public void onSuccess(List<Map<String, Object>> fieldsByEntry) {
result.success(fieldsByEntry);
}
@Override
public void onFailure(Throwable throwable) {
result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage());
}
});
}
} }

View file

@ -16,24 +16,36 @@ import androidx.annotation.Nullable;
import com.adobe.internal.xmp.XMPException; import com.adobe.internal.xmp.XMPException;
import com.adobe.internal.xmp.XMPIterator; import com.adobe.internal.xmp.XMPIterator;
import com.adobe.internal.xmp.XMPMeta; import com.adobe.internal.xmp.XMPMeta;
import com.adobe.internal.xmp.XMPUtils;
import com.adobe.internal.xmp.properties.XMPProperty; import com.adobe.internal.xmp.properties.XMPProperty;
import com.adobe.internal.xmp.properties.XMPPropertyInfo; import com.adobe.internal.xmp.properties.XMPPropertyInfo;
import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.imaging.jpeg.JpegMetadataReader;
import com.drew.imaging.jpeg.JpegSegmentMetadataReader;
import com.drew.imaging.jpeg.JpegSegmentType;
import com.drew.lang.GeoLocation; import com.drew.lang.GeoLocation;
import com.drew.lang.Rational; import com.drew.lang.Rational;
import com.drew.lang.annotations.NotNull;
import com.drew.metadata.Directory; import com.drew.metadata.Directory;
import com.drew.metadata.Metadata; import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.Tag; import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory; import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifReader;
import com.drew.metadata.exif.ExifSubIFDDirectory; import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.ExifThumbnailDirectory;
import com.drew.metadata.exif.GpsDirectory; import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.file.FileTypeDirectory; import com.drew.metadata.file.FileTypeDirectory;
import com.drew.metadata.gif.GifAnimationDirectory; import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory; import com.drew.metadata.webp.WebpDirectory;
import com.drew.metadata.xmp.XmpDirectory; import com.drew.metadata.xmp.XmpDirectory;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TimeZone; import java.util.TimeZone;
@ -70,9 +82,15 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// XMP // XMP
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"; private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
private static final String XMP_XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/";
private static final String XMP_IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/";
private static final String XMP_SUBJECT_PROP_NAME = "dc:subject"; 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_TITLE_PROP_NAME = "dc:title";
private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description"; private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description";
private static final String XMP_THUMBNAIL_PROP_NAME = "xmp:Thumbnails";
private static final String XMP_THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image";
private static final String XMP_GENERIC_LANG = ""; private static final String XMP_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US"; private static final String XMP_SPECIFIC_LANG = "en-US";
@ -108,6 +126,49 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// "+51.3328-000.7053+113.474/" (Apple) // "+51.3328-000.7053+113.474/" (Apple)
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*"); private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*");
private static int TAG_THUMBNAIL_DATA = 0x10000;
// modify metadata-extractor readers to store EXIF thumbnail data
// cf https://github.com/drewnoakes/metadata-extractor/issues/276#issuecomment-677767368
static {
List<JpegSegmentMetadataReader> allReaders = (List<JpegSegmentMetadataReader>) JpegMetadataReader.ALL_READERS;
for (int n = 0, cnt = allReaders.size(); n < cnt; n++) {
if (allReaders.get(n).getClass() != ExifReader.class) {
continue;
}
allReaders.set(n, new ExifReader() {
@Override
public void readJpegSegments(@NotNull final Iterable<byte[]> segments, @NotNull final Metadata metadata, @NotNull final JpegSegmentType segmentType) {
super.readJpegSegments(segments, metadata, segmentType);
for (byte[] segmentBytes : segments) {
// Filter any segments containing unexpected preambles
if (!startsWithJpegExifPreamble(segmentBytes)) {
continue;
}
// Extract the thumbnail
try {
ExifThumbnailDirectory tnDirectory = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class);
if (tnDirectory != null && tnDirectory.containsTag(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET)) {
int offset = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET);
int length = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH);
byte[] tnData = new byte[length];
System.arraycopy(segmentBytes, JPEG_SEGMENT_PREAMBLE.length() + offset, tnData, 0, length);
tnDirectory.setObject(TAG_THUMBNAIL_DATA, tnData);
}
} catch (MetadataException e) {
e.printStackTrace();
}
}
}
});
break;
}
}
private Context context; private Context context;
public MetadataHandler(Context context) { public MetadataHandler(Context context) {
@ -129,6 +190,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
case "getContentResolverMetadata": case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start(); new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break; break;
case "getExifThumbnails":
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
break;
case "getXmpThumbnails":
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
break;
default: default:
result.notImplemented(); result.notImplemented();
break; break;
@ -463,6 +530,50 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
} }
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (ExifThumbnailDirectory dir : metadata.getDirectoriesOfType(ExifThumbnailDirectory.class)) {
byte[] data = (byte[]) dir.getObject(TAG_THUMBNAIL_DATA);
if (data != null) {
thumbnails.add(data);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract exif thumbnail", e);
}
result.success(thumbnails);
}
private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) {
int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME);
for (int i = 1; i < count + 1; i++) {
XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME);
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.getValue()));
}
}
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e);
}
result.success(thumbnails);
}
// convenience methods // convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) { private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {

View file

@ -66,6 +66,10 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
handler.post(() -> eventSink.endOfStream()); handler.post(() -> eventSink.endOfStream());
} }
// Supported image formats:
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() { private void getImage() {
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) { if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) {
RequestOptions options = new RequestOptions() RequestOptions options = new RequestOptions()
@ -91,42 +95,40 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally { } finally {
Glide.with(activity).clear(target); Glide.with(activity).clear(target);
} }
} else if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images
// so we convert the image on platform side first
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.load(uri)
.submit();
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
success(stream.toByteArray());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
} finally {
Glide.with(activity).clear(target);
}
} else { } else {
ContentResolver cr = activity.getContentResolver(); ContentResolver cr = activity.getContentResolver();
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) { try (InputStream is = cr.openInputStream(uri)) {
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images if (is != null) {
// so we convert the image on platform side first streamBytes(is);
FutureTarget<Bitmap> target = Glide.with(activity) } else {
.asBitmap() error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
.load(uri)
.submit();
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
success(stream.toByteArray());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
} finally {
Glide.with(activity).clear(target);
}
} else {
try (InputStream is = cr.openInputStream(uri)) {
if (is != null) {
streamBytes(is);
} else {
error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
}
} catch (IOException e) {
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
} }
} catch (IOException e) {
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
} }
} }
endOfStream(); endOfStream();

View file

@ -20,6 +20,7 @@ import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory; import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.mp4.Mp4Directory; import com.drew.metadata.mp4.Mp4Directory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory; import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import com.drew.metadata.photoshop.PsdHeaderDirectory;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@ -191,48 +192,69 @@ public class SourceImageEntry {
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
if (MimeTypes.JPEG.equals(sourceMimeType)) { switch (sourceMimeType) {
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) { case MimeTypes.JPEG:
if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH); if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
} }
if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) { break;
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT); case MimeTypes.MP4:
for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
} }
for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) {
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION);
}
}
break;
case MimeTypes.AVI:
for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH);
}
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT);
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
}
}
break;
case MimeTypes.PSD:
for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) {
if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH);
}
if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT);
}
}
break;
}
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH);
} }
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) { if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) {
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT);
orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
} }
} else if (MimeTypes.MP4.equals(sourceMimeType)) { if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) { orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
} }
for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) { if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) { sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION);
}
}
} else if (MimeTypes.AVI.equals(sourceMimeType)) {
for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH);
}
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT);
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
}
} }
} }
} catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) { } catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) {

View file

@ -17,14 +17,17 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.commonsware.cwac.document.DocumentFileCompat; import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
@ -86,6 +89,101 @@ public abstract class ImageProvider {
scanNewPath(context, newFile.getPath(), mimeType, callback); scanNewPath(context, newFile.getPath(), mimeType, callback);
} }
@SuppressWarnings("UnstableApiUsage")
public void renameDirectory(Context context, String oldDirPath, String newDirName, final AlbumRenameOpCallback callback) {
if (!oldDirPath.endsWith(File.separator)) {
oldDirPath += File.separator;
}
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, oldDirPath);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to find directory at path=" + oldDirPath));
return;
}
List<Map<String, Object>> entries = new ArrayList<>();
entries.addAll(listContentEntries(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, oldDirPath));
entries.addAll(listContentEntries(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, oldDirPath));
boolean renamed;
try {
renamed = destinationDirDocFile.renameTo(newDirName);
} catch (FileNotFoundException e) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath, e));
return;
}
if (!renamed) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath));
return;
}
List<SettableFuture<Map<String, Object>>> scanFutures = new ArrayList<>();
String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator;
for (Map<String, Object> entry : entries) {
String displayName = (String) entry.get("displayName");
String mimeType = (String) entry.get("mimeType");
String oldEntryPath = oldDirPath + displayName;
MediaScannerConnection.scanFile(context, new String[]{oldEntryPath}, new String[]{mimeType}, null);
SettableFuture<Map<String, Object>> scanFuture = SettableFuture.create();
scanFutures.add(scanFuture);
String newEntryPath = newDirPath + displayName;
scanNewPath(context, newEntryPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
entry.putAll(newFields);
entry.put("success", true);
scanFuture.set(entry);
}
@Override
public void onFailure(Throwable throwable) {
Log.w(LOG_TAG, "failed to scan entry=" + displayName + " in new directory=" + newDirPath, throwable);
entry.put("success", false);
scanFuture.set(entry);
}
});
}
try {
callback.onSuccess(Futures.allAsList(scanFutures).get());
} catch (ExecutionException | InterruptedException e) {
callback.onFailure(e);
}
}
private List<Map<String, Object>> listContentEntries(Context context, Uri contentUri, String dirPath) {
List<Map<String, Object>> entries = new ArrayList<>();
String[] projection = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
};
String selection = MediaStore.MediaColumns.DATA + " like ?";
try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[]{dirPath + "%"}, null);
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
while (cursor.moveToNext()) {
entries.add(new HashMap<String, Object>() {{
put("oldContentId", cursor.getInt(idColumn));
put("displayName", cursor.getString(displayNameColumn));
put("mimeType", cursor.getString(mimeTypeColumn));
}});
}
cursor.close();
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to list entries in contentUri=" + contentUri, e);
}
return entries;
}
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
switch (mimeType) { switch (mimeType) {
case MimeTypes.JPEG: case MimeTypes.JPEG:
@ -209,8 +307,8 @@ public abstract class ImageProvider {
} }
// update fields in media store // update fields in media store
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalImage.getHeight(); int rotatedWidth = originalImage.getHeight();
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalImage.getWidth(); int rotatedHeight = originalImage.getWidth();
Map<String, Object> newFields = new HashMap<>(); Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth); newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight); newFields.put("height", rotatedHeight);
@ -239,8 +337,6 @@ public abstract class ImageProvider {
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) { protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
long contentId = 0; long contentId = 0;
Uri contentUri = null; Uri contentUri = null;
if (newUri != null) { if (newUri != null) {
@ -260,7 +356,11 @@ public abstract class ImageProvider {
Map<String, Object> newFields = new HashMap<>(); Map<String, Object> newFields = new HashMap<>();
// we retrieve updated fields as the renamed file became a new entry in the Media Store // we retrieve updated fields as the renamed file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns.TITLE}; String[] projection = {
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.DATE_MODIFIED,
};
try { try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null); Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) { if (cursor != null) {
@ -268,7 +368,9 @@ public abstract class ImageProvider {
newFields.put("uri", contentUri.toString()); newFields.put("uri", contentUri.toString());
newFields.put("contentId", contentId); newFields.put("contentId", contentId);
newFields.put("path", path); newFields.put("path", path);
newFields.put("displayName", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
} }
cursor.close(); cursor.close();
} }
@ -290,4 +392,10 @@ public abstract class ImageProvider {
void onFailure(Throwable throwable); void onFailure(Throwable throwable);
} }
public interface AlbumRenameOpCallback {
void onSuccess(List<Map<String, Object>> fieldsByEntry);
void onFailure(Throwable throwable);
}
} }

View file

@ -180,20 +180,13 @@ public class MediaStoreImageProvider extends ImageProvider {
// they are valid but miss some attributes, such as width, height, orientation // they are valid but miss some attributes, such as width, height, orientation
SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context); SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);
entryMap = entry.toMap(); entryMap = entry.toMap();
width = entry.width != null ? entry.width : 0;
height = entry.height != null ? entry.height : 0;
} }
if ((width <= 0 || height <= 0) && needSize(mimeType)) { newEntryHandler.handleEntry(entryMap);
// this is probably not a real image, like "/storage/emulated/0", so we skip it if (newEntryCount % 30 == 0) {
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType); Thread.sleep(10);
} else {
newEntryHandler.handleEntry(entryMap);
if (newEntryCount % 30 == 0) {
Thread.sleep(10);
}
newEntryCount++;
} }
newEntryCount++;
} }
} }
cursor.close(); cursor.close();

View file

@ -2,13 +2,14 @@ package deckers.thibault.aves.utils;
public class MimeTypes { public class MimeTypes {
public static final String IMAGE = "image"; public static final String IMAGE = "image";
public static final String DNG = "image/x-adobe-dng"; public static final String DNG = "image/x-adobe-dng"; // .dng
public static final String GIF = "image/gif"; public static final String GIF = "image/gif";
public static final String HEIC = "image/heic"; public static final String HEIC = "image/heic";
public static final String HEIF = "image/heif"; public static final String HEIF = "image/heif";
public static final String JPEG = "image/jpeg"; public static final String JPEG = "image/jpeg";
public static final String PNG = "image/png"; public static final String PNG = "image/png";
public static final String SVG = "image/svg+xml"; public static final String PSD = "image/x-photoshop"; // .psd
public static final String SVG = "image/svg+xml"; // .svg
public static final String WEBP = "image/webp"; public static final String WEBP = "image/webp";
public static final String VIDEO = "video"; public static final String VIDEO = "video";

View file

@ -18,14 +18,14 @@ class AlbumFilter extends CollectionFilter {
const AlbumFilter(this.album, this.uniqueName); const AlbumFilter(this.album, this.uniqueName);
AlbumFilter.fromJson(Map<String, dynamic> json) AlbumFilter.fromMap(Map<String, dynamic> json)
: this( : this(
json['album'], json['album'],
json['uniqueName'], json['uniqueName'],
); );
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'album': album, 'album': album,
'uniqueName': uniqueName, 'uniqueName': uniqueName,
@ -41,8 +41,14 @@ class AlbumFilter extends CollectionFilter {
String get tooltip => album; String get tooltip => album;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
return IconUtils.getAlbumIcon(context: context, album: album, size: size) ?? (showGenericIcon ? Icon(AIcons.album, size: size) : null); return IconUtils.getAlbumIcon(
context: context,
album: album,
size: size,
embossed: embossed,
) ??
(showGenericIcon ? Icon(AIcons.album, size: size) : null);
} }
@override @override

View file

@ -8,7 +8,7 @@ class FavouriteFilter extends CollectionFilter {
static const type = 'favourite'; static const type = 'favourite';
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
}; };
@ -19,7 +19,7 @@ class FavouriteFilter extends CollectionFilter {
String get label => 'Favourite'; String get label => 'Favourite';
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size);
@override @override
String get typeKey => type; String get typeKey => type;

View file

@ -27,17 +27,17 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
final type = jsonMap['type']; final type = jsonMap['type'];
switch (type) { switch (type) {
case AlbumFilter.type: case AlbumFilter.type:
return AlbumFilter.fromJson(jsonMap); return AlbumFilter.fromMap(jsonMap);
case FavouriteFilter.type: case FavouriteFilter.type:
return FavouriteFilter(); return FavouriteFilter();
case LocationFilter.type: case LocationFilter.type:
return LocationFilter.fromJson(jsonMap); return LocationFilter.fromMap(jsonMap);
case MimeFilter.type: case MimeFilter.type:
return MimeFilter.fromJson(jsonMap); return MimeFilter.fromMap(jsonMap);
case QueryFilter.type: case QueryFilter.type:
return QueryFilter.fromJson(jsonMap); return QueryFilter.fromMap(jsonMap);
case TagFilter.type: case TagFilter.type:
return TagFilter.fromJson(jsonMap); return TagFilter.fromMap(jsonMap);
} }
debugPrint('failed to parse filter from json=$jsonString'); debugPrint('failed to parse filter from json=$jsonString');
return null; return null;
@ -45,7 +45,9 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
const CollectionFilter(); const CollectionFilter();
Map<String, dynamic> toJson(); Map<String, dynamic> toMap();
String toJson() => jsonEncode(toMap());
bool filter(ImageEntry entry); bool filter(ImageEntry entry);
@ -55,7 +57,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String get tooltip => label; String get tooltip => label;
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(label)); Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(label));

View file

@ -17,29 +17,33 @@ class LocationFilter extends CollectionFilter {
if (split.length > 1) _countryCode = split[1]; if (split.length > 1) _countryCode = split[1];
} }
LocationFilter.fromJson(Map<String, dynamic> json) LocationFilter.fromMap(Map<String, dynamic> json)
: this( : this(
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null), LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null),
json['location'], json['location'],
); );
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'level': level.toString(), 'level': level.toString(),
'location': _countryCode != null ? '$_location$locationSeparator$_countryCode' : _location, 'location': _countryCode != null ? countryNameAndCode : _location,
}; };
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
@override @override
bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location)); bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
@override @override
String get label => _location; String get label => _location;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
final flag = countryCodeToFlag(_countryCode); final flag = countryCodeToFlag(_countryCode);
if (flag != null) return Text(flag, style: TextStyle(fontSize: size)); // as of Flutter v1.22.0-12.1.pre emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
return Icon(AIcons.location, size: size); return Icon(AIcons.location, size: size);
} }

View file

@ -38,19 +38,27 @@ class MimeFilter extends CollectionFilter {
_icon ??= AIcons.vector; _icon ??= AIcons.vector;
} }
MimeFilter.fromJson(Map<String, dynamic> json) MimeFilter.fromMap(Map<String, dynamic> json)
: this( : this(
json['mime'], json['mime'],
); );
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'mime': mime, 'mime': mime,
}; };
static String displayType(String mime) { static String displayType(String mime) {
return mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', ''); final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE[-\.]'), // for DNG, PSD...
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
return mime;
} }
@override @override
@ -60,7 +68,7 @@ class MimeFilter extends CollectionFilter {
String get label => _label; String get label => _label;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
@override @override
String get typeKey => type; String get typeKey => type;

View file

@ -32,13 +32,13 @@ class QueryFilter extends CollectionFilter {
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
} }
QueryFilter.fromJson(Map<String, dynamic> json) QueryFilter.fromMap(Map<String, dynamic> json)
: this( : this(
json['query'], json['query'],
); );
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'query': query, 'query': query,
}; };
@ -53,7 +53,7 @@ class QueryFilter extends CollectionFilter {
String get label => '$query'; String get label => '$query';
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size);
@override @override
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white); Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white);

View file

@ -10,13 +10,13 @@ class TagFilter extends CollectionFilter {
const TagFilter(this.tag); const TagFilter(this.tag);
TagFilter.fromJson(Map<String, dynamic> json) TagFilter.fromMap(Map<String, dynamic> json)
: this( : this(
json['tag'], json['tag'],
); );
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'tag': tag, 'tag': tag,
}; };
@ -31,7 +31,7 @@ class TagFilter extends CollectionFilter {
String get label => tag; String get label => tag;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(AIcons.tag, size: size) : null; Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(AIcons.tag, size: size) : null;
@override @override
String get typeKey => type; String get typeKey => type;

View file

@ -7,6 +7,7 @@ import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
@ -25,7 +26,7 @@ class ImageEntry {
int orientationDegrees; int orientationDegrees;
final int sizeBytes; final int sizeBytes;
String sourceTitle; String sourceTitle;
final int dateModifiedSecs; int _dateModifiedSecs;
final int sourceDateTakenMillis; final int sourceDateTakenMillis;
final int durationMillis; final int durationMillis;
int _catalogDateMillis; int _catalogDateMillis;
@ -44,18 +45,20 @@ class ImageEntry {
this.orientationDegrees, this.orientationDegrees,
this.sizeBytes, this.sizeBytes,
this.sourceTitle, this.sourceTitle,
this.dateModifiedSecs, int dateModifiedSecs,
this.sourceDateTakenMillis, this.sourceDateTakenMillis,
this.durationMillis, this.durationMillis,
}) : assert(width != null), }) : assert(width != null),
assert(height != null) { assert(height != null) {
this.path = path; this.path = path;
this.dateModifiedSecs = dateModifiedSecs;
} }
ImageEntry copyWith({ ImageEntry copyWith({
@required String uri, @required String uri,
@required String path, @required String path,
@required int contentId, @required int contentId,
@required int dateModifiedSecs,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = ImageEntry( final copied = ImageEntry(
@ -202,6 +205,13 @@ class ImageEntry {
return _bestDate; return _bestDate;
} }
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
_dateModifiedSecs = dateModifiedSecs;
_bestDate = null;
}
DateTime get monthTaken { DateTime get monthTaken {
final d = bestDate; final d = bestDate;
return d == null ? null : DateTime(d.year, d.month); return d == null ? null : DateTime(d.year, d.month);
@ -386,4 +396,19 @@ class ImageEntry {
favourites.remove([this]); favourites.remove([this]);
} }
} }
static int compareByName(ImageEntry a, ImageEntry b) {
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
}
static int compareBySize(ImageEntry a, ImageEntry b) {
final c = b.sizeBytes.compareTo(a.sizeBytes);
return c != 0 ? c : compareByName(a, b);
}
static int compareByDate(ImageEntry a, ImageEntry b) {
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
return c != 0 ? c : compareByName(a, b);
}
} }

View file

@ -131,6 +131,14 @@ class MetadataDb {
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
} }
Future<void> updateEntryId(int oldId, ImageEntry entry) async {
final db = await _database;
final batch = db.batch();
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertEntry(batch, entry);
await batch.commit(noResult: true);
}
void _batchInsertEntry(Batch batch, ImageEntry entry) { void _batchInsertEntry(Batch batch, ImageEntry entry) {
if (entry == null) return; if (entry == null) return;
batch.insert( batch.insert(

View file

@ -0,0 +1,22 @@
import 'package:screen/screen.dart';
enum KeepScreenOn { never, fullscreenOnly, always }
extension ExtraKeepScreenOn on KeepScreenOn {
String get name {
switch (this) {
case KeepScreenOn.never:
return 'Never';
case KeepScreenOn.fullscreenOnly:
return 'Viewer page only';
case KeepScreenOn.always:
return 'Always';
default:
return toString();
}
}
void apply() {
Screen.keepOn(this == KeepScreenOn.always);
}
}

View file

@ -1,5 +1,7 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
@ -23,6 +25,7 @@ class Settings extends ChangeNotifier {
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page'; static const homePageKey = 'home_page';
static const catalogTimeZoneKey = 'catalog_time_zone'; static const catalogTimeZoneKey = 'catalog_time_zone';
@ -30,11 +33,15 @@ class Settings extends ChangeNotifier {
static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionTileExtentKey = 'collection_tile_extent'; static const collectionTileExtentKey = 'collection_tile_extent';
static const showThumbnailLocationKey = 'show_thumbnail_location';
static const showThumbnailRawKey = 'show_thumbnail_raw';
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
// filter grids // filter grids
static const albumSortFactorKey = 'album_sort_factor'; static const albumSortFactorKey = 'album_sort_factor';
static const countrySortFactorKey = 'country_sort_factor'; static const countrySortFactorKey = 'country_sort_factor';
static const tagSortFactorKey = 'tag_sort_factor'; static const tagSortFactorKey = 'tag_sort_factor';
static const pinnedFiltersKey = 'pinned_filters';
// info // info
static const infoMapStyleKey = 'info_map_style'; static const infoMapStyleKey = 'info_map_style';
@ -75,6 +82,13 @@ class Settings extends ChangeNotifier {
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue); set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, KeepScreenOn.fullscreenOnly, KeepScreenOn.values);
set keepScreenOn(KeepScreenOn newValue) {
setAndNotify(keepScreenOnKey, newValue.toString());
newValue.apply();
}
HomePageSetting get homePage => getEnumOrDefault(homePageKey, HomePageSetting.collection, HomePageSetting.values); HomePageSetting get homePage => getEnumOrDefault(homePageKey, HomePageSetting.collection, HomePageSetting.values);
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString()); set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
@ -97,6 +111,18 @@ class Settings extends ChangeNotifier {
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue); set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, true);
set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue);
bool get showThumbnailVideoDuration => getBoolOrDefault(showThumbnailVideoDurationKey, true);
set showThumbnailVideoDuration(bool newValue) => setAndNotify(showThumbnailVideoDurationKey, newValue);
// filter grids // filter grids
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values); ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values);
@ -111,6 +137,10 @@ class Settings extends ChangeNotifier {
set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString()); set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString());
Set<CollectionFilter> get pinnedFilters => (_prefs.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
// info // info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);

View file

@ -9,13 +9,18 @@ mixin AlbumMixin on SourceBase {
List<String> sortedAlbums = List.unmodifiable([]); List<String> sortedAlbums = List.unmodifiable([]);
int compareAlbumsByName(String a, String b) {
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
final c = compareAsciiUpperCase(ua, ub);
if (c != 0) return c;
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
return compareAsciiUpperCase(va, vb);
}
void updateAlbums() { void updateAlbums() {
final sorted = _folderPaths.toList() final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
..sort((a, b) {
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
});
sortedAlbums = List.unmodifiable(sorted); sortedAlbums = List.unmodifiable(sorted);
invalidateFilterEntryCounts(); invalidateFilterEntryCounts();
eventBus.fire(AlbumsChangedEvent()); eventBus.fire(AlbumsChangedEvent());

View file

@ -7,7 +7,6 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -137,16 +136,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
void _applySort() { void _applySort() {
switch (sortFactor) { switch (sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
_filteredEntries.sort((a, b) { _filteredEntries.sort(ImageEntry.compareByDate);
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.bestTitle, b.bestTitle);
});
break; break;
case EntrySortFactor.size: case EntrySortFactor.size:
_filteredEntries.sort((a, b) => b.sizeBytes.compareTo(a.sizeBytes)); _filteredEntries.sort(ImageEntry.compareBySize);
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
_filteredEntries.sort((a, b) => compareAsciiUpperCase(a.bestTitle, b.bestTitle)); _filteredEntries.sort(ImageEntry.compareByName);
break; break;
} }
} }
@ -178,16 +174,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory); final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
int compare(a, b) { sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName);
final ua = source.getUniqueAlbumName(a);
final ub = source.getUniqueAlbumName(b);
final c = compareAsciiUpperCase(ua, ub);
if (c != 0) return c;
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
return compareAsciiUpperCase(va, vb);
}
sections = SplayTreeMap.of(byAlbum, compare);
break; break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
@ -87,7 +88,23 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
invalidateFilterEntryCounts(); invalidateFilterEntryCounts();
} }
void applyMove({ Future<void> moveEntry(ImageEntry entry, Map newFields) async {
final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int;
entry.uri = newFields['uri'] as String;
entry.path = newFields['path'] as String;
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.contentId = newContentId;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.move(oldContentId, entry);
}
void updateAfterMove({
@required Iterable<ImageEntry> entries, @required Iterable<ImageEntry> entries,
@required Set<String> fromAlbums, @required Set<String> fromAlbums,
@required String toAlbum, @required String toAlbum,

View file

@ -76,9 +76,13 @@ mixin LocationMixin on SourceBase {
void updateLocations() { void updateLocations() {
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
List<String> lister(String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
sortedCountries = lister((address) => '${address.countryName}${LocationFilter.locationSeparator}${address.countryCode}');
sortedPlaces = lister((address) => address.place); // the same country code could be found with different country names
// e.g. if the locale changed between geolocating calls
// so we merge countries by code, keeping only one name for each code
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
invalidateFilterEntryCounts(); invalidateFilterEntryCounts();
eventBus.fire(LocationsChangedEvent()); eventBus.fire(LocationsChangedEvent());

View file

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -28,7 +26,7 @@ class AppShortcutService {
try { try {
await platform.invokeMethod('pin', <String, dynamic>{ await platform.invokeMethod('pin', <String, dynamic>{
'label': label, 'label': label,
'filters': filters.map((filter) => jsonEncode(filter.toJson())).toList(), 'filters': filters.map((filter) => filter.toJson()).toList(),
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');

View file

@ -40,7 +40,7 @@ class ImageFileService {
} }
} }
static Future<List> getObsoleteEntries(List<int> knownContentIds) async { static Future<List<int>> getObsoleteEntries(List<int> knownContentIds) async {
try { try {
final result = await platform.invokeMethod('getObsoleteEntries', <String, dynamic>{ final result = await platform.invokeMethod('getObsoleteEntries', <String, dynamic>{
'knownContentIds': knownContentIds, 'knownContentIds': knownContentIds,
@ -194,6 +194,19 @@ class ImageFileService {
} }
return {}; return {};
} }
static Future<List<Map>> renameDirectory(String path, String newName) async {
try {
final result = await platform.invokeMethod('renameDirectory', <String, dynamic>{
'path': path,
'newName': newName,
});
return (result as List).cast<Map>();
} on PlatformException catch (e) {
debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
} }
@immutable @immutable

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -86,4 +88,28 @@ class MetadataService {
} }
return {}; return {};
} }
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<List<Uint8List>> getXmpThumbnails(String uri) async {
try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
} }

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:tuple/tuple.dart';
class Constants { class Constants {
// as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
@ -12,6 +13,13 @@ class Constants {
fontFamily: 'Concourse Caps', fontFamily: 'Concourse Caps',
); );
static const embossShadow = Shadow(
color: Colors.black87,
offset: Offset(0.5, 1.0),
);
static const pointNemo = Tuple2(-48.876667, -123.393333);
static const List<Dependency> androidDependencies = [ static const List<Dependency> androidDependencies = [
Dependency( Dependency(
name: 'CWAC-Document', name: 'CWAC-Document',
@ -58,6 +66,12 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE', licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/collection', sourceUrl: 'https://github.com/dart-lang/collection',
), ),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency( Dependency(
name: 'Draggable Scrollbar', name: 'Draggable Scrollbar',
license: 'MIT', license: 'MIT',
@ -77,10 +91,10 @@ class Constants {
sourceUrl: 'https://github.com/Skylled/expansion_tile_card', sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
), ),
Dependency( Dependency(
name: 'Firebase Crashlytics', name: 'FlutterFire',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/packages/firebase_crashlytics/LICENSE', licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire/tree/master/packages/firebase_crashlytics', sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
), ),
Dependency( Dependency(
name: 'Flushbar', name: 'Flushbar',
@ -94,6 +108,12 @@ class Constants {
licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE', licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE',
sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer', sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer',
), ),
Dependency(
name: 'Flutter Map',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
sourceUrl: 'https://github.com/fleaflet/flutter_map',
),
Dependency( Dependency(
name: 'Flutter Markdown', name: 'Flutter Markdown',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
@ -137,10 +157,22 @@ class Constants {
sourceUrl: 'https://github.com/dart-lang/intl', sourceUrl: 'https://github.com/dart-lang/intl',
), ),
Dependency( Dependency(
name: 'Outline Material Icons', name: 'LatLong',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/lucaslcode/outline_material_icons/blob/master/LICENSE', licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/lucaslcode/outline_material_icons', sourceUrl: 'https://github.com/MikeMitterer/dart-latlong',
),
Dependency(
name: 'Overlay Support',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support',
),
Dependency(
name: 'Package info',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info',
), ),
Dependency( Dependency(
name: 'Palette Generator', name: 'Palette Generator',
@ -220,6 +252,12 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE', licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/tuple', sourceUrl: 'https://github.com/dart-lang/tuple',
), ),
Dependency(
name: 'URL Launcher',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
),
Dependency( Dependency(
name: 'UUID', name: 'UUID',
license: 'MIT', license: 'MIT',

View file

@ -8,6 +8,7 @@ class Durations {
static const sweepingAnimation = Duration(milliseconds: 650); static const sweepingAnimation = Duration(milliseconds: 650);
static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration
static const staggeredAnimation = Duration(milliseconds: 375); static const staggeredAnimation = Duration(milliseconds: 375);
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
// collection animations // collection animations
static const appBarTitleAnimation = Duration(milliseconds: 300); static const appBarTitleAnimation = Duration(milliseconds: 300);
@ -32,4 +33,5 @@ class Durations {
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
} }

View file

@ -10,6 +10,7 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/collection/collection_actions.dart'; import 'package:aves/widgets/collection/collection_actions.dart';
import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/collection/search/search_delegate.dart'; import 'package:aves/widgets/collection/search/search_delegate.dart';
import 'package:aves/widgets/common/action_delegates/add_shortcut_dialog.dart';
import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar_title.dart';
@ -18,7 +19,7 @@ import 'package:aves/widgets/common/data_providers/media_store_collection_provid
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/filter_grids/search_button.dart'; import 'package:aves/widgets/common/search_button.dart';
import 'package:aves/widgets/stats/stats.dart'; import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -306,7 +307,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_goToStats(); _goToStats();
break; break;
case CollectionAction.addShortcut: case CollectionAction.addShortcut:
unawaited(AppShortcutService.pin('Collection', collection.filters)); unawaited(_showShortcutDialog(context));
break; break;
case CollectionAction.group: case CollectionAction.group:
final value = await showDialog<EntryGroupFactor>( final value = await showDialog<EntryGroupFactor>(
@ -348,6 +349,16 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
Future<void> _showShortcutDialog(BuildContext context) async {
final name = await showDialog<String>(
context: context,
builder: (context) => AddShortcutDialog(collection.filters),
);
if (name == null || name.isEmpty) return;
unawaited(AppShortcutService.pin(name, collection.filters));
}
void _goToSearch() { void _goToSearch() {
Navigator.push( Navigator.push(
context, context,

View file

@ -13,7 +13,7 @@ class EmptyContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const color = Color(0xFF607D8B); const color = Colors.blueGrey;
return Align( return Align(
alignment: alignment, alignment: alignment,
child: Column( child: Column(

View file

@ -108,7 +108,7 @@ class _FilterBarState extends State<FilterBar> {
filter: filter, filter: filter,
removable: true, removable: true,
heroType: HeroType.always, heroType: HeroType.always,
onPressed: (filter) { onTap: (filter) {
_userRemovedFilter = filter; _userRemovedFilter = filter;
widget.onPressed(filter); widget.onPressed(filter);
}, },

View file

@ -10,14 +10,14 @@ class ExpandableFilterRow extends StatelessWidget {
final Iterable<CollectionFilter> filters; final Iterable<CollectionFilter> filters;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
final HeroType Function(CollectionFilter filter) heroTypeBuilder; final HeroType Function(CollectionFilter filter) heroTypeBuilder;
final FilterCallback onPressed; final FilterCallback onTap;
const ExpandableFilterRow({ const ExpandableFilterRow({
this.title, this.title,
@required this.filters, @required this.filters,
this.expandedNotifier, this.expandedNotifier,
this.heroTypeBuilder, this.heroTypeBuilder,
@required this.onPressed, @required this.onTap,
}); });
static const double horizontalPadding = 8; static const double horizontalPadding = 8;
@ -107,7 +107,7 @@ class ExpandableFilterRow extends StatelessWidget {
key: Key(filter.key), key: Key(filter.key),
filter: filter, filter: filter,
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
onPressed: onPressed, onTap: onTap,
); );
} }
} }

View file

@ -138,7 +138,7 @@ class ImageSearchDelegate {
filters: filters, filters: filters,
expandedNotifier: expandedSectionNotifier, expandedNotifier: expandedSectionNotifier,
heroTypeBuilder: heroTypeBuilder, heroTypeBuilder: heroTypeBuilder,
onPressed: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter), onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter),
); );
} }

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
@ -8,6 +9,7 @@ import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ThumbnailEntryOverlay extends StatelessWidget { class ThumbnailEntryOverlay extends StatelessWidget {
final ImageEntry entry; final ImageEntry entry;
@ -23,26 +25,32 @@ class ThumbnailEntryOverlay extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fontSize = min(14.0, (extent / 8)).roundToDouble(); final fontSize = min(14.0, (extent / 8)).roundToDouble();
final iconSize = fontSize * 2; final iconSize = fontSize * 2;
return Column( return Selector<Settings, Tuple3<bool, bool, bool>>(
mainAxisSize: MainAxisSize.min, selector: (context, s) => Tuple3(s.showThumbnailLocation, s.showThumbnailRaw, s.showThumbnailVideoDuration),
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, s, child) {
children: [ return Column(
if (entry.hasGps) GpsIcon(iconSize: iconSize), mainAxisSize: MainAxisSize.min,
if (entry.isAnimated) crossAxisAlignment: CrossAxisAlignment.start,
AnimatedImageIcon(iconSize: iconSize) children: [
else if (entry.isVideo) if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
DefaultTextStyle( if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
style: TextStyle( if (entry.isAnimated)
color: Colors.grey[200], AnimatedImageIcon(iconSize: iconSize)
fontSize: fontSize, else if (entry.isVideo)
), DefaultTextStyle(
child: VideoIcon( style: TextStyle(
entry: entry, color: Colors.grey[200],
iconSize: iconSize, fontSize: fontSize,
), ),
), child: VideoIcon(
], entry: entry,
); iconSize: iconSize,
showDuration: settings.showThumbnailVideoDuration,
),
),
],
);
});
} }
} }

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/transition_image.dart'; import 'package:aves/widgets/common/transition_image.dart';
@ -91,6 +92,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
? fastImage ? fastImage
: Image( : Image(
key: ValueKey('HQ'), key: ValueKey('HQ'),
image: _sizedThumbnailProvider,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child; if (wasSynchronouslyLoaded) return child;
return AnimatedSwitcher( return AnimatedSwitcher(
@ -111,7 +113,17 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
child: frame == null ? fastImage : child, child: frame == null ? fastImage : child,
); );
}, },
image: _sizedThumbnailProvider, errorBuilder: (context, error, stackTrace) => Center(
child: Tooltip(
message: error.toString(),
preferBelow: false,
child: Icon(
AIcons.error,
size: extent / 2,
color: Colors.blueGrey,
),
),
),
width: extent, width: extent,
height: extent, height: extent,
fit: BoxFit.cover, fit: BoxFit.cover,

View file

@ -0,0 +1,74 @@
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/material.dart';
import '../aves_dialog.dart';
class AddShortcutDialog extends StatefulWidget {
final Set<CollectionFilter> filters;
const AddShortcutDialog(this.filters);
@override
_AddShortcutDialogState createState() => _AddShortcutDialogState();
}
class _AddShortcutDialogState extends State<AddShortcutDialog> {
final TextEditingController _nameController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
@override
void initState() {
super.initState();
final filters = List.from(widget.filters)..sort();
if (filters.isEmpty) {
_nameController.text = 'Collection';
} else {
_nameController.text = filters.first.label;
}
_validate();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AvesDialog(
content: TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Shortcut label',
),
autofocus: true,
maxLength: 10,
onChanged: (_) => _validate(),
onSubmitted: (_) => _submit(context),
),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Add'.toUpperCase()),
);
},
)
],
);
}
Future<void> _validate() async {
final name = _nameController.text ?? '';
_isValidNotifier.value = name.isNotEmpty;
}
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
}

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/durations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -13,7 +14,9 @@ class CreateAlbumDialog extends StatefulWidget {
} }
class _CreateAlbumDialogState extends State<CreateAlbumDialog> { class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final FocusNode _nameFieldFocusNode = FocusNode();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false); final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
Set<StorageVolume> _allVolumes; Set<StorageVolume> _allVolumes;
@ -25,11 +28,13 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
_allVolumes = androidFileUtils.storageVolumes; _allVolumes = androidFileUtils.storageVolumes;
_primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first); _primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first);
_selectedVolume = _primaryVolume; _selectedVolume = _primaryVolume;
_nameFieldFocusNode.addListener(_onFocus);
} }
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_nameFieldFocusNode.removeListener(_onFocus);
super.dispose(); super.dispose();
} }
@ -37,6 +42,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AvesDialog( return AvesDialog(
title: 'New Album', title: 'New Album',
scrollController: _scrollController,
scrollableContent: [ scrollableContent: [
if (_allVolumes.length > 1) ...[ if (_allVolumes.length > 1) ...[
Padding( Padding(
@ -73,9 +79,10 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
builder: (context, exists, child) { builder: (context, exists, child) {
return TextField( return TextField(
controller: _nameController, controller: _nameController,
focusNode: _nameFieldFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Album name',
helperText: exists ? 'Album already exists' : '', helperText: exists ? 'Album already exists' : '',
hintText: 'Album name',
), ),
autofocus: _allVolumes.length == 1, autofocus: _allVolumes.length == 1,
onChanged: (_) => _validate(), onChanged: (_) => _validate(),
@ -102,15 +109,34 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
); );
} }
void _onFocus() async {
// when the field gets focus, we wait for the soft keyboard to appear
// then scroll to the bottom to make sure the field is in view
if (_nameFieldFocusNode.hasFocus) {
await Future.delayed(Durations.softKeyboardDisplayDelay);
_scrollToBottom();
}
}
void _scrollToBottom() {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: Durations.dialogFieldReachAnimation,
curve: Curves.easeInOut,
);
}
String _buildAlbumPath(String name) { String _buildAlbumPath(String name) {
if (name == null || name.isEmpty) return ''; if (name == null || name.isEmpty) return '';
return join(_selectedVolume.path, 'Pictures', name); return join(_selectedVolume.path, 'Pictures', name);
} }
Future<void> _validate() async { Future<void> _validate() async {
final path = _buildAlbumPath(_nameController.text); final newName = _nameController.text ?? '';
_existsNotifier.value = path.isEmpty ? false : await Directory(path).exists(); final path = _buildAlbumPath(newName);
_isValidNotifier.value = (_nameController.text ?? '').isNotEmpty; final exists = newName.isNotEmpty && await Directory(path).exists();
_existsNotifier.value = exists;
_isValidNotifier.value = newName.isNotEmpty;
} }
void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text)); void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text));

View file

@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as path;
import '../aves_dialog.dart';
class RenameAlbumDialog extends StatefulWidget {
final String album;
const RenameAlbumDialog(this.album);
@override
_RenameAlbumDialogState createState() => _RenameAlbumDialogState();
}
class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
final TextEditingController _nameController = TextEditingController();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
String get album => widget.album;
String get initialValue => path.basename(album);
@override
void initState() {
super.initState();
_nameController.text = initialValue;
_validate();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AvesDialog(
content: ValueListenableBuilder<bool>(
valueListenable: _existsNotifier,
builder: (context, exists, child) {
return TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'New name',
helperText: exists ? 'Album already exists' : '',
),
autofocus: true,
onChanged: (_) => _validate(),
onSubmitted: (_) => _submit(context),
);
}),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Apply'.toUpperCase()),
);
},
)
],
);
}
String _buildAlbumPath(String name) {
if (name == null || name.isEmpty) return '';
return path.join(path.dirname(album), name);
}
Future<void> _validate() async {
final newName = _nameController.text ?? '';
final path = _buildAlbumPath(newName);
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
_existsNotifier.value = exists && newName != initialValue;
_isValidNotifier.value = newName.isNotEmpty && !exists;
}
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
}

View file

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart'; import 'package:path/path.dart' as path;
import '../aves_dialog.dart'; import '../aves_dialog.dart';
@ -25,6 +25,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
void initState() { void initState() {
super.initState(); super.initState();
_nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle; _nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle;
_validate();
} }
@override @override
@ -38,6 +39,10 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
return AvesDialog( return AvesDialog(
content: TextField( content: TextField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration(
labelText: 'New name',
suffixText: entry.extension,
),
autofocus: true, autofocus: true,
onChanged: (_) => _validate(), onChanged: (_) => _validate(),
onSubmitted: (_) => _submit(context), onSubmitted: (_) => _submit(context),
@ -60,13 +65,17 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
); );
} }
String _buildEntryPath(String name) {
if (name == null || name.isEmpty) return '';
return path.join(entry.directory, name + entry.extension);
}
Future<void> _validate() async { Future<void> _validate() async {
var newName = _nameController.text ?? ''; final newName = _nameController.text ?? '';
if (newName.isNotEmpty) { final path = _buildEntryPath(newName);
newName += entry.extension; final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
} debugPrint('TLAD path=$path exists=$exists');
final type = await FileSystemEntity.type(join(entry.directory, newName)); _isValidNotifier.value = newName.isNotEmpty && !exists;
_isValidNotifier.value = type == FileSystemEntityType.notFound;
} }
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);

View file

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
@ -18,7 +17,7 @@ import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -96,7 +95,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
icon: AIcons.album, icon: AIcons.album,
text: 'No albums', text: 'No albums',
), ),
onPressed: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album), onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
); );
}, },
), ),
@ -136,8 +135,10 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
uri: newFields['uri'] as String, uri: newFields['uri'] as String,
path: newFields['path'] as String, path: newFields['path'] as String,
contentId: newFields['contentId'] as int, contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
)); ));
}); });
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata)); await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails)); await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else { } else {
@ -147,20 +148,12 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) { if (entry != null) {
fromAlbums.add(entry.directory); fromAlbums.add(entry.directory);
final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int;
entry.uri = newFields['uri'] as String;
entry.path = newFields['path'] as String;
entry.contentId = newContentId;
movedEntries.add(entry); movedEntries.add(entry);
await source.moveEntry(entry, newFields);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.move(oldContentId, entry);
} }
}); });
} }
source.applyMove( source.updateAfterMove(
entries: movedEntries, entries: movedEntries,
fromAlbums: fromAlbums, fromAlbums: fromAlbums,
toAlbum: destinationAlbum, toAlbum: destinationAlbum,

View file

@ -6,6 +6,7 @@ class AvesDialog extends AlertDialog {
AvesDialog({ AvesDialog({
String title, String title,
ScrollController scrollController,
List<Widget> scrollableContent, List<Widget> scrollableContent,
Widget content, Widget content,
@required List<Widget> actions, @required List<Widget> actions,
@ -31,6 +32,7 @@ class AvesDialog extends AlertDialog {
), ),
), ),
child: ListView( child: ListView(
controller: scrollController ?? ScrollController(),
shrinkWrap: true, shrinkWrap: true,
children: scrollableContent, children: scrollableContent,
), ),
@ -38,7 +40,7 @@ class AvesDialog extends AlertDialog {
), ),
) )
: content, : content,
contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 24), contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 0),
actions: actions, actions: actions,
actionsPadding: EdgeInsets.symmetric(horizontal: 8), actionsPadding: EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View file

@ -1,8 +1,10 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
typedef FilterCallback = void Function(CollectionFilter filter); typedef FilterCallback = void Function(CollectionFilter filter);
typedef OffsetFilterCallback = void Function(CollectionFilter filter, Offset tapPosition);
enum HeroType { always, onTap, never } enum HeroType { always, onTap, never }
@ -13,7 +15,8 @@ class AvesFilterChip extends StatefulWidget {
final Widget background; final Widget background;
final Widget details; final Widget details;
final HeroType heroType; final HeroType heroType;
final FilterCallback onPressed; final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
static final BorderRadius borderRadius = BorderRadius.circular(32); static final BorderRadius borderRadius = BorderRadius.circular(32);
static const double outlineWidth = 2; static const double outlineWidth = 2;
@ -31,7 +34,8 @@ class AvesFilterChip extends StatefulWidget {
this.background, this.background,
this.details, this.details,
this.heroType = HeroType.onTap, this.heroType = HeroType.onTap,
@required this.onPressed, @required this.onTap,
this.onLongPress,
}) : super(key: key); }) : super(key: key);
@override @override
@ -42,6 +46,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
Future<Color> _colorFuture; Future<Color> _colorFuture;
Color _outlineColor; Color _outlineColor;
bool _tapped; bool _tapped;
Offset _tapPosition;
CollectionFilter get filter => widget.filter; CollectionFilter get filter => widget.filter;
@ -75,7 +80,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasBackground = widget.background != null; final hasBackground = widget.background != null;
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon); final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null; final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null;
Widget content = Row( Widget content = Row(
@ -122,12 +127,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
color: Colors.black54, color: Colors.black54,
child: DefaultTextStyle( child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith( style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: [ shadows: [Constants.embossShadow],
Shadow(
color: Colors.black87,
offset: Offset(0.5, 1.0),
)
],
), ),
child: content, child: content,
), ),
@ -160,12 +160,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: InkWell( child: InkWell(
onTap: widget.onPressed != null onTapDown: (details) => _tapPosition = details.globalPosition,
onTap: widget.onTap != null
? () { ? () {
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onPressed(filter)); WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter));
setState(() => _tapped = true); setState(() => _tapped = true);
} }
: null, : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null,
borderRadius: borderRadius, borderRadius: borderRadius,
child: FutureBuilder<Color>( child: FutureBuilder<Color>(
future: _colorFuture, future: _colorFuture,

View file

@ -3,14 +3,18 @@ import 'package:flutter/widgets.dart';
import 'aves_dialog.dart'; import 'aves_dialog.dart';
typedef TextBuilder<T> = String Function(T value);
class AvesSelectionDialog<T> extends StatefulWidget { class AvesSelectionDialog<T> extends StatefulWidget {
final T initialValue; final T initialValue;
final Map<T, String> options; final Map<T, String> options;
final TextBuilder<T> optionSubtitleBuilder;
final String title; final String title;
const AvesSelectionDialog({ const AvesSelectionDialog({
@required this.initialValue, @required this.initialValue,
@required this.options, @required this.options,
this.optionSubtitleBuilder,
@required this.title, @required this.title,
}); });
@ -41,20 +45,30 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
); );
} }
Widget _buildRadioListTile(T value, String title) => RadioListTile<T>( Widget _buildRadioListTile(T value, String title) {
key: Key(value.toString()), return RadioListTile<T>(
value: value, key: Key(value.toString()),
groupValue: _selectedValue, value: value,
onChanged: (v) { groupValue: _selectedValue,
_selectedValue = v; onChanged: (v) {
Navigator.pop(context, _selectedValue); _selectedValue = v;
setState(() {}); Navigator.pop(context, _selectedValue);
}, setState(() {});
title: Text( },
title, title: Text(
softWrap: false, title,
overflow: TextOverflow.fade, softWrap: false,
maxLines: 1, overflow: TextOverflow.fade,
), maxLines: 1,
); ),
subtitle: widget.optionSubtitleBuilder != null
? Text(
widget.optionSubtitleBuilder(value),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
)
: null,
);
}
} }

View file

@ -2,60 +2,64 @@ import 'dart:ui';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart'; import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class AIcons { class AIcons {
static const IconData allCollection = OMIcons.collections; static const IconData allCollection = Icons.collections_outlined;
static const IconData image = OMIcons.photo; static const IconData image = Icons.photo_outlined;
static const IconData video = OMIcons.movie; static const IconData video = Icons.movie_outlined;
static const IconData vector = OMIcons.code; static const IconData vector = Icons.code_outlined;
static const IconData checked = OMIcons.done; static const IconData android = Icons.android;
static const IconData date = OMIcons.calendarToday; static const IconData checked = Icons.done_outlined;
static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record; static const IconData disc = Icons.fiber_manual_record;
static const IconData error = OMIcons.errorOutline; static const IconData error = Icons.error_outline;
static const IconData location = OMIcons.place; static const IconData location = Icons.place_outlined;
static const IconData shooting = OMIcons.camera; static const IconData raw = Icons.camera_outlined;
static const IconData removableStorage = OMIcons.sdStorage; static const IconData shooting = Icons.camera_outlined;
static const IconData settings = OMIcons.settings; static const IconData removableStorage = Icons.sd_storage_outlined;
static const IconData text = OMIcons.formatQuote; static const IconData settings = Icons.settings_outlined;
static const IconData tag = OMIcons.localOffer; static const IconData text = Icons.format_quote_outlined;
static const IconData tag = Icons.local_offer_outlined;
// actions // actions
static const IconData addShortcut = OMIcons.bookmarkBorder; static const IconData addShortcut = Icons.bookmark_border;
static const IconData clear = OMIcons.clear; static const IconData clear = Icons.clear_outlined;
static const IconData collapse = OMIcons.expandLess; static const IconData collapse = Icons.expand_less_outlined;
static const IconData createAlbum = OMIcons.addCircleOutline; static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = OMIcons.whatshot; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = OMIcons.delete; static const IconData delete = Icons.delete_outlined;
static const IconData expand = OMIcons.expandMore; static const IconData expand = Icons.expand_more_outlined;
static const IconData favourite = OMIcons.favoriteBorder; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = OMIcons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = OMIcons.arrowUpward; static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = OMIcons.groupWork; static const IconData group = Icons.group_work_outlined;
static const IconData info = OMIcons.info; static const IconData info = Icons.info_outlined;
static const IconData layers = OMIcons.layers; static const IconData layers = Icons.layers_outlined;
static const IconData openInNew = OMIcons.openInNew; static const IconData openInNew = Icons.open_in_new_outlined;
static const IconData print = OMIcons.print; static const IconData pin = Icons.push_pin_outlined;
static const IconData refresh = OMIcons.refresh; static const IconData print = Icons.print_outlined;
static const IconData rename = OMIcons.title; static const IconData refresh = Icons.refresh_outlined;
static const IconData rotateLeft = OMIcons.rotateLeft; static const IconData rename = Icons.title_outlined;
static const IconData rotateRight = OMIcons.rotateRight; static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData search = OMIcons.search; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData select = OMIcons.selectAll; static const IconData search = Icons.search_outlined;
static const IconData share = OMIcons.share; static const IconData select = Icons.select_all_outlined;
static const IconData sort = OMIcons.sort; static const IconData share = Icons.share_outlined;
static const IconData stats = OMIcons.pieChart; static const IconData sort = Icons.sort_outlined;
static const IconData zoomIn = OMIcons.add; static const IconData stats = Icons.pie_chart_outlined;
static const IconData zoomOut = OMIcons.remove; static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined;
// albums // albums
static const IconData album = OMIcons.photoAlbum; static const IconData album = Icons.photo_album_outlined;
static const IconData cameraAlbum = OMIcons.photoCamera; static const IconData cameraAlbum = Icons.photo_camera_outlined;
static const IconData downloadAlbum = Icons.file_download; static const IconData downloadAlbum = Icons.file_download;
static const IconData screenshotAlbum = OMIcons.smartphone; static const IconData screenshotAlbum = Icons.smartphone_outlined;
// thumbnail overlay // thumbnail overlay
static const IconData animated = Icons.slideshow; static const IconData animated = Icons.slideshow;
@ -67,15 +71,21 @@ class AIcons {
class VideoIcon extends StatelessWidget { class VideoIcon extends StatelessWidget {
final ImageEntry entry; final ImageEntry entry;
final double iconSize; final double iconSize;
final bool showDuration;
const VideoIcon({Key key, this.entry, this.iconSize}) : super(key: key); const VideoIcon({
Key key,
this.entry,
this.iconSize,
this.showDuration,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.play, icon: AIcons.play,
size: iconSize, size: iconSize,
text: entry.durationText, text: showDuration ? entry.durationText : null,
); );
} }
} }
@ -90,7 +100,7 @@ class AnimatedImageIcon extends StatelessWidget {
return OverlayIcon( return OverlayIcon(
icon: AIcons.animated, icon: AIcons.animated,
size: iconSize, size: iconSize,
iconSize: iconSize * .8, iconScale: .8,
); );
} }
} }
@ -109,29 +119,47 @@ class GpsIcon extends StatelessWidget {
} }
} }
class RawIcon extends StatelessWidget {
final double iconSize;
const RawIcon({Key key, this.iconSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.raw,
size: iconSize,
);
}
}
class OverlayIcon extends StatelessWidget { class OverlayIcon extends StatelessWidget {
final IconData icon; final IconData icon;
final double size, iconSize; final double size;
final String text; final String text;
final double iconScale;
const OverlayIcon({ const OverlayIcon({
Key key, Key key,
@required this.icon, @required this.icon,
@required this.size, @required this.size,
double iconSize, this.iconScale = 1,
this.text, this.text,
}) : iconSize = iconSize ?? size, }) : super(key: key);
super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final iconChild = SizedBox( final iconChild = Icon(icon, size: size);
final iconBox = SizedBox(
width: size, width: size,
height: size, height: size,
child: Icon( // using a transform is better than modifying the icon size to properly center the scaled icon
icon, child: iconScale != 1
size: iconSize, ? Transform.scale(
), scale: iconScale,
child: iconChild,
)
: iconChild,
); );
return Container( return Container(
@ -142,12 +170,12 @@ class OverlayIcon extends StatelessWidget {
borderRadius: BorderRadius.circular(size), borderRadius: BorderRadius.circular(size),
), ),
child: text == null child: text == null
? iconChild ? iconBox
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
iconChild, iconBox,
SizedBox(width: 2), SizedBox(width: 2),
Text(text), Text(text),
], ],
@ -161,15 +189,17 @@ class IconUtils {
@required BuildContext context, @required BuildContext context,
@required String album, @required String album,
double size = 24, double size = 24,
bool embossed = false,
}) { }) {
Widget buildIcon(IconData icon) => embossed ? DecoratedIcon(icon, shadows: [Constants.embossShadow], size: size) : Icon(icon, size: size);
switch (androidFileUtils.getAlbumType(album)) { switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.camera: case AlbumType.camera:
return Icon(AIcons.cameraAlbum, size: size); return buildIcon(AIcons.cameraAlbum);
case AlbumType.screenshots: case AlbumType.screenshots:
case AlbumType.screenRecordings: case AlbumType.screenRecordings:
return Icon(AIcons.screenshotAlbum, size: size); return buildIcon(AIcons.screenshotAlbum);
case AlbumType.download: case AlbumType.download:
return Icon(AIcons.downloadAlbum, size: size); return buildIcon(AIcons.downloadAlbum);
case AlbumType.app: case AlbumType.app:
return Image( return Image(
image: AppIconImage( image: AppIconImage(

View file

@ -11,13 +11,13 @@ import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class DebugPage extends StatefulWidget { class DebugPage extends StatefulWidget {
static const routeName = '/debug'; static const routeName = '/debug';
@ -58,10 +58,10 @@ class DebugPageState extends State<DebugPage> {
title: Text('Debug'), title: Text('Debug'),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(icon: Icon(OMIcons.whatshot)), Tab(icon: Icon(AIcons.debug)),
Tab(icon: Icon(OMIcons.settings)), Tab(icon: Icon(AIcons.settings)),
Tab(icon: Icon(OMIcons.sdStorage)), Tab(icon: Icon(AIcons.removableStorage)),
Tab(icon: Icon(OMIcons.android)), Tab(icon: Icon(AIcons.android)),
], ],
), ),
), ),

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
@ -7,25 +8,27 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class AlbumListPage extends StatelessWidget { class AlbumListPage extends StatelessWidget {
static const routeName = '/albums'; static const routeName = '/albums';
final CollectionSource source; final CollectionSource source;
static final ChipActionDelegate actionDelegate = AlbumChipActionDelegate();
const AlbumListPage({@required this.source}); const AlbumListPage({@required this.source});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>( return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => s.albumSortFactor, selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
builder: (context, sortFactor, child) { builder: (context, s, child) {
return AnimatedBuilder( return AnimatedBuilder(
animation: androidFileUtils.appNameChangeNotifier, animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder( builder: (context, child) => StreamBuilder(
@ -33,9 +36,14 @@ class AlbumListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage( builder: (context, snapshot) => FilterNavigationPage(
source: source, source: source,
title: 'Albums', title: 'Albums',
actionDelegate: actionDelegate, chipSetActionDelegate: AlbumChipSetActionDelegate(),
chipActionDelegate: AlbumChipActionDelegate(source: source),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.rename,
],
filterEntries: getAlbumEntries(source), filterEntries: getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), filterBuilder: (album) => AlbumFilter(album, source.getUniqueAlbumName(album)),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: 'No albums', text: 'No albums',
@ -50,35 +58,40 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries // common with album selection page to move/copy entries
static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) { static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
final albums = source.sortedAlbums
.map((album) => MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
))
.toList();
switch (settings.albumSortFactor) { switch (settings.albumSortFactor) {
case ChipSortFactor.date: case ChipSortFactor.date:
albums.sort(FilterNavigationPage.compareChipByDate); final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
return Map.fromEntries(albums); album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
));
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allAlbumMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
final unpinnedMapEntries = (byPin[false] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
case ChipSortFactor.name: case ChipSortFactor.name:
default: default:
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[]; final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in source.sortedAlbums) { for (var album in source.sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) { if (pinned.contains(album)) {
case AlbumType.regular: pinnedAlbums.add(album);
regularAlbums.add(album); } else {
break; switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.app: case AlbumType.regular:
appAlbums.add(album); regularAlbums.add(album);
break; break;
default: case AlbumType.app:
specialAlbums.add(album); appAlbums.add(album);
break; break;
default:
specialAlbums.add(album);
break;
}
} }
} }
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) { return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
return MapEntry( return MapEntry(
album, album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),

View file

@ -1,3 +0,0 @@
enum ChipAction {
sort,
}

View file

@ -0,0 +1,95 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/action_delegates/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
class ChipActionDelegate {
Future<void> onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) {
case ChipAction.pin:
final pinnedFilters = settings.pinnedFilters..add(filter);
settings.pinnedFilters = pinnedFilters;
break;
case ChipAction.unpin:
final pinnedFilters = settings.pinnedFilters..remove(filter);
settings.pinnedFilters = pinnedFilters;
break;
default:
break;
}
}
}
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin {
final CollectionSource source;
AlbumChipActionDelegate({
@required this.source,
});
@override
Future<void> onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async {
await super.onActionSelected(context, filter, action);
switch (action) {
case ChipAction.rename:
unawaited(_showRenameDialog(context, filter as AlbumFilter));
break;
default:
break;
}
}
Future<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
final album = filter.album;
final newName = await showDialog<String>(
context: context,
builder: (context) => RenameAlbumDialog(album),
);
if (newName == null || newName.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return;
final result = await ImageFileService.renameDirectory(album, newName);
final bySuccess = groupBy<Map, bool>(result, (fields) => fields['success']);
final albumEntries = source.rawEntries.where(filter.filter);
final movedEntries = <ImageEntry>[];
await Future.forEach<Map>(bySuccess[true], (newFields) async {
final oldContentId = newFields['oldContentId'];
final entry = albumEntries.firstWhere((entry) => entry.contentId == oldContentId, orElse: () => null);
if (entry != null) {
movedEntries.add(entry);
await source.moveEntry(entry, newFields);
}
});
source.updateAfterMove(
entries: movedEntries,
fromAlbums: {album},
toAlbum: path.join(path.dirname(album), newName),
copy: false,
);
final failed = bySuccess[false]?.length ?? 0;
if (failed > 0) {
showFeedback(context, 'Failed to move ${Intl.plural(failed, one: '$failed item', other: '$failed items')}');
} else {
showFeedback(context, 'Done!');
}
}
}

View file

@ -0,0 +1,37 @@
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
sort,
}
enum ChipAction {
pin,
unpin,
rename,
}
extension ExtraChipAction on ChipAction {
String getText() {
switch (this) {
case ChipAction.pin:
return 'Pin to top';
case ChipAction.unpin:
return 'Unpin from top';
case ChipAction.rename:
return 'Rename';
}
return null;
}
IconData getIcon() {
switch (this) {
case ChipAction.pin:
case ChipAction.unpin:
return AIcons.pin;
case ChipAction.rename:
return AIcons.rename;
}
return null;
}
}

View file

@ -2,21 +2,21 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
abstract class ChipActionDelegate { abstract class ChipSetActionDelegate {
ChipSortFactor get sortFactor; ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor); set sortFactor(ChipSortFactor factor);
Future<void> onChipActionSelected(BuildContext context, ChipAction action) async { Future<void> onActionSelected(BuildContext context, ChipSetAction action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) { switch (action) {
case ChipAction.sort: case ChipSetAction.sort:
await _showSortDialog(context); await _showSortDialog(context);
break; break;
} }
@ -40,7 +40,7 @@ abstract class ChipActionDelegate {
} }
} }
class AlbumChipActionDelegate extends ChipActionDelegate { class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override @override
ChipSortFactor get sortFactor => settings.albumSortFactor; ChipSortFactor get sortFactor => settings.albumSortFactor;
@ -48,7 +48,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate {
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
} }
class CountryChipActionDelegate extends ChipActionDelegate { class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override @override
ChipSortFactor get sortFactor => settings.countrySortFactor; ChipSortFactor get sortFactor => settings.countrySortFactor;
@ -56,7 +56,7 @@ class CountryChipActionDelegate extends ChipActionDelegate {
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor; set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
} }
class TagChipActionDelegate extends ChipActionDelegate { class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override @override
ChipSortFactor get sortFactor => settings.tagSortFactor; ChipSortFactor get sortFactor => settings.tagSortFactor;

View file

@ -5,25 +5,31 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart'; import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DecoratedFilterChip extends StatelessWidget { class DecoratedFilterChip extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final CollectionFilter filter; final CollectionFilter filter;
final ImageEntry entry; final ImageEntry entry;
final FilterCallback onPressed; final bool pinned;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
const DecoratedFilterChip({ const DecoratedFilterChip({
Key key, Key key,
@required this.source, @required this.source,
@required this.filter, @required this.filter,
@required this.entry, @required this.entry,
@required this.onPressed, this.pinned = false,
@required this.onTap,
this.onLongPress,
}) : super(key: key); }) : super(key: key);
@override @override
@ -45,7 +51,8 @@ class DecoratedFilterChip extends StatelessWidget {
showGenericIcon: false, showGenericIcon: false,
background: backgroundImage, background: backgroundImage,
details: _buildDetails(filter), details: _buildDetails(filter),
onPressed: onPressed, onTap: onTap,
onLongPress: onLongPress,
); );
} }
@ -54,19 +61,31 @@ class DecoratedFilterChip extends StatelessWidget {
'${source.count(filter)}', '${source.count(filter)}',
style: TextStyle(color: FilterGridPage.detailColor), style: TextStyle(color: FilterGridPage.detailColor),
); );
return filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album) return Row(
? Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (pinned)
Icon( Padding(
AIcons.removableStorage, padding: EdgeInsets.only(right: 8),
size: 16, child: DecoratedIcon(
color: FilterGridPage.detailColor, AIcons.pin,
), color: FilterGridPage.detailColor,
SizedBox(width: 8), shadows: [Constants.embossShadow],
count, size: 16,
], ),
) ),
: count; if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album))
Padding(
padding: EdgeInsets.only(right: 8),
child: DecoratedIcon(
AIcons.removableStorage,
color: FilterGridPage.detailColor,
shadows: [Constants.embossShadow],
size: 16,
),
),
count,
],
);
} }
} }

View file

@ -15,28 +15,34 @@ import 'package:aves/widgets/common/data_providers/media_query_data_provider.dar
import 'package:aves/widgets/common/double_back_pop.dart'; import 'package:aves/widgets/common/double_back_pop.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/common/search_button.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/search_button.dart'; import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class FilterNavigationPage extends StatelessWidget { class FilterNavigationPage extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final ChipActionDelegate actionDelegate; final ChipSetActionDelegate chipSetActionDelegate;
final ChipActionDelegate chipActionDelegate;
final Map<String, ImageEntry> filterEntries; final Map<String, ImageEntry> filterEntries;
final CollectionFilter Function(String key) filterBuilder; final CollectionFilter Function(String key) filterBuilder;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final List<ChipAction> Function(CollectionFilter filter) chipActionsBuilder;
const FilterNavigationPage({ const FilterNavigationPage({
@required this.source, @required this.source,
@required this.title, @required this.title,
@required this.actionDelegate, @required this.chipSetActionDelegate,
@required this.chipActionDelegate,
@required this.chipActionsBuilder,
@required this.filterEntries, @required this.filterEntries,
@required this.filterBuilder, @required this.filterBuilder,
@required this.emptyBuilder, @required this.emptyBuilder,
@ -66,7 +72,7 @@ class FilterNavigationPage extends StatelessWidget {
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink(); return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
}, },
), ),
onPressed: (filter) => Navigator.pushAndRemoveUntil( onTap: (filter) => Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName), settings: RouteSettings(name: CollectionPage.routeName),
@ -79,24 +85,43 @@ class FilterNavigationPage extends StatelessWidget {
), ),
settings.navRemoveRoutePredicate(CollectionPage.routeName), settings.navRemoveRoutePredicate(CollectionPage.routeName),
), ),
onLongPress: (filter, tapPosition) => _showMenu(context, filter, tapPosition),
); );
} }
Future<void> _showMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
items: chipActionsBuilder(filter)
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(text: action.getText(), icon: action.getIcon()),
))
.toList(),
);
if (selectedAction != null) {
unawaited(chipActionDelegate.onActionSelected(context, filter, selectedAction));
}
}
List<Widget> _buildActions(BuildContext context) { List<Widget> _buildActions(BuildContext context) {
return [ return [
SearchButton(source), SearchButton(source),
PopupMenuButton<ChipAction>( PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'), key: Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
PopupMenuItem( PopupMenuItem(
key: Key('menu-sort'), key: Key('menu-sort'),
value: ChipAction.sort, value: ChipSetAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort), child: MenuRow(text: 'Sort...', icon: AIcons.sort),
), ),
]; ];
}, },
onSelected: (action) => actionDelegate.onChipActionSelected(context, action), onSelected: (action) => chipSetActionDelegate.onActionSelected(context, action),
), ),
]; ];
} }
@ -111,7 +136,7 @@ class FilterNavigationPage extends StatelessWidget {
)); ));
} }
static int compareChipByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) { static int compareChipsByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) {
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
} }
@ -123,7 +148,8 @@ class FilterGridPage extends StatelessWidget {
final Map<String, ImageEntry> filterEntries; final Map<String, ImageEntry> filterEntries;
final CollectionFilter Function(String key) filterBuilder; final CollectionFilter Function(String key) filterBuilder;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final FilterCallback onPressed; final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
const FilterGridPage({ const FilterGridPage({
@required this.source, @required this.source,
@ -131,7 +157,8 @@ class FilterGridPage extends StatelessWidget {
@required this.filterEntries, @required this.filterEntries,
@required this.filterBuilder, @required this.filterBuilder,
@required this.emptyBuilder, @required this.emptyBuilder,
@required this.onPressed, @required this.onTap,
this.onLongPress,
}); });
List<String> get filterKeys => filterEntries.keys.toList(); List<String> get filterKeys => filterEntries.keys.toList();
@ -141,6 +168,7 @@ class FilterGridPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pinnedFilters = settings.pinnedFilters;
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: DoubleBackPopScope( body: DoubleBackPopScope(
@ -164,12 +192,15 @@ class FilterGridPage extends StatelessWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, i) { (context, i) {
final key = filterKeys[i]; final key = filterKeys[i];
final filter = filterBuilder(key);
final child = DecoratedFilterChip( final child = DecoratedFilterChip(
key: Key(key), key: Key(key),
source: source, source: source,
filter: filterBuilder(key), filter: filter,
entry: filterEntries[key], entry: filterEntries[key],
onPressed: onPressed, pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
); );
return AnimationConfiguration.staggeredGrid( return AnimationConfiguration.staggeredGrid(
position: i, position: i,

View file

@ -1,3 +1,4 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -6,31 +7,37 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class CountryListPage extends StatelessWidget { class CountryListPage extends StatelessWidget {
static const routeName = '/countries'; static const routeName = '/countries';
final CollectionSource source; final CollectionSource source;
static final ChipActionDelegate actionDelegate = CountryChipActionDelegate();
const CountryListPage({@required this.source}); const CountryListPage({@required this.source});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>( return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => s.countrySortFactor, selector: (context, s) => Tuple2(s.countrySortFactor, s.pinnedFilters),
builder: (context, sortFactor, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(), stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage( builder: (context, snapshot) => FilterNavigationPage(
source: source, source: source,
title: 'Countries', title: 'Countries',
actionDelegate: actionDelegate, chipSetActionDelegate: CountryChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getCountryEntries(), filterEntries: _getCountryEntries(),
filterBuilder: (s) => LocationFilter(LocationLevel.country, s), filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
@ -44,9 +51,11 @@ class CountryListPage extends StatelessWidget {
} }
Map<String, ImageEntry> _getCountryEntries() { Map<String, ImageEntry> _getCountryEntries() {
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
final locatedEntries = entriesByDate.where((entry) => entry.isLocated); final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
final countries = source.sortedCountries.map((countryNameAndCode) { final allMapEntries = source.sortedCountries.map((countryNameAndCode) {
final split = countryNameAndCode.split(LocationFilter.locationSeparator); final split = countryNameAndCode.split(LocationFilter.locationSeparator);
ImageEntry entry; ImageEntry entry;
if (split.length > 1) { if (split.length > 1) {
@ -56,12 +65,19 @@ class CountryListPage extends StatelessWidget {
return MapEntry(countryNameAndCode, entry); return MapEntry(countryNameAndCode, entry);
}).toList(); }).toList();
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
switch (settings.countrySortFactor) { switch (settings.countrySortFactor) {
case ChipSortFactor.date: case ChipSortFactor.date:
countries.sort(FilterNavigationPage.compareChipByDate); pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break; break;
case ChipSortFactor.name: case ChipSortFactor.name:
// already sorted by name at the source level
break;
} }
return Map.fromEntries(countries); return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
} }
} }

View file

@ -1,3 +1,4 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -6,31 +7,37 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class TagListPage extends StatelessWidget { class TagListPage extends StatelessWidget {
static const routeName = '/tags'; static const routeName = '/tags';
final CollectionSource source; final CollectionSource source;
static final ChipActionDelegate actionDelegate = TagChipActionDelegate();
const TagListPage({@required this.source}); const TagListPage({@required this.source});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>( return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => s.tagSortFactor, selector: (context, s) => Tuple2(s.tagSortFactor, s.pinnedFilters),
builder: (context, sortFactor, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(), stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage( builder: (context, snapshot) => FilterNavigationPage(
source: source, source: source,
title: 'Tags', title: 'Tags',
actionDelegate: actionDelegate, chipSetActionDelegate: TagChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getTagEntries(), filterEntries: _getTagEntries(),
filterBuilder: (s) => TagFilter(s), filterBuilder: (s) => TagFilter(s),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
@ -44,20 +51,29 @@ class TagListPage extends StatelessWidget {
} }
Map<String, ImageEntry> _getTagEntries() { Map<String, ImageEntry> _getTagEntries() {
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
final tags = source.sortedTags final allMapEntries = source.sortedTags
.map((tag) => MapEntry( .map((tag) => MapEntry(
tag, tag,
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null), entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
)) ))
.toList(); .toList();
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
switch (settings.tagSortFactor) { switch (settings.tagSortFactor) {
case ChipSortFactor.date: case ChipSortFactor.date:
tags.sort(FilterNavigationPage.compareChipByDate); pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break; break;
case ChipSortFactor.name: case ChipSortFactor.name:
// already sorted by name at the source level
break;
} }
return Map.fromEntries(tags); return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
} }
} }

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
@ -23,6 +24,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:screen/screen.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class FullscreenBody extends StatefulWidget { class FullscreenBody extends StatefulWidget {
@ -97,10 +99,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection, collection: collection,
showInfo: () => _goToVerticalPage(infoPage), showInfo: () => _goToVerticalPage(infoPage),
); );
WidgetsBinding.instance.addObserver(this);
_initVideoController(); _initVideoController();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
Screen.keepOn(true);
}
} }
@override @override
@ -327,6 +332,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
void _onLeave() { void _onLeave() {
if (Navigator.canPop(context)) { if (Navigator.canPop(context)) {
_showSystemUI(); _showSystemUI();
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
Screen.keepOn(false);
}
} else { } else {
// exit app when trying to pop a fullscreen page that is a viewer for a single entry // exit app when trying to pop a fullscreen page that is a viewer for a single entry
SystemNavigator.pop(); SystemNavigator.pop();

View file

@ -74,7 +74,7 @@ class BasicSection extends StatelessWidget {
children: effectiveFilters children: effectiveFilters
.map((filter) => AvesFilterChip( .map((filter) => AvesFilterChip(
filter: filter, filter: filter,
onPressed: onFilter, onTap: onFilter,
)) ))
.toList(), .toList(),
), ),

View file

@ -119,7 +119,7 @@ class _LocationSectionState extends State<LocationSection> {
children: filters children: filters
.map((filter) => AvesFilterChip( .map((filter) => AvesFilterChip(
filter: filter, filter: filter,
onPressed: widget.onFilter, onTap: widget.onFilter,
)) ))
.toList(), .toList(),
), ),

View file

@ -6,6 +6,8 @@ import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/common/highlight_title.dart'; import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart';
import 'package:collection/collection.dart';
import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:expansion_tile_card/expansion_tile_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -27,10 +29,16 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
String _loadedMetadataUri; String _loadedMetadataUri;
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null); final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
ImageEntry get entry => widget.entry;
bool get isVisible => widget.visibleNotifier.value; bool get isVisible => widget.visibleNotifier.value;
static const int maxValueLength = 140; static const int maxValueLength = 140;
// directory names from metadata-extractor
static const exifThumbnailDirectory = 'Exif Thumbnail';
static const xmpDirectory = 'XMP';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -94,10 +102,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
fontSize: 18, fontSize: 18,
), ),
children: [ children: [
Divider(thickness: 1.0, height: 1.0), Divider(thickness: 1, height: 1),
SizedBox(height: 4),
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
Container( Container(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
padding: EdgeInsets.all(8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags), child: InfoRowGroup(dir.tags),
), ),
], ],
@ -112,9 +123,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
} }
Future<void> _getMetadata() async { Future<void> _getMetadata() async {
if (_loadedMetadataUri == widget.entry.uri) return; if (_loadedMetadataUri == entry.uri) return;
if (isVisible) { if (isVisible) {
final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {}; final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
_metadata = rawMetadata.entries.map((dirKV) { _metadata = rawMetadata.entries.map((dirKV) {
final directoryName = dirKV.key as String ?? ''; final directoryName = dirKV.key as String ?? '';
final rawTags = dirKV.value as Map ?? {}; final rawTags = dirKV.value as Map ?? {};
@ -126,8 +137,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}).where((kv) => kv != null))); }).where((kv) => kv != null)));
return _MetadataDirectory(directoryName, tags); return _MetadataDirectory(directoryName, tags);
}).toList() }).toList()
..sort((a, b) => a.name.compareTo(b.name)); ..sort((a, b) => compareAsciiUpperCase(a.name, b.name));
_loadedMetadataUri = widget.entry.uri; _loadedMetadataUri = entry.uri;
} else { } else {
_metadata = []; _metadata = [];
_loadedMetadataUri = null; _loadedMetadataUri = null;

View file

@ -0,0 +1,67 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter/material.dart';
enum MetadataThumbnailSource { exif, xmp }
class MetadataThumbnails extends StatefulWidget {
final MetadataThumbnailSource source;
final ImageEntry entry;
const MetadataThumbnails({
Key key,
@required this.source,
@required this.entry,
}) : super(key: key);
@override
_MetadataThumbnailsState createState() => _MetadataThumbnailsState();
}
class _MetadataThumbnailsState extends State<MetadataThumbnails> {
Future<List<Uint8List>> _loader;
@override
void initState() {
super.initState();
switch (widget.source) {
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(widget.entry.uri);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(widget.entry.uri);
break;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Uint8List>>(
future: _loader,
builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
final turns = (widget.entry.orientationDegrees / 90).round();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
child: Wrap(
children: snapshot.data.map((bytes) {
return RotatedBox(
quarterTurns: turns,
child: Image.memory(
bytes,
scale: devicePixelRatio,
),
);
}).toList(),
),
);
}
return SizedBox.shrink();
});
}
}

View file

@ -1,5 +1,4 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
@ -10,6 +9,7 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -135,12 +135,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTextStyle( return DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith( style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: [ shadows: [Constants.embossShadow],
Shadow(
color: Colors.black87,
offset: Offset(0.5, 1.0),
)
],
), ),
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
@ -217,7 +212,7 @@ class _LocationRow extends AnimatedWidget {
} }
return Row( return Row(
children: [ children: [
Icon(AIcons.location, size: _iconSize), DecoratedIcon(AIcons.location, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
], ],
@ -237,7 +232,7 @@ class _DateRow extends StatelessWidget {
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row( return Row(
children: [ children: [
Icon(AIcons.date, size: _iconSize), DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)),
@ -255,7 +250,7 @@ class _ShootingRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
Icon(AIcons.shooting, size: _iconSize), DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)),

View file

@ -2,6 +2,7 @@ import 'package:aves/main.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
@ -19,7 +20,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:screen/screen.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
@ -43,7 +43,7 @@ class _HomePageState extends State<HomePage> {
super.initState(); super.initState();
_setup(); _setup();
imageCache.maximumSizeBytes = 512 * (1 << 20); imageCache.maximumSizeBytes = 512 * (1 << 20);
Screen.keepOn(true); settings.keepScreenOn.apply();
} }
@override @override

View file

@ -1,6 +1,8 @@
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/highlight_title.dart'; import 'package:aves/widgets/common/highlight_title.dart';
@ -49,6 +51,23 @@ class SettingsPage extends StatelessWidget {
title: Text('Tap “back” twice to exit'), title: Text('Tap “back” twice to exit'),
), ),
SectionTitle('Display'), SectionTitle('Display'),
ListTile(
title: Text('Keep screen on'),
subtitle: Text(settings.keepScreenOn.name),
onTap: () async {
final value = await showDialog<KeepScreenOn>(
context: context,
builder: (context) => AvesSelectionDialog<KeepScreenOn>(
initialValue: settings.keepScreenOn,
options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.name))),
title: 'Keep Screen On',
),
);
if (value != null) {
settings.keepScreenOn = value;
}
},
),
ListTile( ListTile(
title: Text('SVG background'), title: Text('SVG background'),
trailing: SvgBackgroundSelector(), trailing: SvgBackgroundSelector(),
@ -62,6 +81,11 @@ class SettingsPage extends StatelessWidget {
builder: (context) => AvesSelectionDialog<CoordinateFormat>( builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: settings.coordinateFormat, initialValue: settings.coordinateFormat,
options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.name))), options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.name))),
optionSubtitleBuilder: (dynamic value) {
// dynamic declaration followed by cast, as workaround for generics limitation
final formatter = (value as CoordinateFormat);
return formatter.format(Constants.pointNemo);
},
title: 'Coordinate Format', title: 'Coordinate Format',
), ),
); );
@ -70,6 +94,22 @@ class SettingsPage extends StatelessWidget {
} }
}, },
), ),
SectionTitle('Thumbnails'),
SwitchListTile(
value: settings.showThumbnailLocation,
onChanged: (v) => settings.showThumbnailLocation = v,
title: Text('Show location icon'),
),
SwitchListTile(
value: settings.showThumbnailRaw,
onChanged: (v) => settings.showThumbnailRaw = v,
title: Text('Show raw icon'),
),
SwitchListTile(
value: settings.showThumbnailVideoDuration,
onChanged: (v) => settings.showThumbnailVideoDuration = v,
title: Text('Show video duration'),
),
SectionTitle('Privacy'), SectionTitle('Privacy'),
SwitchListTile( SwitchListTile(
value: settings.isCrashlyticsEnabled, value: settings.isCrashlyticsEnabled,

View file

@ -54,7 +54,7 @@ class FilterTable extends StatelessWidget {
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip( child: AvesFilterChip(
filter: filter, filter: filter,
onPressed: (filter) => _goToCollection(context, filter), onTap: (filter) => _goToCollection(context, filter),
), ),
), ),
if (showPercentIndicator) if (showPercentIndicator)

View file

@ -141,6 +141,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.5" version: "2.1.5"
decorated_icon:
dependency: "direct main"
description:
name: decorated_icon
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
draggable_scrollbar: draggable_scrollbar:
dependency: "direct main" dependency: "direct main"
description: description:
@ -501,13 +508,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.3.0" version: "0.3.0"
outline_material_icons:
dependency: "direct main"
description:
name: outline_material_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
overlay_support: overlay_support:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.11+23 version: 1.1.12+24
# video_player (as of v0.10.8+2, backed by ExoPlayer): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)
@ -38,6 +38,7 @@ dependencies:
sdk: flutter sdk: flutter
charts_flutter: charts_flutter:
collection: collection:
decorated_icon:
draggable_scrollbar: draggable_scrollbar:
# path: ../flutter-draggable-scrollbar # path: ../flutter-draggable-scrollbar
git: git:
@ -63,7 +64,6 @@ dependencies:
google_maps_flutter: google_maps_flutter:
intl: intl:
latlong: # for flutter_map latlong: # for flutter_map
outline_material_icons:
overlay_support: overlay_support:
package_info: package_info:
palette_generator: palette_generator:

View file

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -12,7 +10,7 @@ import 'package:test/test.dart';
void main() { void main() {
test('Filter serialization', () { test('Filter serialization', () {
CollectionFilter jsonRoundTrip(filter) => CollectionFilter.fromJson(jsonEncode(filter.toJson())); CollectionFilter jsonRoundTrip(filter) => CollectionFilter.fromJson(filter.toJson());
final album = AlbumFilter('path/to/album', 'album'); final album = AlbumFilter('path/to/album', 'album');
expect(album, jsonRoundTrip(album)); expect(album, jsonRoundTrip(album));