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":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break;
case "renameDirectory":
new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
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.XMPIterator;
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.XMPPropertyInfo;
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.Rational;
import com.drew.lang.annotations.NotNull;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifReader;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.ExifThumbnailDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.file.FileTypeDirectory;
import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory;
import com.drew.metadata.xmp.XmpDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;
@ -70,9 +82,15 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// XMP
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_TITLE_PROP_NAME = "dc:title";
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_SPECIFIC_LANG = "en-US";
@ -108,6 +126,49 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// "+51.3328-000.7053+113.474/" (Apple)
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;
public MetadataHandler(Context context) {
@ -129,6 +190,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getExifThumbnails":
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
break;
case "getXmpThumbnails":
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
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
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());
}
// 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() {
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) {
RequestOptions options = new RequestOptions()
@ -91,42 +95,40 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally {
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 {
ContentResolver cr = activity.getContentResolver();
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 {
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());
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());
}
}
endOfStream();

View file

@ -20,6 +20,7 @@ import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.mp4.Mp4Directory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import com.drew.metadata.photoshop.PsdHeaderDirectory;
import java.io.IOException;
import java.io.InputStream;
@ -191,48 +192,69 @@ public class SourceImageEntry {
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
if (MimeTypes.JPEG.equals(sourceMimeType)) {
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
switch (sourceMimeType) {
case MimeTypes.JPEG:
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
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)) {
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
break;
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_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT);
}
} else if (MimeTypes.MP4.equals(sourceMimeType)) {
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);
}
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) {
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);
}
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} 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.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper;
@ -86,6 +89,101 @@ public abstract class ImageProvider {
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) {
switch (mimeType) {
case MimeTypes.JPEG:
@ -209,8 +307,8 @@ public abstract class ImageProvider {
}
// update fields in media store
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalImage.getHeight();
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalImage.getWidth();
int rotatedWidth = originalImage.getHeight();
int rotatedHeight = originalImage.getWidth();
Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth);
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) {
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;
Uri contentUri = null;
if (newUri != null) {
@ -260,7 +356,11 @@ public abstract class ImageProvider {
Map<String, Object> newFields = new HashMap<>();
// 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 {
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) {
@ -268,7 +368,9 @@ public abstract class ImageProvider {
newFields.put("uri", contentUri.toString());
newFields.put("contentId", contentId);
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("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
}
cursor.close();
}
@ -290,4 +392,10 @@ public abstract class ImageProvider {
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
SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);
entryMap = entry.toMap();
width = entry.width != null ? entry.width : 0;
height = entry.height != null ? entry.height : 0;
}
if ((width <= 0 || height <= 0) && needSize(mimeType)) {
// this is probably not a real image, like "/storage/emulated/0", so we skip it
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType);
} else {
newEntryHandler.handleEntry(entryMap);
if (newEntryCount % 30 == 0) {
Thread.sleep(10);
}
newEntryCount++;
newEntryHandler.handleEntry(entryMap);
if (newEntryCount % 30 == 0) {
Thread.sleep(10);
}
newEntryCount++;
}
}
cursor.close();

View file

@ -2,13 +2,14 @@ package deckers.thibault.aves.utils;
public class MimeTypes {
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 HEIC = "image/heic";
public static final String HEIF = "image/heif";
public static final String JPEG = "image/jpeg";
public static final String PNG = "image/png";
public static final String SVG = "image/svg+xml";
public static final String 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 VIDEO = "video";

View file

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

View file

@ -8,7 +8,7 @@ class FavouriteFilter extends CollectionFilter {
static const type = 'favourite';
@override
Map<String, dynamic> toJson() => {
Map<String, dynamic> toMap() => {
'type': type,
};
@ -19,7 +19,7 @@ class FavouriteFilter extends CollectionFilter {
String get label => 'Favourite';
@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
String get typeKey => type;

View file

@ -27,17 +27,17 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
final type = jsonMap['type'];
switch (type) {
case AlbumFilter.type:
return AlbumFilter.fromJson(jsonMap);
return AlbumFilter.fromMap(jsonMap);
case FavouriteFilter.type:
return FavouriteFilter();
case LocationFilter.type:
return LocationFilter.fromJson(jsonMap);
return LocationFilter.fromMap(jsonMap);
case MimeFilter.type:
return MimeFilter.fromJson(jsonMap);
return MimeFilter.fromMap(jsonMap);
case QueryFilter.type:
return QueryFilter.fromJson(jsonMap);
return QueryFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromJson(jsonMap);
return TagFilter.fromMap(jsonMap);
}
debugPrint('failed to parse filter from json=$jsonString');
return null;
@ -45,7 +45,9 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
const CollectionFilter();
Map<String, dynamic> toJson();
Map<String, dynamic> toMap();
String toJson() => jsonEncode(toMap());
bool filter(ImageEntry entry);
@ -55,7 +57,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
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));

View file

@ -17,29 +17,33 @@ class LocationFilter extends CollectionFilter {
if (split.length > 1) _countryCode = split[1];
}
LocationFilter.fromJson(Map<String, dynamic> json)
LocationFilter.fromMap(Map<String, dynamic> json)
: this(
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null),
json['location'],
);
@override
Map<String, dynamic> toJson() => {
Map<String, dynamic> toMap() => {
'type': type,
'level': level.toString(),
'location': _countryCode != null ? '$_location$locationSeparator$_countryCode' : _location,
'location': _countryCode != null ? countryNameAndCode : _location,
};
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
@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
String get label => _location;
@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);
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);
}

