diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java index ebe81b15d..b1702a11a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java @@ -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> fieldsByEntry) { + result.success(fieldsByEntry); + } + + @Override + public void onFailure(Throwable throwable) { + result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage()); + } + }); + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java index 5dd54d6d3..b69a320f3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java @@ -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 allReaders = (List) 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 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 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 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 void putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java index 572e0f361..31cb5c924 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java @@ -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 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 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(); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java index 50efa6b54..be3742986 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java @@ -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) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 6a2df9874..14a0a620c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -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> 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>> scanFutures = new ArrayList<>(); + String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator; + for (Map 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> scanFuture = SettableFuture.create(); + scanFutures.add(scanFuture); + String newEntryPath = newDirPath + displayName; + scanNewPath(context, newEntryPath, mimeType, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map 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> listContentEntries(Context context, Uri contentUri, String dirPath) { + List> 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() {{ + 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 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 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> fieldsByEntry); + + void onFailure(Throwable throwable); + } } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 475cd67b3..121533ccc 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -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(); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java index 5c6f1833e..debb8a3c3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java @@ -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"; diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index f729f2329..5a1f454ca 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -18,14 +18,14 @@ class AlbumFilter extends CollectionFilter { const AlbumFilter(this.album, this.uniqueName); - AlbumFilter.fromJson(Map json) + AlbumFilter.fromMap(Map json) : this( json['album'], json['uniqueName'], ); @override - Map toJson() => { + Map 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 diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index fbf1194de..fb703e733 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -8,7 +8,7 @@ class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; @override - Map toJson() => { + Map 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; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index ab519743c..5e3b2b14c 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -27,17 +27,17 @@ abstract class CollectionFilter implements Comparable { 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 { const CollectionFilter(); - Map toJson(); + Map toMap(); + + String toJson() => jsonEncode(toMap()); bool filter(ImageEntry entry); @@ -55,7 +57,7 @@ abstract class CollectionFilter implements Comparable { 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(BuildContext context) => SynchronousFuture(stringToColor(label)); diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 1042430d2..74ea8d1e8 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -17,29 +17,33 @@ class LocationFilter extends CollectionFilter { if (split.length > 1) _countryCode = split[1]; } - LocationFilter.fromJson(Map json) + LocationFilter.fromMap(Map json) : this( LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null), json['location'], ); @override - Map toJson() => { + Map 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); } diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 54b6beb0a..cd1000439 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -38,19 +38,27 @@ class MimeFilter extends CollectionFilter { _icon ??= AIcons.vector; } - MimeFilter.fromJson(Map json) + MimeFilter.fromMap(Map json) : this( json['mime'], ); @override - Map toJson() => { + Map 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; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 9714adc2b..78937d159 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -32,13 +32,13 @@ class QueryFilter extends CollectionFilter { _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); } - QueryFilter.fromJson(Map json) + QueryFilter.fromMap(Map json) : this( json['query'], ); @override - Map toJson() => { + Map 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(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white); diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 83c0d5ef1..96bab615a 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -10,13 +10,13 @@ class TagFilter extends CollectionFilter { const TagFilter(this.tag); - TagFilter.fromJson(Map json) + TagFilter.fromMap(Map json) : this( json['tag'], ); @override - Map toJson() => { + Map 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; diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 48b1cb965..e2cfe22e0 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -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); + } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 441fba32c..88682abe9 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -131,6 +131,14 @@ class MetadataDb { debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); } + Future 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( diff --git a/lib/model/settings/screen_on.dart b/lib/model/settings/screen_on.dart new file mode 100644 index 000000000..c07924d73 --- /dev/null +++ b/lib/model/settings/screen_on.dart @@ -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); + } +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 93d54b742..8c5e1c808 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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 get pinnedFilters => (_prefs.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet(); + + set pinnedFilters(Set newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList()); + // info EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index f534beb7e..3369ed290 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -9,13 +9,18 @@ mixin AlbumMixin on SourceBase { List 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()); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 85361c504..21b169c91 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -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(_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>.of(byAlbum, source.compareAlbumsByName); break; } sections = Map.unmodifiable(sections); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 55b7aec41..3f2c173d7 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -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 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 entries, @required Set fromAlbums, @required String toAlbum, diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index f5cd1f125..789a1e1eb 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -76,9 +76,13 @@ mixin LocationMixin on SourceBase { void updateLocations() { final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); - List lister(String Function(AddressDetails a) f) => List.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.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.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase)); invalidateFilterEntryCounts(); eventBus.fire(LocationsChangedEvent()); diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 9008776f3..5d4c244b7 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -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', { '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}'); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 1b4c6df7e..684026a30 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -40,7 +40,7 @@ class ImageFileService { } } - static Future getObsoleteEntries(List knownContentIds) async { + static Future> getObsoleteEntries(List knownContentIds) async { try { final result = await platform.invokeMethod('getObsoleteEntries', { 'knownContentIds': knownContentIds, @@ -194,6 +194,19 @@ class ImageFileService { } return {}; } + + static Future> renameDirectory(String path, String newName) async { + try { + final result = await platform.invokeMethod('renameDirectory', { + 'path': path, + 'newName': newName, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } } @immutable diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 83cdc40aa..276fc2a34 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -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> getExifThumbnails(String uri) async { + try { + final result = await platform.invokeMethod('getExifThumbnails', { + 'uri': uri, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } + + static Future> getXmpThumbnails(String uri) async { + try { + final result = await platform.invokeMethod('getXmpThumbnails', { + 'uri': uri, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 658ff6763..742466b3b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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 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', diff --git a/lib/utils/durations.dart b/lib/utils/durations.dart index f4d7c2a26..a5709e238 100644 --- a/lib/utils/durations.dart +++ b/lib/utils/durations.dart @@ -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); } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index f83b8dc36..e29302f42 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -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 with SingleTickerPr _goToStats(); break; case CollectionAction.addShortcut: - unawaited(AppShortcutService.pin('Collection', collection.filters)); + unawaited(_showShortcutDialog(context)); break; case CollectionAction.group: final value = await showDialog( @@ -348,6 +349,16 @@ class _CollectionAppBarState extends State with SingleTickerPr } } + Future _showShortcutDialog(BuildContext context) async { + final name = await showDialog( + context: context, + builder: (context) => AddShortcutDialog(collection.filters), + ); + if (name == null || name.isEmpty) return; + + unawaited(AppShortcutService.pin(name, collection.filters)); + } + void _goToSearch() { Navigator.push( context, diff --git a/lib/widgets/collection/empty.dart b/lib/widgets/collection/empty.dart index 5c8db758d..ea79572db 100644 --- a/lib/widgets/collection/empty.dart +++ b/lib/widgets/collection/empty.dart @@ -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( diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index cfe71641c..e447f934a 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -108,7 +108,7 @@ class _FilterBarState extends State { filter: filter, removable: true, heroType: HeroType.always, - onPressed: (filter) { + onTap: (filter) { _userRemovedFilter = filter; widget.onPressed(filter); }, diff --git a/lib/widgets/collection/search/expandable_filter_row.dart b/lib/widgets/collection/search/expandable_filter_row.dart index f3239c1f1..decf75f9f 100644 --- a/lib/widgets/collection/search/expandable_filter_row.dart +++ b/lib/widgets/collection/search/expandable_filter_row.dart @@ -10,14 +10,14 @@ class ExpandableFilterRow extends StatelessWidget { final Iterable filters; final ValueNotifier 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, ); } } diff --git a/lib/widgets/collection/search/search_delegate.dart b/lib/widgets/collection/search/search_delegate.dart index b63699f09..cbda818d6 100644 --- a/lib/widgets/collection/search/search_delegate.dart +++ b/lib/widgets/collection/search/search_delegate.dart @@ -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), ); } diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index a380e6d32..fb46bb6f2 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -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>( + 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, + ), + ), + ], + ); + }); } } diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 96a5c3590..685d49572 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -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 { ? 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 { 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, diff --git a/lib/widgets/common/action_delegates/add_shortcut_dialog.dart b/lib/widgets/common/action_delegates/add_shortcut_dialog.dart new file mode 100644 index 000000000..1b353f519 --- /dev/null +++ b/lib/widgets/common/action_delegates/add_shortcut_dialog.dart @@ -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 filters; + + const AddShortcutDialog(this.filters); + + @override + _AddShortcutDialogState createState() => _AddShortcutDialogState(); +} + +class _AddShortcutDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + final ValueNotifier _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( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return FlatButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text('Add'.toUpperCase()), + ); + }, + ) + ], + ); + } + + Future _validate() async { + final name = _nameController.text ?? ''; + _isValidNotifier.value = name.isNotEmpty; + } + + void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); +} diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/common/action_delegates/create_album_dialog.dart index 87e943911..6d9d394c6 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/common/action_delegates/create_album_dialog.dart @@ -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 { + final ScrollController _scrollController = ScrollController(); final TextEditingController _nameController = TextEditingController(); + final FocusNode _nameFieldFocusNode = FocusNode(); final ValueNotifier _existsNotifier = ValueNotifier(false); final ValueNotifier _isValidNotifier = ValueNotifier(false); Set _allVolumes; @@ -25,11 +28,13 @@ class _CreateAlbumDialogState extends State { _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 { 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 { 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 { ); } + 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 _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)); diff --git a/lib/widgets/common/action_delegates/rename_album_dialog.dart b/lib/widgets/common/action_delegates/rename_album_dialog.dart new file mode 100644 index 000000000..99bd36467 --- /dev/null +++ b/lib/widgets/common/action_delegates/rename_album_dialog.dart @@ -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 { + final TextEditingController _nameController = TextEditingController(); + final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _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( + 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( + 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 _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); +} diff --git a/lib/widgets/common/action_delegates/rename_entry_dialog.dart b/lib/widgets/common/action_delegates/rename_entry_dialog.dart index 766566caf..8c143d5b7 100644 --- a/lib/widgets/common/action_delegates/rename_entry_dialog.dart +++ b/lib/widgets/common/action_delegates/rename_entry_dialog.dart @@ -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 { void initState() { super.initState(); _nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle; + _validate(); } @override @@ -38,6 +39,10 @@ class _RenameEntryDialogState extends State { 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 { ); } + String _buildEntryPath(String name) { + if (name == null || name.isEmpty) return ''; + return path.join(entry.directory, name + entry.extension); + } + Future _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); diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 4f5fd8d9a..d9320a80a 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -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(context, (filter as AlbumFilter)?.album), + onTap: (filter) => Navigator.pop(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, diff --git a/lib/widgets/common/aves_dialog.dart b/lib/widgets/common/aves_dialog.dart index e487b9b78..aa3829d67 100644 --- a/lib/widgets/common/aves_dialog.dart +++ b/lib/widgets/common/aves_dialog.dart @@ -6,6 +6,7 @@ class AvesDialog extends AlertDialog { AvesDialog({ String title, + ScrollController scrollController, List scrollableContent, Widget content, @required List 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( diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index 7c383ad48..b6bb5ef03 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -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 { Future _colorFuture; Color _outlineColor; bool _tapped; + Offset _tapPosition; CollectionFilter get filter => widget.filter; @@ -75,7 +80,7 @@ class _AvesFilterChipState extends State { @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 { 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 { 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( future: _colorFuture, diff --git a/lib/widgets/common/aves_selection_dialog.dart b/lib/widgets/common/aves_selection_dialog.dart index a681e8df7..a8c121f82 100644 --- a/lib/widgets/common/aves_selection_dialog.dart +++ b/lib/widgets/common/aves_selection_dialog.dart @@ -3,14 +3,18 @@ import 'package:flutter/widgets.dart'; import 'aves_dialog.dart'; +typedef TextBuilder = String Function(T value); + class AvesSelectionDialog extends StatefulWidget { final T initialValue; final Map options; + final TextBuilder optionSubtitleBuilder; final String title; const AvesSelectionDialog({ @required this.initialValue, @required this.options, + this.optionSubtitleBuilder, @required this.title, }); @@ -41,20 +45,30 @@ class _AvesSelectionDialogState extends State { ); } - Widget _buildRadioListTile(T value, String title) => RadioListTile( - 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( + 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, + ); + } } diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 6f7d2b47c..40a200769 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -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( diff --git a/lib/widgets/filter_grids/search_button.dart b/lib/widgets/common/search_button.dart similarity index 100% rename from lib/widgets/filter_grids/search_button.dart rename to lib/widgets/common/search_button.dart diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index e5fb45a72..e78a35cc8 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -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 { 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)), ], ), ), diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 42cc65638..e623a4d6a 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -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( - selector: (context, s) => s.albumSortFactor, - builder: (context, sortFactor, child) { + return Selector>>( + 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 getAlbumEntries(CollectionSource source) { + final pinned = settings.pinnedFilters.whereType().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, 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 = [], appAlbums = [], specialAlbums = []; + final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; 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), diff --git a/lib/widgets/filter_grids/chip_actions.dart b/lib/widgets/filter_grids/chip_actions.dart deleted file mode 100644 index 5e0984543..000000000 --- a/lib/widgets/filter_grids/chip_actions.dart +++ /dev/null @@ -1,3 +0,0 @@ -enum ChipAction { - sort, -} diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart new file mode 100644 index 000000000..4fdf14035 --- /dev/null +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -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 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 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 _showRenameDialog(BuildContext context, AlbumFilter filter) async { + final album = filter.album; + final newName = await showDialog( + 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(result, (fields) => fields['success']); + + final albumEntries = source.rawEntries.where(filter.filter); + final movedEntries = []; + await Future.forEach(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!'); + } + } +} diff --git a/lib/widgets/filter_grids/common/chip_actions.dart b/lib/widgets/filter_grids/common/chip_actions.dart new file mode 100644 index 000000000..e26455793 --- /dev/null +++ b/lib/widgets/filter_grids/common/chip_actions.dart @@ -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; + } +} diff --git a/lib/widgets/filter_grids/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart similarity index 78% rename from lib/widgets/filter_grids/chip_action_delegate.dart rename to lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 7eb741d7b..e053ee92a 100644 --- a/lib/widgets/filter_grids/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -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 onChipActionSelected(BuildContext context, ChipAction action) async { + Future 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; diff --git a/lib/widgets/filter_grids/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart similarity index 57% rename from lib/widgets/filter_grids/decorated_filter_chip.dart rename to lib/widgets/filter_grids/common/decorated_filter_chip.dart index 210311562..4616f4da9 100644 --- a/lib/widgets/filter_grids/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -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, + ], + ); } } diff --git a/lib/widgets/filter_grids/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart similarity index 76% rename from lib/widgets/filter_grids/filter_grid_page.dart rename to lib/widgets/filter_grids/common/filter_grid_page.dart index 1d35d2a1e..9acc8a89d 100644 --- a/lib/widgets/filter_grids/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -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 filterEntries; final CollectionFilter Function(String key) filterBuilder; final Widget Function() emptyBuilder; + final List 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 _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( + 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 _buildActions(BuildContext context) { return [ SearchButton(source), - PopupMenuButton( + PopupMenuButton( 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 a, MapEntry b) { + static int compareChipsByDate(MapEntry a, MapEntry 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 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 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, diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 8a2362a74..783e79429 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -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( - selector: (context, s) => s.countrySortFactor, - builder: (context, sortFactor, child) { + return Selector>>( + selector: (context, s) => Tuple2(s.countrySortFactor, s.pinnedFilters), + builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), 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 _getCountryEntries() { + final pinned = settings.pinnedFilters.whereType().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, 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]); } } diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 90533f253..9f8cb47a8 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -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( - selector: (context, s) => s.tagSortFactor, - builder: (context, sortFactor, child) { + return Selector>>( + selector: (context, s) => Tuple2(s.tagSortFactor, s.pinnedFilters), + builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), 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 _getTagEntries() { + final pinned = settings.pinnedFilters.whereType().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, 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]); } } diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 8e3515585..554e48e31 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -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 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 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(); diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 0880e9867..82e1307c5 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -74,7 +74,7 @@ class BasicSection extends StatelessWidget { children: effectiveFilters .map((filter) => AvesFilterChip( filter: filter, - onPressed: onFilter, + onTap: onFilter, )) .toList(), ), diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 665379805..8c53dc01f 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -119,7 +119,7 @@ class _LocationSectionState extends State { children: filters .map((filter) => AvesFilterChip( filter: filter, - onPressed: widget.onFilter, + onTap: widget.onFilter, )) .toList(), ), diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index 11bfda26d..38107d084 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -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 with Auto String _loadedMetadataUri; final ValueNotifier _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 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 with Auto } Future _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 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; diff --git a/lib/widgets/fullscreen/info/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata_thumbnail.dart new file mode 100644 index 000000000..8599ae9d9 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata_thumbnail.dart @@ -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 { + Future> _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>( + 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(); + }); + } +} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 51933c597..f9edab547 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -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)), diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 5dd7eaf71..cb5fe69be 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -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 { super.initState(); _setup(); imageCache.maximumSizeBytes = 512 * (1 << 20); - Screen.keepOn(true); + settings.keepScreenOn.apply(); } @override diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index c57913b10..f230ce8da 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -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( + context: context, + builder: (context) => AvesSelectionDialog( + 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( 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, diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index ed946bae8..958f63a62 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -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) diff --git a/pubspec.lock b/pubspec.lock index dc4027043..c79564288 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 6d2584819..918637ed5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index 7ebf5fe35..28bf84b9f 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -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));