View file

@ -38,19 +38,27 @@ class MimeFilter extends CollectionFilter {
_icon ??= AIcons.vector;
}
MimeFilter.fromJson(Map<String, dynamic> json)
MimeFilter.fromMap(Map<String, dynamic> json)
: this(
json['mime'],
);
@override
Map<String, dynamic> toJson() => {
Map<String, dynamic> toMap() => {
'type': type,
'mime': 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
@ -60,7 +68,7 @@ class MimeFilter extends CollectionFilter {
String get label => _label;
@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
String get typeKey => type;

View file

@ -32,13 +32,13 @@ class QueryFilter extends CollectionFilter {
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
}
QueryFilter.fromJson(Map<String, dynamic> json)
QueryFilter.fromMap(Map<String, dynamic> json)
: this(
json['query'],
);
@override
Map<String, dynamic> toJson() => {
Map<String, dynamic> toMap() => {
'type': type,
'query': query,
};
@ -53,7 +53,7 @@ class QueryFilter extends CollectionFilter {
String get label => '$query';
@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
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);
TagFilter.fromJson(Map<String, dynamic> json)
TagFilter.fromMap(Map<String, dynamic> json)
: this(
json['tag'],
);
@override
Map<String, dynamic> toJson() => {
Map<String, dynamic> toMap() => {
'type': type,
'tag': tag,
};
@ -31,7 +31,7 @@ class TagFilter extends CollectionFilter {
String get label => tag;
@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
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/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
@ -25,7 +26,7 @@ class ImageEntry {
int orientationDegrees;
final int sizeBytes;
String sourceTitle;
final int dateModifiedSecs;
int _dateModifiedSecs;
final int sourceDateTakenMillis;
final int durationMillis;
int _catalogDateMillis;
@ -44,18 +45,20 @@ class ImageEntry {
this.orientationDegrees,
this.sizeBytes,
this.sourceTitle,
this.dateModifiedSecs,
int dateModifiedSecs,
this.sourceDateTakenMillis,
this.durationMillis,
}) : assert(width != null),
assert(height != null) {
this.path = path;
this.dateModifiedSecs = dateModifiedSecs;
}
ImageEntry copyWith({
@required String uri,
@required String path,
@required int contentId,
@required int dateModifiedSecs,
}) {
final copyContentId = contentId ?? this.contentId;
final copied = ImageEntry(
@ -202,6 +205,13 @@ class ImageEntry {
return _bestDate;
}
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
_dateModifiedSecs = dateModifiedSecs;
_bestDate = null;
}
DateTime get monthTaken {
final d = bestDate;
return d == null ? null : DateTime(d.year, d.month);
@ -386,4 +396,19 @@ class ImageEntry {
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');
}
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) {
if (entry == null) return;
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/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
@ -23,6 +25,7 @@ class Settings extends ChangeNotifier {
static const hasAcceptedTermsKey = 'has_accepted_terms';
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page';
static const catalogTimeZoneKey = 'catalog_time_zone';
@ -30,11 +33,15 @@ class Settings extends ChangeNotifier {
static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor';
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
static const albumSortFactorKey = 'album_sort_factor';
static const countrySortFactorKey = 'country_sort_factor';
static const tagSortFactorKey = 'tag_sort_factor';
static const pinnedFiltersKey = 'pinned_filters';
// info
static const infoMapStyleKey = 'info_map_style';
@ -75,6 +82,13 @@ class Settings extends ChangeNotifier {
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);
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
@ -97,6 +111,18 @@ class Settings extends ChangeNotifier {
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
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<CollectionFilter> get pinnedFilters => (_prefs.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
// info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);

View file

@ -9,13 +9,18 @@ mixin AlbumMixin on SourceBase {
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() {
final sorted = _folderPaths.toList()
..sort((a, b) {
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
});
final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
sortedAlbums = List.unmodifiable(sorted);
invalidateFilterEntryCounts();
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/source/collection_source.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:collection/collection.dart';
import 'package:flutter/foundation.dart';
@ -137,16 +136,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
void _applySort() {
switch (sortFactor) {
case EntrySortFactor.date:
_filteredEntries.sort((a, b) {
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.bestTitle, b.bestTitle);
});
_filteredEntries.sort(ImageEntry.compareByDate);
break;
case EntrySortFactor.size:
_filteredEntries.sort((a, b) => b.sizeBytes.compareTo(a.sizeBytes));
_filteredEntries.sort(ImageEntry.compareBySize);
break;
case EntrySortFactor.name:
_filteredEntries.sort((a, b) => compareAsciiUpperCase(a.bestTitle, b.bestTitle));
_filteredEntries.sort(ImageEntry.compareByName);
break;
}
}
@ -178,16 +174,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
break;
case EntrySortFactor.name:
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
int compare(a, b) {
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);
sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName);
break;
}
sections = Map.unmodifiable(sections);

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
@ -87,7 +88,23 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
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 Set<String> fromAlbums,
@required String toAlbum,

View file

@ -76,9 +76,13 @@ mixin LocationMixin on SourceBase {
void updateLocations() {
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
List<String> lister(String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
sortedCountries = lister((address) => '${address.countryName}${LocationFilter.locationSeparator}${address.countryCode}');
sortedPlaces = lister((address) => address.place);
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
// 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();
eventBus.fire(LocationsChangedEvent());

View file

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -28,7 +26,7 @@ class AppShortcutService {
try {
await platform.invokeMethod('pin', <String, dynamic>{
'label': label,
'filters': filters.map((filter) => jsonEncode(filter.toJson())).toList(),
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e) {
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 {
final result = await platform.invokeMethod('getObsoleteEntries', <String, dynamic>{
'knownContentIds': knownContentIds,
@ -194,6 +194,19 @@ class ImageFileService {
}
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

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/services/service_policy.dart';
@ -86,4 +88,28 @@ class MetadataService {
}
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/painting.dart';
import 'package:tuple/tuple.dart';
class Constants {
// 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',
);
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 = [
Dependency(
name: 'CWAC-Document',
@ -58,6 +66,12 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
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(
name: 'Draggable Scrollbar',
license: 'MIT',
@ -77,10 +91,10 @@ class Constants {
sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
),
Dependency(
name: 'Firebase Crashlytics',
name: 'FlutterFire',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/packages/firebase_crashlytics/LICENSE',
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire/tree/master/packages/firebase_crashlytics',
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
),
Dependency(
name: 'Flushbar',
@ -94,6 +108,12 @@ class Constants {
licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE',
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(
name: 'Flutter Markdown',
license: 'BSD 3-Clause',
@ -137,10 +157,22 @@ class Constants {
sourceUrl: 'https://github.com/dart-lang/intl',
),
Dependency(
name: 'Outline Material Icons',
name: 'LatLong',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/lucaslcode/outline_material_icons/blob/master/LICENSE',
sourceUrl: 'https://github.com/lucaslcode/outline_material_icons',
licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE',
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(
name: 'Palette Generator',
@ -220,6 +252,12 @@ class Constants {
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
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(
name: 'UUID',
license: 'MIT',

View file

@ -8,6 +8,7 @@ class Durations {
static const sweepingAnimation = Duration(milliseconds: 650);
static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration
static const staggeredAnimation = Duration(milliseconds: 375);
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
// collection animations
static const appBarTitleAnimation = Duration(milliseconds: 300);
@ -32,4 +33,5 @@ class Durations {
static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
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/filter_bar.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/app_bar_subtitle.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/icons.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:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -306,7 +307,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_goToStats();
break;
case CollectionAction.addShortcut:
unawaited(AppShortcutService.pin('Collection', collection.filters));
unawaited(_showShortcutDialog(context));
break;
case CollectionAction.group:
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() {
Navigator.push(
context,

View file

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

View file

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

View file

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

View file

@ -138,7 +138,7 @@ class ImageSearchDelegate {
filters: filters,
expandedNotifier: expandedSectionNotifier,
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 '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/enums.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:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ThumbnailEntryOverlay extends StatelessWidget {
final ImageEntry entry;
@ -23,26 +25,32 @@ class ThumbnailEntryOverlay extends StatelessWidget {
Widget build(BuildContext context) {
final fontSize = min(14.0, (extent / 8)).roundToDouble();
final iconSize = fontSize * 2;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.hasGps) GpsIcon(iconSize: iconSize),
if (entry.isAnimated)
AnimatedImageIcon(iconSize: iconSize)
else if (entry.isVideo)
DefaultTextStyle(
style: TextStyle(
color: Colors.grey[200],
fontSize: fontSize,
),
child: VideoIcon(
entry: entry,
iconSize: iconSize,
),
),
],
);
return Selector<Settings, Tuple3<bool, bool, bool>>(
selector: (context, s) => Tuple3(s.showThumbnailLocation, s.showThumbnailRaw, s.showThumbnailVideoDuration),
builder: (context, s, child) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
if (entry.isAnimated)
AnimatedImageIcon(iconSize: iconSize)
else if (entry.isVideo)
DefaultTextStyle(
style: TextStyle(
color: Colors.grey[200],
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/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/uri_image_provider.dart';
import 'package:aves/widgets/common/transition_image.dart';
@ -91,6 +92,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
? fastImage
: Image(
key: ValueKey('HQ'),
image: _sizedThumbnailProvider,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child;
return AnimatedSwitcher(
@ -111,7 +113,17 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
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,
height: extent,
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 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/durations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
@ -13,7 +14,9 @@ class CreateAlbumDialog extends StatefulWidget {
}
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _nameController = TextEditingController();
final FocusNode _nameFieldFocusNode = FocusNode();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
Set<StorageVolume> _allVolumes;
@ -25,11 +28,13 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
_allVolumes = androidFileUtils.storageVolumes;
_primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first);
_selectedVolume = _primaryVolume;
_nameFieldFocusNode.addListener(_onFocus);
}
@override
void dispose() {
_nameController.dispose();
_nameFieldFocusNode.removeListener(_onFocus);
super.dispose();
}
@ -37,6 +42,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
Widget build(BuildContext context) {
return AvesDialog(
title: 'New Album',
scrollController: _scrollController,
scrollableContent: [
if (_allVolumes.length > 1) ...[
Padding(
@ -73,9 +79,10 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
builder: (context, exists, child) {
return TextField(
controller: _nameController,
focusNode: _nameFieldFocusNode,
decoration: InputDecoration(
labelText: 'Album name',
helperText: exists ? 'Album already exists' : '',
hintText: 'Album name',
),
autofocus: _allVolumes.length == 1,
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) {
if (name == null || name.isEmpty) return '';
return join(_selectedVolume.path, 'Pictures', name);
}
Future<void> _validate() async {
final path = _buildAlbumPath(_nameController.text);
_existsNotifier.value = path.isEmpty ? false : await Directory(path).exists();
_isValidNotifier.value = (_nameController.text ?? '').isNotEmpty;
final newName = _nameController.text ?? '';
final path = _buildAlbumPath(newName);
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));

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

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.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/icons.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/material.dart';
import 'package:flutter/scheduler.dart';
@ -96,7 +95,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
icon: AIcons.album,
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,
path: newFields['path'] as String,
contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
));
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else {
@ -147,20 +148,12 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
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);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.move(oldContentId, entry);
await source.moveEntry(entry, newFields);
}
});
}
source.applyMove(
source.updateAfterMove(
entries: movedEntries,
fromAlbums: fromAlbums,
toAlbum: destinationAlbum,

View file

@ -6,6 +6,7 @@ class AvesDialog extends AlertDialog {
AvesDialog({
String title,
ScrollController scrollController,
List<Widget> scrollableContent,
Widget content,
@required List<Widget> actions,
@ -31,6 +32,7 @@ class AvesDialog extends AlertDialog {
),
),
child: ListView(
controller: scrollController ?? ScrollController(),
shrinkWrap: true,
children: scrollableContent,
),
@ -38,7 +40,7 @@ class AvesDialog extends AlertDialog {
),
)
: 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,
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder(

View file

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

View file

@ -3,14 +3,18 @@ import 'package:flutter/widgets.dart';
import 'aves_dialog.dart';
typedef TextBuilder<T> = String Function(T value);
class AvesSelectionDialog<T> extends StatefulWidget {
final T initialValue;
final Map<T, String> options;
final TextBuilder<T> optionSubtitleBuilder;
final String title;
const AvesSelectionDialog({
@required this.initialValue,
@required this.options,
this.optionSubtitleBuilder,
@required this.title,
});
@ -41,20 +45,30 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
);
}
Widget _buildRadioListTile(T value, String title) => RadioListTile<T>(
key: Key(value.toString()),
value: value,
groupValue: _selectedValue,
onChanged: (v) {
_selectedValue = v;
Navigator.pop(context, _selectedValue);
setState(() {});
},
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
Widget _buildRadioListTile(T value, String title) {
return RadioListTile<T>(
key: Key(value.toString()),
value: value,
groupValue: _selectedValue,
onChanged: (v) {
_selectedValue = v;
Navigator.pop(context, _selectedValue);
setState(() {});
},
title: Text(
title,
softWrap: false,
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/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:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class AIcons {
static const IconData allCollection = OMIcons.collections;
static const IconData image = OMIcons.photo;
static const IconData video = OMIcons.movie;
static const IconData vector = OMIcons.code;
static const IconData allCollection = Icons.collections_outlined;
static const IconData image = Icons.photo_outlined;
static const IconData video = Icons.movie_outlined;
static const IconData vector = Icons.code_outlined;
static const IconData checked = OMIcons.done;
static const IconData date = OMIcons.calendarToday;
static const IconData android = Icons.android;
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 error = OMIcons.errorOutline;
static const IconData location = OMIcons.place;
static const IconData shooting = OMIcons.camera;
static const IconData removableStorage = OMIcons.sdStorage;
static const IconData settings = OMIcons.settings;
static const IconData text = OMIcons.formatQuote;
static const IconData tag = OMIcons.localOffer;
static const IconData error = Icons.error_outline;
static const IconData location = Icons.place_outlined;
static const IconData raw = Icons.camera_outlined;
static const IconData shooting = Icons.camera_outlined;
static const IconData removableStorage = Icons.sd_storage_outlined;
static const IconData settings = Icons.settings_outlined;
static const IconData text = Icons.format_quote_outlined;
static const IconData tag = Icons.local_offer_outlined;
// actions
static const IconData addShortcut = OMIcons.bookmarkBorder;
static const IconData clear = OMIcons.clear;
static const IconData collapse = OMIcons.expandLess;
static const IconData createAlbum = OMIcons.addCircleOutline;
static const IconData debug = OMIcons.whatshot;
static const IconData delete = OMIcons.delete;
static const IconData expand = OMIcons.expandMore;
static const IconData favourite = OMIcons.favoriteBorder;
static const IconData favouriteActive = OMIcons.favorite;
static const IconData goUp = OMIcons.arrowUpward;
static const IconData group = OMIcons.groupWork;
static const IconData info = OMIcons.info;
static const IconData layers = OMIcons.layers;
static const IconData openInNew = OMIcons.openInNew;
static const IconData print = OMIcons.print;
static const IconData refresh = OMIcons.refresh;
static const IconData rename = OMIcons.title;
static const IconData rotateLeft = OMIcons.rotateLeft;
static const IconData rotateRight = OMIcons.rotateRight;
static const IconData search = OMIcons.search;
static const IconData select = OMIcons.selectAll;
static const IconData share = OMIcons.share;
static const IconData sort = OMIcons.sort;
static const IconData stats = OMIcons.pieChart;
static const IconData zoomIn = OMIcons.add;
static const IconData zoomOut = OMIcons.remove;
static const IconData addShortcut = Icons.bookmark_border;
static const IconData clear = Icons.clear_outlined;
static const IconData collapse = Icons.expand_less_outlined;
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData openInNew = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined;
static const IconData rename = Icons.title_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined;
static const IconData share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined;
static const IconData stats = Icons.pie_chart_outlined;
static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined;
// albums
static const IconData album = OMIcons.photoAlbum;
static const IconData cameraAlbum = OMIcons.photoCamera;
static const IconData album = Icons.photo_album_outlined;
static const IconData cameraAlbum = Icons.photo_camera_outlined;
static const IconData downloadAlbum = Icons.file_download;
static const IconData screenshotAlbum = OMIcons.smartphone;
static const IconData screenshotAlbum = Icons.smartphone_outlined;
// thumbnail overlay
static const IconData animated = Icons.slideshow;
@ -67,15 +71,21 @@ class AIcons {
class VideoIcon extends StatelessWidget {
final ImageEntry entry;
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
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.play,
size: iconSize,
text: entry.durationText,
text: showDuration ? entry.durationText : null,
);
}
}
@ -90,7 +100,7 @@ class AnimatedImageIcon extends StatelessWidget {
return OverlayIcon(
icon: AIcons.animated,
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 {
final IconData icon;
final double size, iconSize;
final double size;
final String text;
final double iconScale;
const OverlayIcon({
Key key,
@required this.icon,
@required this.size,
double iconSize,
this.iconScale = 1,
this.text,
}) : iconSize = iconSize ?? size,
super(key: key);
}) : super(key: key);
@override
Widget build(BuildContext context) {
final iconChild = SizedBox(
final iconChild = Icon(icon, size: size);
final iconBox = SizedBox(
width: size,
height: size,
child: Icon(
icon,
size: iconSize,
),
// using a transform is better than modifying the icon size to properly center the scaled icon
child: iconScale != 1
? Transform.scale(
scale: iconScale,
child: iconChild,
)
: iconChild,
);
return Container(
@ -142,12 +170,12 @@ class OverlayIcon extends StatelessWidget {
borderRadius: BorderRadius.circular(size),
),
child: text == null
? iconChild
? iconBox
: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
iconChild,
iconBox,
SizedBox(width: 2),
Text(text),
],
@ -161,15 +189,17 @@ class IconUtils {
@required BuildContext context,
@required String album,
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)) {
case AlbumType.camera:
return Icon(AIcons.cameraAlbum, size: size);
return buildIcon(AIcons.cameraAlbum);
case AlbumType.screenshots:
case AlbumType.screenRecordings:
return Icon(AIcons.screenshotAlbum, size: size);
return buildIcon(AIcons.screenshotAlbum);
case AlbumType.download:
return Icon(AIcons.downloadAlbum, size: size);
return buildIcon(AIcons.downloadAlbum);
case AlbumType.app:
return Image(
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/file_utils.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:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class DebugPage extends StatefulWidget {
static const routeName = '/debug';
@ -58,10 +58,10 @@ class DebugPageState extends State<DebugPage> {
title: Text('Debug'),
bottom: TabBar(
tabs: [
Tab(icon: Icon(OMIcons.whatshot)),
Tab(icon: Icon(OMIcons.settings)),
Tab(icon: Icon(OMIcons.sdStorage)),
Tab(icon: Icon(OMIcons.android)),
Tab(icon: Icon(AIcons.debug)),
Tab(icon: Icon(AIcons.settings)),
Tab(icon: Icon(AIcons.removableStorage)),
Tab(icon: Icon(AIcons.android)),
],
),
),

View file

@ -1,4 +1,5 @@
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/album.dart';
@ -7,25 +8,27 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.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:provider/provider.dart';
import 'package:tuple/tuple.dart';
class AlbumListPage extends StatelessWidget {
static const routeName = '/albums';
final CollectionSource source;
static final ChipActionDelegate actionDelegate = AlbumChipActionDelegate();
const AlbumListPage({@required this.source});
@override
Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.albumSortFactor,
builder: (context, sortFactor, child) {
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
builder: (context, s, child) {
return AnimatedBuilder(
animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder(
@ -33,9 +36,14 @@ class AlbumListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage(
source: source,
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),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
filterBuilder: (album) => AlbumFilter(album, source.getUniqueAlbumName(album)),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: 'No albums',
@ -50,35 +58,40 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries
static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
final entriesByDate = source.sortedEntriesForFilterList;
final albums = source.sortedAlbums
.map((album) => MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
))
.toList();
switch (settings.albumSortFactor) {
case ChipSortFactor.date:
albums.sort(FilterNavigationPage.compareChipByDate);
return Map.fromEntries(albums);
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
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:
default:
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in source.sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
break;
default:
specialAlbums.add(album);
break;
if (pinned.contains(album)) {
pinnedAlbums.add(album);
} else {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
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(
album,
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/utils/durations.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/scheduler.dart';
abstract class ChipActionDelegate {
abstract class ChipSetActionDelegate {
ChipSortFactor get sortFactor;
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
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) {
case ChipAction.sort:
case ChipSetAction.sort:
await _showSortDialog(context);
break;
}
@ -40,7 +40,7 @@ abstract class ChipActionDelegate {
}
}
class AlbumChipActionDelegate extends ChipActionDelegate {
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@ -48,7 +48,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate {
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
}
class CountryChipActionDelegate extends ChipActionDelegate {
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@ -56,7 +56,7 @@ class CountryChipActionDelegate extends ChipActionDelegate {
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
}
class TagChipActionDelegate extends ChipActionDelegate {
class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override
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/source/collection_source.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/vector.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
class DecoratedFilterChip extends StatelessWidget {
final CollectionSource source;
final CollectionFilter filter;
final ImageEntry entry;
final FilterCallback onPressed;
final bool pinned;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
const DecoratedFilterChip({
Key key,
@required this.source,
@required this.filter,
@required this.entry,
@required this.onPressed,
this.pinned = false,
@required this.onTap,
this.onLongPress,
}) : super(key: key);
@override
@ -45,7 +51,8 @@ class DecoratedFilterChip extends StatelessWidget {
showGenericIcon: false,
background: backgroundImage,
details: _buildDetails(filter),
onPressed: onPressed,
onTap: onTap,
onLongPress: onLongPress,
);
}
@ -54,19 +61,31 @@ class DecoratedFilterChip extends StatelessWidget {
'${source.count(filter)}',
style: TextStyle(color: FilterGridPage.detailColor),
);
return filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
AIcons.removableStorage,
size: 16,
color: FilterGridPage.detailColor,
),
SizedBox(width: 8),
count,
],
)
: count;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (pinned)
Padding(
padding: EdgeInsets.only(right: 8),
child: DecoratedIcon(
AIcons.pin,
color: FilterGridPage.detailColor,
shadows: [Constants.embossShadow],
size: 16,
),
),
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/icons.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/filter_grids/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/chip_actions.dart';
import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/search_button.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.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/decorated_filter_chip.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart';
class FilterNavigationPage extends StatelessWidget {
final CollectionSource source;
final String title;
final ChipActionDelegate actionDelegate;
final ChipSetActionDelegate chipSetActionDelegate;
final ChipActionDelegate chipActionDelegate;
final Map<String, ImageEntry> filterEntries;
final CollectionFilter Function(String key) filterBuilder;
final Widget Function() emptyBuilder;
final List<ChipAction> Function(CollectionFilter filter) chipActionsBuilder;
const FilterNavigationPage({
@required this.source,
@required this.title,
@required this.actionDelegate,
@required this.chipSetActionDelegate,
@required this.chipActionDelegate,
@required this.chipActionsBuilder,
@required this.filterEntries,
@required this.filterBuilder,
@required this.emptyBuilder,
@ -66,7 +72,7 @@ class FilterNavigationPage extends StatelessWidget {
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
},
),
onPressed: (filter) => Navigator.pushAndRemoveUntil(
onTap: (filter) => Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
@ -79,24 +85,43 @@ class FilterNavigationPage extends StatelessWidget {
),
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) {
return [
SearchButton(source),
PopupMenuButton<ChipAction>(
PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: Key('menu-sort'),
value: ChipAction.sort,
value: ChipSetAction.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;
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
}
@ -123,7 +148,8 @@ class FilterGridPage extends StatelessWidget {
final Map<String, ImageEntry> filterEntries;
final CollectionFilter Function(String key) filterBuilder;
final Widget Function() emptyBuilder;
final FilterCallback onPressed;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
const FilterGridPage({
@required this.source,
@ -131,7 +157,8 @@ class FilterGridPage extends StatelessWidget {
@required this.filterEntries,
@required this.filterBuilder,
@required this.emptyBuilder,
@required this.onPressed,
@required this.onTap,
this.onLongPress,
});
List<String> get filterKeys => filterEntries.keys.toList();
@ -141,6 +168,7 @@ class FilterGridPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final pinnedFilters = settings.pinnedFilters;
return MediaQueryDataProvider(
child: Scaffold(
body: DoubleBackPopScope(
@ -164,12 +192,15 @@ class FilterGridPage extends StatelessWidget {
delegate: SliverChildBuilderDelegate(
(context, i) {
final key = filterKeys[i];
final filter = filterBuilder(key);
final child = DecoratedFilterChip(
key: Key(key),
source: source,
filter: filterBuilder(key),
filter: filter,
entry: filterEntries[key],
onPressed: onPressed,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
);
return AnimationConfiguration.staggeredGrid(
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/image_entry.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/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.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:provider/provider.dart';
import 'package:tuple/tuple.dart';
class CountryListPage extends StatelessWidget {
static const routeName = '/countries';
final CollectionSource source;
static final ChipActionDelegate actionDelegate = CountryChipActionDelegate();
const CountryListPage({@required this.source});
@override
Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.countrySortFactor,
builder: (context, sortFactor, child) {
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple2(s.countrySortFactor, s.pinnedFilters),
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage(
source: source,
title: 'Countries',
actionDelegate: actionDelegate,
chipSetActionDelegate: CountryChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getCountryEntries(),
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
emptyBuilder: () => EmptyContent(
@ -44,9 +51,11 @@ class CountryListPage extends StatelessWidget {
}
Map<String, ImageEntry> _getCountryEntries() {
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
final entriesByDate = source.sortedEntriesForFilterList;
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);
ImageEntry entry;
if (split.length > 1) {
@ -56,12 +65,19 @@ class CountryListPage extends StatelessWidget {
return MapEntry(countryNameAndCode, entry);
}).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) {
case ChipSortFactor.date:
countries.sort(FilterNavigationPage.compareChipByDate);
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break;
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/image_entry.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/widgets/collection/empty.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.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:provider/provider.dart';
import 'package:tuple/tuple.dart';
class TagListPage extends StatelessWidget {
static const routeName = '/tags';
final CollectionSource source;
static final ChipActionDelegate actionDelegate = TagChipActionDelegate();
const TagListPage({@required this.source});
@override
Widget build(BuildContext context) {
return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.tagSortFactor,
builder: (context, sortFactor, child) {
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple2(s.tagSortFactor, s.pinnedFilters),
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage(
source: source,
title: 'Tags',
actionDelegate: actionDelegate,
chipSetActionDelegate: TagChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getTagEntries(),
filterBuilder: (s) => TagFilter(s),
emptyBuilder: () => EmptyContent(
@ -44,20 +51,29 @@ class TagListPage extends StatelessWidget {
}
Map<String, ImageEntry> _getTagEntries() {
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
final entriesByDate = source.sortedEntriesForFilterList;
final tags = source.sortedTags
final allMapEntries = source.sortedTags
.map((tag) => MapEntry(
tag,
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
))
.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) {
case ChipSortFactor.date:
tags.sort(FilterNavigationPage.compareChipByDate);
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break;
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/image_entry.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.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:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:screen/screen.dart';
import 'package:tuple/tuple.dart';
class FullscreenBody extends StatefulWidget {
@ -97,10 +99,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
WidgetsBinding.instance.addObserver(this);
_initVideoController();
_registerWidget(widget);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
Screen.keepOn(true);
}
}
@override
@ -327,6 +332,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
void _onLeave() {
if (Navigator.canPop(context)) {
_showSystemUI();
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
Screen.keepOn(false);
}
} else {
// exit app when trying to pop a fullscreen page that is a viewer for a single entry
SystemNavigator.pop();

View file

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

View file

@ -119,7 +119,7 @@ class _LocationSectionState extends State<LocationSection> {
children: filters
.map((filter) => AvesFilterChip(
filter: filter,
onPressed: widget.onFilter,
onTap: widget.onFilter,
))
.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/icons.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:flutter/material.dart';
@ -27,10 +29,16 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
String _loadedMetadataUri;
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
ImageEntry get entry => widget.entry;
bool get isVisible => widget.visibleNotifier.value;
static const int maxValueLength = 140;
// directory names from metadata-extractor
static const exifThumbnailDirectory = 'Exif Thumbnail';
static const xmpDirectory = 'XMP';
@override
void initState() {
super.initState();
@ -94,10 +102,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
fontSize: 18,
),
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(
alignment: Alignment.topLeft,
padding: EdgeInsets.all(8),
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags),
),
],
@ -112,9 +123,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
Future<void> _getMetadata() async {
if (_loadedMetadataUri == widget.entry.uri) return;
if (_loadedMetadataUri == entry.uri) return;
if (isVisible) {
final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {};
final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
_metadata = rawMetadata.entries.map((dirKV) {
final directoryName = dirKV.key as String ?? '';
final rawTags = dirKV.value as Map ?? {};
@ -126,8 +137,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}).where((kv) => kv != null)));
return _MetadataDirectory(directoryName, tags);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
_loadedMetadataUri = widget.entry.uri;
..sort((a, b) => compareAsciiUpperCase(a.name, b.name));
_loadedMetadataUri = entry.uri;
} else {
_metadata = [];
_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:ui';
import 'package:aves/model/image_entry.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/icons.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -135,12 +135,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
Widget build(BuildContext context) {
return DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: [
Shadow(
color: Colors.black87,
offset: Offset(0.5, 1.0),
)
],
shadows: [Constants.embossShadow],
),
softWrap: false,
overflow: TextOverflow.fade,
@ -217,7 +212,7 @@ class _LocationRow extends AnimatedWidget {
}
return Row(
children: [
Icon(AIcons.location, size: _iconSize),
DecoratedIcon(AIcons.location, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding),
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
],
@ -237,7 +232,7 @@ class _DateRow extends StatelessWidget {
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row(
children: [
Icon(AIcons.date, size: _iconSize),
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, 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) {
return Row(
children: [
Icon(AIcons.shooting, size: _iconSize),
DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding),
Expanded(child: Text(details.aperture, 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/image_entry.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/source/collection_lens.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:pedantic/pedantic.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:screen/screen.dart';
class HomePage extends StatefulWidget {
static const routeName = '/';
@ -43,7 +43,7 @@ class _HomePageState extends State<HomePage> {
super.initState();
_setup();
imageCache.maximumSizeBytes = 512 * (1 << 20);
Screen.keepOn(true);
settings.keepScreenOn.apply();
}
@override

View file

@ -1,6 +1,8 @@
import 'package:aves/model/settings/coordinate_format.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/utils/constants.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/highlight_title.dart';
@ -49,6 +51,23 @@ class SettingsPage extends StatelessWidget {
title: Text('Tap “back” twice to exit'),
),
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(
title: Text('SVG background'),
trailing: SvgBackgroundSelector(),
@ -62,6 +81,11 @@ class SettingsPage extends StatelessWidget {
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: settings.coordinateFormat,
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',
),
);
@ -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'),
SwitchListTile(
value: settings.isCrashlyticsEnabled,

View file

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

View file

@ -141,6 +141,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:
@ -501,13 +508,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
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.
# Read more about iOS versioning at
# 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):
# - does not support content URIs (by default, but trivial by fork)
@ -38,6 +38,7 @@ dependencies:
sdk: flutter
charts_flutter:
collection:
decorated_icon:
draggable_scrollbar:
# path: ../flutter-draggable-scrollbar
git:
@ -63,7 +64,6 @@ dependencies:
google_maps_flutter:
intl:
latlong: # for flutter_map
outline_material_icons:
overlay_support:
package_info:
palette_generator:

View file

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
@ -12,7 +10,7 @@ import 'package:test/test.dart';
void main() {
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');
expect(album, jsonRoundTrip(album));