Merge branch 'develop'
This commit is contained in:
commit
440d6da046
66 changed files with 1499 additions and 430 deletions
|
@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
case "rotate":
|
||||
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
case "renameDirectory":
|
||||
new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
|
@ -179,4 +182,26 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renameDirectory(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
String dirPath = call.argument("path");
|
||||
String newName = call.argument("newName");
|
||||
if (dirPath == null || newName == null) {
|
||||
result.error("renameDirectory-args", "failed because of missing arguments", null);
|
||||
return;
|
||||
}
|
||||
|
||||
ImageProvider provider = new MediaStoreImageProvider();
|
||||
provider.renameDirectory(activity, dirPath, newName, new ImageProvider.AlbumRenameOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(List<Map<String, Object>> fieldsByEntry) {
|
||||
result.success(fieldsByEntry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable throwable) {
|
||||
result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -16,24 +16,36 @@ import androidx.annotation.Nullable;
|
|||
import com.adobe.internal.xmp.XMPException;
|
||||
import com.adobe.internal.xmp.XMPIterator;
|
||||
import com.adobe.internal.xmp.XMPMeta;
|
||||
import com.adobe.internal.xmp.XMPUtils;
|
||||
import com.adobe.internal.xmp.properties.XMPProperty;
|
||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
|
||||
import com.drew.imaging.ImageMetadataReader;
|
||||
import com.drew.imaging.ImageProcessingException;
|
||||
import com.drew.imaging.jpeg.JpegMetadataReader;
|
||||
import com.drew.imaging.jpeg.JpegSegmentMetadataReader;
|
||||
import com.drew.imaging.jpeg.JpegSegmentType;
|
||||
import com.drew.lang.GeoLocation;
|
||||
import com.drew.lang.Rational;
|
||||
import com.drew.lang.annotations.NotNull;
|
||||
import com.drew.metadata.Directory;
|
||||
import com.drew.metadata.Metadata;
|
||||
import com.drew.metadata.MetadataException;
|
||||
import com.drew.metadata.Tag;
|
||||
import com.drew.metadata.exif.ExifIFD0Directory;
|
||||
import com.drew.metadata.exif.ExifReader;
|
||||
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
||||
import com.drew.metadata.exif.ExifThumbnailDirectory;
|
||||
import com.drew.metadata.exif.GpsDirectory;
|
||||
import com.drew.metadata.file.FileTypeDirectory;
|
||||
import com.drew.metadata.gif.GifAnimationDirectory;
|
||||
import com.drew.metadata.webp.WebpDirectory;
|
||||
import com.drew.metadata.xmp.XmpDirectory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TimeZone;
|
||||
|
@ -70,9 +82,15 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
|
||||
// XMP
|
||||
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
|
||||
private static final String XMP_XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/";
|
||||
private static final String XMP_IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/";
|
||||
|
||||
private static final String XMP_SUBJECT_PROP_NAME = "dc:subject";
|
||||
private static final String XMP_TITLE_PROP_NAME = "dc:title";
|
||||
private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description";
|
||||
private static final String XMP_THUMBNAIL_PROP_NAME = "xmp:Thumbnails";
|
||||
private static final String XMP_THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image";
|
||||
|
||||
private static final String XMP_GENERIC_LANG = "";
|
||||
private static final String XMP_SPECIFIC_LANG = "en-US";
|
||||
|
||||
|
@ -108,6 +126,49 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
// "+51.3328-000.7053+113.474/" (Apple)
|
||||
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*");
|
||||
|
||||
private static int TAG_THUMBNAIL_DATA = 0x10000;
|
||||
|
||||
// modify metadata-extractor readers to store EXIF thumbnail data
|
||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/276#issuecomment-677767368
|
||||
static {
|
||||
List<JpegSegmentMetadataReader> allReaders = (List<JpegSegmentMetadataReader>) JpegMetadataReader.ALL_READERS;
|
||||
for (int n = 0, cnt = allReaders.size(); n < cnt; n++) {
|
||||
if (allReaders.get(n).getClass() != ExifReader.class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allReaders.set(n, new ExifReader() {
|
||||
@Override
|
||||
public void readJpegSegments(@NotNull final Iterable<byte[]> segments, @NotNull final Metadata metadata, @NotNull final JpegSegmentType segmentType) {
|
||||
super.readJpegSegments(segments, metadata, segmentType);
|
||||
|
||||
for (byte[] segmentBytes : segments) {
|
||||
// Filter any segments containing unexpected preambles
|
||||
if (!startsWithJpegExifPreamble(segmentBytes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the thumbnail
|
||||
try {
|
||||
ExifThumbnailDirectory tnDirectory = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class);
|
||||
if (tnDirectory != null && tnDirectory.containsTag(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET)) {
|
||||
int offset = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET);
|
||||
int length = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH);
|
||||
|
||||
byte[] tnData = new byte[length];
|
||||
System.arraycopy(segmentBytes, JPEG_SEGMENT_PREAMBLE.length() + offset, tnData, 0, length);
|
||||
tnDirectory.setObject(TAG_THUMBNAIL_DATA, tnData);
|
||||
}
|
||||
} catch (MetadataException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Context context;
|
||||
|
||||
public MetadataHandler(Context context) {
|
||||
|
@ -129,6 +190,12 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
case "getContentResolverMetadata":
|
||||
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
case "getExifThumbnails":
|
||||
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
case "getXmpThumbnails":
|
||||
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
|
@ -463,6 +530,50 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Uri uri = Uri.parse(call.argument("uri"));
|
||||
List<byte[]> thumbnails = new ArrayList<>();
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
for (ExifThumbnailDirectory dir : metadata.getDirectoriesOfType(ExifThumbnailDirectory.class)) {
|
||||
byte[] data = (byte[]) dir.getObject(TAG_THUMBNAIL_DATA);
|
||||
if (data != null) {
|
||||
thumbnails.add(data);
|
||||
}
|
||||
}
|
||||
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
|
||||
Log.w(LOG_TAG, "failed to extract exif thumbnail", e);
|
||||
}
|
||||
result.success(thumbnails);
|
||||
}
|
||||
|
||||
private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Uri uri = Uri.parse(call.argument("uri"));
|
||||
List<byte[]> thumbnails = new ArrayList<>();
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
|
||||
XMPMeta xmpMeta = dir.getXMPMeta();
|
||||
try {
|
||||
if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) {
|
||||
int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME);
|
||||
for (int i = 1; i < count + 1; i++) {
|
||||
XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME);
|
||||
if (image != null) {
|
||||
thumbnails.add(XMPUtils.decodeBase64(image.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (XMPException e) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
|
||||
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e);
|
||||
}
|
||||
result.success(thumbnails);
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
|
||||
|
|
|
@ -66,6 +66,10 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
handler.post(() -> eventSink.endOfStream());
|
||||
}
|
||||
|
||||
// Supported image formats:
|
||||
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
|
||||
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
|
||||
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
|
||||
private void getImage() {
|
||||
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) {
|
||||
RequestOptions options = new RequestOptions()
|
||||
|
@ -91,42 +95,40 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
} finally {
|
||||
Glide.with(activity).clear(target);
|
||||
}
|
||||
} else if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
|
||||
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images
|
||||
// so we convert the image on platform side first
|
||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.submit();
|
||||
try {
|
||||
Bitmap bitmap = target.get();
|
||||
if (bitmap != null) {
|
||||
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
||||
// Bitmap.CompressFormat.PNG is slower than JPEG
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
|
||||
success(stream.toByteArray());
|
||||
} else {
|
||||
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
|
||||
} finally {
|
||||
Glide.with(activity).clear(target);
|
||||
}
|
||||
} else {
|
||||
ContentResolver cr = activity.getContentResolver();
|
||||
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
|
||||
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images
|
||||
// so we convert the image on platform side first
|
||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.submit();
|
||||
try {
|
||||
Bitmap bitmap = target.get();
|
||||
if (bitmap != null) {
|
||||
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
||||
// Bitmap.CompressFormat.PNG is slower than JPEG
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
|
||||
success(stream.toByteArray());
|
||||
} else {
|
||||
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
|
||||
} finally {
|
||||
Glide.with(activity).clear(target);
|
||||
}
|
||||
} else {
|
||||
try (InputStream is = cr.openInputStream(uri)) {
|
||||
if (is != null) {
|
||||
streamBytes(is);
|
||||
} else {
|
||||
error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
|
||||
try (InputStream is = cr.openInputStream(uri)) {
|
||||
if (is != null) {
|
||||
streamBytes(is);
|
||||
} else {
|
||||
error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
|
||||
}
|
||||
}
|
||||
endOfStream();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -17,14 +17,17 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
|||
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.SettableFuture;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
|
@ -86,6 +89,101 @@ public abstract class ImageProvider {
|
|||
scanNewPath(context, newFile.getPath(), mimeType, callback);
|
||||
}
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public void renameDirectory(Context context, String oldDirPath, String newDirName, final AlbumRenameOpCallback callback) {
|
||||
if (!oldDirPath.endsWith(File.separator)) {
|
||||
oldDirPath += File.separator;
|
||||
}
|
||||
|
||||
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, oldDirPath);
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(new Exception("failed to find directory at path=" + oldDirPath));
|
||||
return;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> entries = new ArrayList<>();
|
||||
entries.addAll(listContentEntries(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, oldDirPath));
|
||||
entries.addAll(listContentEntries(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, oldDirPath));
|
||||
|
||||
boolean renamed;
|
||||
try {
|
||||
renamed = destinationDirDocFile.renameTo(newDirName);
|
||||
} catch (FileNotFoundException e) {
|
||||
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath, e));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renamed) {
|
||||
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath));
|
||||
return;
|
||||
}
|
||||
|
||||
List<SettableFuture<Map<String, Object>>> scanFutures = new ArrayList<>();
|
||||
String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator;
|
||||
for (Map<String, Object> entry : entries) {
|
||||
String displayName = (String) entry.get("displayName");
|
||||
String mimeType = (String) entry.get("mimeType");
|
||||
|
||||
String oldEntryPath = oldDirPath + displayName;
|
||||
MediaScannerConnection.scanFile(context, new String[]{oldEntryPath}, new String[]{mimeType}, null);
|
||||
|
||||
SettableFuture<Map<String, Object>> scanFuture = SettableFuture.create();
|
||||
scanFutures.add(scanFuture);
|
||||
String newEntryPath = newDirPath + displayName;
|
||||
scanNewPath(context, newEntryPath, mimeType, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> newFields) {
|
||||
entry.putAll(newFields);
|
||||
entry.put("success", true);
|
||||
scanFuture.set(entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable throwable) {
|
||||
Log.w(LOG_TAG, "failed to scan entry=" + displayName + " in new directory=" + newDirPath, throwable);
|
||||
entry.put("success", false);
|
||||
scanFuture.set(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
callback.onSuccess(Futures.allAsList(scanFutures).get());
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
callback.onFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> listContentEntries(Context context, Uri contentUri, String dirPath) {
|
||||
List<Map<String, Object>> entries = new ArrayList<>();
|
||||
String[] projection = {
|
||||
MediaStore.MediaColumns._ID,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
};
|
||||
String selection = MediaStore.MediaColumns.DATA + " like ?";
|
||||
|
||||
try {
|
||||
Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[]{dirPath + "%"}, null);
|
||||
if (cursor != null) {
|
||||
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
|
||||
int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
|
||||
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
|
||||
while (cursor.moveToNext()) {
|
||||
entries.add(new HashMap<String, Object>() {{
|
||||
put("oldContentId", cursor.getInt(idColumn));
|
||||
put("displayName", cursor.getString(displayNameColumn));
|
||||
put("mimeType", cursor.getString(mimeTypeColumn));
|
||||
}});
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(LOG_TAG, "failed to list entries in contentUri=" + contentUri, e);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.JPEG:
|
||||
|
@ -209,8 +307,8 @@ public abstract class ImageProvider {
|
|||
}
|
||||
|
||||
// update fields in media store
|
||||
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalImage.getHeight();
|
||||
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalImage.getWidth();
|
||||
int rotatedWidth = originalImage.getHeight();
|
||||
int rotatedHeight = originalImage.getWidth();
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("width", rotatedWidth);
|
||||
newFields.put("height", rotatedHeight);
|
||||
|
@ -239,8 +337,6 @@ public abstract class ImageProvider {
|
|||
|
||||
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
|
||||
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
||||
|
||||
long contentId = 0;
|
||||
Uri contentUri = null;
|
||||
if (newUri != null) {
|
||||
|
@ -260,7 +356,11 @@ public abstract class ImageProvider {
|
|||
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||
String[] projection = {MediaStore.MediaColumns.TITLE};
|
||||
String[] projection = {
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.TITLE,
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
};
|
||||
try {
|
||||
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
|
||||
if (cursor != null) {
|
||||
|
@ -268,7 +368,9 @@ public abstract class ImageProvider {
|
|||
newFields.put("uri", contentUri.toString());
|
||||
newFields.put("contentId", contentId);
|
||||
newFields.put("path", path);
|
||||
newFields.put("displayName", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
|
||||
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
||||
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
|
@ -290,4 +392,10 @@ public abstract class ImageProvider {
|
|||
|
||||
void onFailure(Throwable throwable);
|
||||
}
|
||||
|
||||
public interface AlbumRenameOpCallback {
|
||||
void onSuccess(List<Map<String, Object>> fieldsByEntry);
|
||||
|
||||
void onFailure(Throwable throwable);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -18,14 +18,14 @@ class AlbumFilter extends CollectionFilter {
|
|||
|
||||
const AlbumFilter(this.album, this.uniqueName);
|
||||
|
||||
AlbumFilter.fromJson(Map<String, dynamic> json)
|
||||
AlbumFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
json['album'],
|
||||
json['uniqueName'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'album': album,
|
||||
'uniqueName': uniqueName,
|
||||
|
@ -41,8 +41,14 @@ class AlbumFilter extends CollectionFilter {
|
|||
String get tooltip => album;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
|
||||
return IconUtils.getAlbumIcon(context: context, album: album, size: size) ?? (showGenericIcon ? Icon(AIcons.album, size: size) : null);
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
return IconUtils.getAlbumIcon(
|
||||
context: context,
|
||||
album: album,
|
||||
size: size,
|
||||
embossed: embossed,
|
||||
) ??
|
||||
(showGenericIcon ? Icon(AIcons.album, size: size) : null);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -8,7 +8,7 @@ class FavouriteFilter extends CollectionFilter {
|
|||
static const type = 'favourite';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
};
|
||||
|
||||
|
@ -19,7 +19,7 @@ class FavouriteFilter extends CollectionFilter {
|
|||
String get label => 'Favourite';
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size);
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size);
|
||||
|
||||
@override
|
||||
String get typeKey => type;
|
||||
|
|
|
@ -27,17 +27,17 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
final type = jsonMap['type'];
|
||||
switch (type) {
|
||||
case AlbumFilter.type:
|
||||
return AlbumFilter.fromJson(jsonMap);
|
||||
return AlbumFilter.fromMap(jsonMap);
|
||||
case FavouriteFilter.type:
|
||||
return FavouriteFilter();
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromJson(jsonMap);
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case MimeFilter.type:
|
||||
return MimeFilter.fromJson(jsonMap);
|
||||
return MimeFilter.fromMap(jsonMap);
|
||||
case QueryFilter.type:
|
||||
return QueryFilter.fromJson(jsonMap);
|
||||
return QueryFilter.fromMap(jsonMap);
|
||||
case TagFilter.type:
|
||||
return TagFilter.fromJson(jsonMap);
|
||||
return TagFilter.fromMap(jsonMap);
|
||||
}
|
||||
debugPrint('failed to parse filter from json=$jsonString');
|
||||
return null;
|
||||
|
@ -45,7 +45,9 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
const CollectionFilter();
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
Map<String, dynamic> toMap();
|
||||
|
||||
String toJson() => jsonEncode(toMap());
|
||||
|
||||
bool filter(ImageEntry entry);
|
||||
|
||||
|
@ -55,7 +57,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
String get tooltip => label;
|
||||
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true});
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
|
||||
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(label));
|
||||
|
||||
|
|
|
@ -17,29 +17,33 @@ class LocationFilter extends CollectionFilter {
|
|||
if (split.length > 1) _countryCode = split[1];
|
||||
}
|
||||
|
||||
LocationFilter.fromJson(Map<String, dynamic> json)
|
||||
LocationFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
LocationLevel.values.firstWhere((v) => v.toString() == json['level'], orElse: () => null),
|
||||
json['location'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'level': level.toString(),
|
||||
'location': _countryCode != null ? '$_location$locationSeparator$_countryCode' : _location,
|
||||
'location': _countryCode != null ? countryNameAndCode : _location,
|
||||
};
|
||||
|
||||
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
||||
|
||||
@override
|
||||
bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
||||
bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
||||
|
||||
@override
|
||||
String get label => _location;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
final flag = countryCodeToFlag(_countryCode);
|
||||
if (flag != null) return Text(flag, style: TextStyle(fontSize: size));
|
||||
// as of Flutter v1.22.0-12.1.pre emoji shadows are rendered as colorful duplicates,
|
||||
// not filled with the shadow color as expected, so we remove them
|
||||
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
|
||||
return Icon(AIcons.location, size: size);
|
||||
}
|
||||
|
||||
|
|
|
@ -38,19 +38,27 @@ class MimeFilter extends CollectionFilter {
|
|||
_icon ??= AIcons.vector;
|
||||
}
|
||||
|
||||
MimeFilter.fromJson(Map<String, dynamic> json)
|
||||
MimeFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
json['mime'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'mime': mime,
|
||||
};
|
||||
|
||||
static String displayType(String mime) {
|
||||
return mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
|
||||
final patterns = [
|
||||
RegExp('.*/'), // remove type, keep subtype
|
||||
RegExp('(X-|VND.)'), // noisy prefixes
|
||||
'+XML', // noisy suffix
|
||||
RegExp('ADOBE[-\.]'), // for DNG, PSD...
|
||||
];
|
||||
mime = mime.toUpperCase();
|
||||
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
|
||||
return mime;
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -60,7 +68,7 @@ class MimeFilter extends CollectionFilter {
|
|||
String get label => _label;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
|
||||
|
||||
@override
|
||||
String get typeKey => type;
|
||||
|
|
|
@ -32,13 +32,13 @@ class QueryFilter extends CollectionFilter {
|
|||
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
|
||||
}
|
||||
|
||||
QueryFilter.fromJson(Map<String, dynamic> json)
|
||||
QueryFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
json['query'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'query': query,
|
||||
};
|
||||
|
@ -53,7 +53,7 @@ class QueryFilter extends CollectionFilter {
|
|||
String get label => '$query';
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size);
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size);
|
||||
|
||||
@override
|
||||
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white);
|
||||
|
|
|
@ -10,13 +10,13 @@ class TagFilter extends CollectionFilter {
|
|||
|
||||
const TagFilter(this.tag);
|
||||
|
||||
TagFilter.fromJson(Map<String, dynamic> json)
|
||||
TagFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
json['tag'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'tag': tag,
|
||||
};
|
||||
|
@ -31,7 +31,7 @@ class TagFilter extends CollectionFilter {
|
|||
String get label => tag;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(AIcons.tag, size: size) : null;
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(AIcons.tag, size: size) : null;
|
||||
|
||||
@override
|
||||
String get typeKey => type;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -131,6 +131,14 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
}
|
||||
|
||||
Future<void> updateEntryId(int oldId, ImageEntry entry) async {
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
_batchInsertEntry(batch, entry);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertEntry(Batch batch, ImageEntry entry) {
|
||||
if (entry == null) return;
|
||||
batch.insert(
|
||||
|
|
22
lib/model/settings/screen_on.dart
Normal file
22
lib/model/settings/screen_on.dart
Normal file
|
@ -0,0 +1,22 @@
|
|||
import 'package:screen/screen.dart';
|
||||
|
||||
enum KeepScreenOn { never, fullscreenOnly, always }
|
||||
|
||||
extension ExtraKeepScreenOn on KeepScreenOn {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case KeepScreenOn.never:
|
||||
return 'Never';
|
||||
case KeepScreenOn.fullscreenOnly:
|
||||
return 'Viewer page only';
|
||||
case KeepScreenOn.always:
|
||||
return 'Always';
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
void apply() {
|
||||
Screen.keepOn(this == KeepScreenOn.always);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
|
@ -23,6 +25,7 @@ class Settings extends ChangeNotifier {
|
|||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
static const homePageKey = 'home_page';
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
|
||||
|
@ -30,11 +33,15 @@ class Settings extends ChangeNotifier {
|
|||
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
static const collectionTileExtentKey = 'collection_tile_extent';
|
||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
||||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||
|
||||
// filter grids
|
||||
static const albumSortFactorKey = 'album_sort_factor';
|
||||
static const countrySortFactorKey = 'country_sort_factor';
|
||||
static const tagSortFactorKey = 'tag_sort_factor';
|
||||
static const pinnedFiltersKey = 'pinned_filters';
|
||||
|
||||
// info
|
||||
static const infoMapStyleKey = 'info_map_style';
|
||||
|
@ -75,6 +82,13 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
|
||||
|
||||
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, KeepScreenOn.fullscreenOnly, KeepScreenOn.values);
|
||||
|
||||
set keepScreenOn(KeepScreenOn newValue) {
|
||||
setAndNotify(keepScreenOnKey, newValue.toString());
|
||||
newValue.apply();
|
||||
}
|
||||
|
||||
HomePageSetting get homePage => getEnumOrDefault(homePageKey, HomePageSetting.collection, HomePageSetting.values);
|
||||
|
||||
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
|
||||
|
@ -97,6 +111,18 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
|
||||
|
||||
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
|
||||
|
||||
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
||||
|
||||
bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, true);
|
||||
|
||||
set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue);
|
||||
|
||||
bool get showThumbnailVideoDuration => getBoolOrDefault(showThumbnailVideoDurationKey, true);
|
||||
|
||||
set showThumbnailVideoDuration(bool newValue) => setAndNotify(showThumbnailVideoDurationKey, newValue);
|
||||
|
||||
// filter grids
|
||||
|
||||
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values);
|
||||
|
@ -111,6 +137,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString());
|
||||
|
||||
Set<CollectionFilter> get pinnedFilters => (_prefs.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
|
||||
|
||||
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
// info
|
||||
|
||||
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
|
||||
|
|
|
@ -9,13 +9,18 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
List<String> sortedAlbums = List.unmodifiable([]);
|
||||
|
||||
int compareAlbumsByName(String a, String b) {
|
||||
final ua = getUniqueAlbumName(a);
|
||||
final ub = getUniqueAlbumName(b);
|
||||
final c = compareAsciiUpperCase(ua, ub);
|
||||
if (c != 0) return c;
|
||||
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
||||
final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
|
||||
return compareAsciiUpperCase(va, vb);
|
||||
}
|
||||
|
||||
void updateAlbums() {
|
||||
final sorted = _folderPaths.toList()
|
||||
..sort((a, b) {
|
||||
final ua = getUniqueAlbumName(a);
|
||||
final ub = getUniqueAlbumName(b);
|
||||
return compareAsciiUpperCase(ua, ub);
|
||||
});
|
||||
final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
|
||||
sortedAlbums = List.unmodifiable(sorted);
|
||||
invalidateFilterEntryCounts();
|
||||
eventBus.fire(AlbumsChangedEvent());
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -137,16 +136,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
void _applySort() {
|
||||
switch (sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
_filteredEntries.sort((a, b) {
|
||||
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||
});
|
||||
_filteredEntries.sort(ImageEntry.compareByDate);
|
||||
break;
|
||||
case EntrySortFactor.size:
|
||||
_filteredEntries.sort((a, b) => b.sizeBytes.compareTo(a.sizeBytes));
|
||||
_filteredEntries.sort(ImageEntry.compareBySize);
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
_filteredEntries.sort((a, b) => compareAsciiUpperCase(a.bestTitle, b.bestTitle));
|
||||
_filteredEntries.sort(ImageEntry.compareByName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -178,16 +174,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
break;
|
||||
case EntrySortFactor.name:
|
||||
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
int compare(a, b) {
|
||||
final ua = source.getUniqueAlbumName(a);
|
||||
final ub = source.getUniqueAlbumName(b);
|
||||
final c = compareAsciiUpperCase(ua, ub);
|
||||
if (c != 0) return c;
|
||||
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
||||
final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
|
||||
return compareAsciiUpperCase(va, vb);
|
||||
}
|
||||
sections = SplayTreeMap.of(byAlbum, compare);
|
||||
sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName);
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
|
@ -87,7 +88,23 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
|||
invalidateFilterEntryCounts();
|
||||
}
|
||||
|
||||
void applyMove({
|
||||
Future<void> moveEntry(ImageEntry entry, Map newFields) async {
|
||||
final oldContentId = entry.contentId;
|
||||
final newContentId = newFields['contentId'] as int;
|
||||
entry.uri = newFields['uri'] as String;
|
||||
entry.path = newFields['path'] as String;
|
||||
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||
entry.contentId = newContentId;
|
||||
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
|
||||
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
|
||||
|
||||
await metadataDb.updateEntryId(oldContentId, entry);
|
||||
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
|
||||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
await favourites.move(oldContentId, entry);
|
||||
}
|
||||
|
||||
void updateAfterMove({
|
||||
@required Iterable<ImageEntry> entries,
|
||||
@required Set<String> fromAlbums,
|
||||
@required String toAlbum,
|
||||
|
|
|
@ -76,9 +76,13 @@ mixin LocationMixin on SourceBase {
|
|||
|
||||
void updateLocations() {
|
||||
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
|
||||
List<String> lister(String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
|
||||
sortedCountries = lister((address) => '${address.countryName}${LocationFilter.locationSeparator}${address.countryCode}');
|
||||
sortedPlaces = lister((address) => address.place);
|
||||
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
|
||||
|
||||
// the same country code could be found with different country names
|
||||
// e.g. if the locale changed between geolocating calls
|
||||
// so we merge countries by code, keeping only one name for each code
|
||||
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
|
||||
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
|
||||
|
||||
invalidateFilterEntryCounts();
|
||||
eventBus.fire(LocationsChangedEvent());
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -28,7 +26,7 @@ class AppShortcutService {
|
|||
try {
|
||||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
'label': label,
|
||||
'filters': filters.map((filter) => jsonEncode(filter.toJson())).toList(),
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
|
|
@ -40,7 +40,7 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Future<List> getObsoleteEntries(List<int> knownContentIds) async {
|
||||
static Future<List<int>> getObsoleteEntries(List<int> knownContentIds) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getObsoleteEntries', <String, dynamic>{
|
||||
'knownContentIds': knownContentIds,
|
||||
|
@ -194,6 +194,19 @@ class ImageFileService {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<List<Map>> renameDirectory(String path, String newName) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('renameDirectory', <String, dynamic>{
|
||||
'path': path,
|
||||
'newName': newName,
|
||||
});
|
||||
return (result as List).cast<Map>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -86,4 +88,28 @@ class MetadataService {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||
'uri': uri,
|
||||
});
|
||||
return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<List<Uint8List>> getXmpThumbnails(String uri) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
|
||||
'uri': uri,
|
||||
});
|
||||
return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class Constants {
|
||||
// as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
|
||||
|
@ -12,6 +13,13 @@ class Constants {
|
|||
fontFamily: 'Concourse Caps',
|
||||
);
|
||||
|
||||
static const embossShadow = Shadow(
|
||||
color: Colors.black87,
|
||||
offset: Offset(0.5, 1.0),
|
||||
);
|
||||
|
||||
static const pointNemo = Tuple2(-48.876667, -123.393333);
|
||||
|
||||
static const List<Dependency> androidDependencies = [
|
||||
Dependency(
|
||||
name: 'CWAC-Document',
|
||||
|
@ -58,6 +66,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/collection',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Decorated Icon',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Draggable Scrollbar',
|
||||
license: 'MIT',
|
||||
|
@ -77,10 +91,10 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Firebase Crashlytics',
|
||||
name: 'FlutterFire',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/packages/firebase_crashlytics/LICENSE',
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire/tree/master/packages/firebase_crashlytics',
|
||||
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flushbar',
|
||||
|
@ -94,6 +108,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Map',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/fleaflet/flutter_map',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Markdown',
|
||||
license: 'BSD 3-Clause',
|
||||
|
@ -137,10 +157,22 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/dart-lang/intl',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Outline Material Icons',
|
||||
name: 'LatLong',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/lucaslcode/outline_material_icons/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/lucaslcode/outline_material_icons',
|
||||
licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/MikeMitterer/dart-latlong',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Overlay Support',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/boyan01/overlay_support',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Package info',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Palette Generator',
|
||||
|
@ -220,6 +252,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/tuple',
|
||||
),
|
||||
Dependency(
|
||||
name: 'URL Launcher',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
|
||||
),
|
||||
Dependency(
|
||||
name: 'UUID',
|
||||
license: 'MIT',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/utils/durations.dart';
|
|||
import 'package:aves/widgets/collection/collection_actions.dart';
|
||||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||||
import 'package:aves/widgets/collection/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
|
@ -18,7 +19,7 @@ import 'package:aves/widgets/common/data_providers/media_store_collection_provid
|
|||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/common/menu_row.dart';
|
||||
import 'package:aves/widgets/filter_grids/search_button.dart';
|
||||
import 'package:aves/widgets/common/search_button.dart';
|
||||
import 'package:aves/widgets/stats/stats.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -306,7 +307,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_goToStats();
|
||||
break;
|
||||
case CollectionAction.addShortcut:
|
||||
unawaited(AppShortcutService.pin('Collection', collection.filters));
|
||||
unawaited(_showShortcutDialog(context));
|
||||
break;
|
||||
case CollectionAction.group:
|
||||
final value = await showDialog<EntryGroupFactor>(
|
||||
|
@ -348,6 +349,16 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _showShortcutDialog(BuildContext context) async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AddShortcutDialog(collection.filters),
|
||||
);
|
||||
if (name == null || name.isEmpty) return;
|
||||
|
||||
unawaited(AppShortcutService.pin(name, collection.filters));
|
||||
}
|
||||
|
||||
void _goToSearch() {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -108,7 +108,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
filter: filter,
|
||||
removable: true,
|
||||
heroType: HeroType.always,
|
||||
onPressed: (filter) {
|
||||
onTap: (filter) {
|
||||
_userRemovedFilter = filter;
|
||||
widget.onPressed(filter);
|
||||
},
|
||||
|
|
|
@ -10,14 +10,14 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
final Iterable<CollectionFilter> filters;
|
||||
final ValueNotifier<String> expandedNotifier;
|
||||
final HeroType Function(CollectionFilter filter) heroTypeBuilder;
|
||||
final FilterCallback onPressed;
|
||||
final FilterCallback onTap;
|
||||
|
||||
const ExpandableFilterRow({
|
||||
this.title,
|
||||
@required this.filters,
|
||||
this.expandedNotifier,
|
||||
this.heroTypeBuilder,
|
||||
@required this.onPressed,
|
||||
@required this.onTap,
|
||||
});
|
||||
|
||||
static const double horizontalPadding = 8;
|
||||
|
@ -107,7 +107,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||
onPressed: onPressed,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/utils/durations.dart';
|
||||
|
@ -8,6 +9,7 @@ import 'package:aves/widgets/common/fx/sweeper.dart';
|
|||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ThumbnailEntryOverlay extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
|
@ -23,26 +25,32 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final fontSize = min(14.0, (extent / 8)).roundToDouble();
|
||||
final iconSize = fontSize * 2;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (entry.hasGps) GpsIcon(iconSize: iconSize),
|
||||
if (entry.isAnimated)
|
||||
AnimatedImageIcon(iconSize: iconSize)
|
||||
else if (entry.isVideo)
|
||||
DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey[200],
|
||||
fontSize: fontSize,
|
||||
),
|
||||
child: VideoIcon(
|
||||
entry: entry,
|
||||
iconSize: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Selector<Settings, Tuple3<bool, bool, bool>>(
|
||||
selector: (context, s) => Tuple3(s.showThumbnailLocation, s.showThumbnailRaw, s.showThumbnailVideoDuration),
|
||||
builder: (context, s, child) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize),
|
||||
if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize),
|
||||
if (entry.isAnimated)
|
||||
AnimatedImageIcon(iconSize: iconSize)
|
||||
else if (entry.isVideo)
|
||||
DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey[200],
|
||||
fontSize: fontSize,
|
||||
),
|
||||
child: VideoIcon(
|
||||
entry: entry,
|
||||
iconSize: iconSize,
|
||||
showDuration: settings.showThumbnailVideoDuration,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/utils/durations.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/widgets/common/transition_image.dart';
|
||||
|
@ -91,6 +92,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
? fastImage
|
||||
: Image(
|
||||
key: ValueKey('HQ'),
|
||||
image: _sizedThumbnailProvider,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
return AnimatedSwitcher(
|
||||
|
@ -111,7 +113,17 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
child: frame == null ? fastImage : child,
|
||||
);
|
||||
},
|
||||
image: _sizedThumbnailProvider,
|
||||
errorBuilder: (context, error, stackTrace) => Center(
|
||||
child: Tooltip(
|
||||
message: error.toString(),
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
AIcons.error,
|
||||
size: extent / 2,
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
fit: BoxFit.cover,
|
||||
|
|
74
lib/widgets/common/action_delegates/add_shortcut_dialog.dart
Normal file
74
lib/widgets/common/action_delegates/add_shortcut_dialog.dart
Normal file
|
@ -0,0 +1,74 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class AddShortcutDialog extends StatefulWidget {
|
||||
final Set<CollectionFilter> filters;
|
||||
|
||||
const AddShortcutDialog(this.filters);
|
||||
|
||||
@override
|
||||
_AddShortcutDialogState createState() => _AddShortcutDialogState();
|
||||
}
|
||||
|
||||
class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final filters = List.from(widget.filters)..sort();
|
||||
if (filters.isEmpty) {
|
||||
_nameController.text = 'Collection';
|
||||
} else {
|
||||
_nameController.text = filters.first.label;
|
||||
}
|
||||
_validate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
content: TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Shortcut label',
|
||||
),
|
||||
autofocus: true,
|
||||
maxLength: 10,
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Add'.toUpperCase()),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
final name = _nameController.text ?? '';
|
||||
_isValidNotifier.value = name.isNotEmpty;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/durations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
@ -13,7 +14,9 @@ class CreateAlbumDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final FocusNode _nameFieldFocusNode = FocusNode();
|
||||
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
Set<StorageVolume> _allVolumes;
|
||||
|
@ -25,11 +28,13 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
_allVolumes = androidFileUtils.storageVolumes;
|
||||
_primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first);
|
||||
_selectedVolume = _primaryVolume;
|
||||
_nameFieldFocusNode.addListener(_onFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_nameFieldFocusNode.removeListener(_onFocus);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -37,6 +42,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
title: 'New Album',
|
||||
scrollController: _scrollController,
|
||||
scrollableContent: [
|
||||
if (_allVolumes.length > 1) ...[
|
||||
Padding(
|
||||
|
@ -73,9 +79,10 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
builder: (context, exists, child) {
|
||||
return TextField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFieldFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Album name',
|
||||
helperText: exists ? 'Album already exists' : '',
|
||||
hintText: 'Album name',
|
||||
),
|
||||
autofocus: _allVolumes.length == 1,
|
||||
onChanged: (_) => _validate(),
|
||||
|
@ -102,15 +109,34 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onFocus() async {
|
||||
// when the field gets focus, we wait for the soft keyboard to appear
|
||||
// then scroll to the bottom to make sure the field is in view
|
||||
if (_nameFieldFocusNode.hasFocus) {
|
||||
await Future.delayed(Durations.softKeyboardDisplayDelay);
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: Durations.dialogFieldReachAnimation,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildAlbumPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return join(_selectedVolume.path, 'Pictures', name);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
final path = _buildAlbumPath(_nameController.text);
|
||||
_existsNotifier.value = path.isEmpty ? false : await Directory(path).exists();
|
||||
_isValidNotifier.value = (_nameController.text ?? '').isNotEmpty;
|
||||
final newName = _nameController.text ?? '';
|
||||
final path = _buildAlbumPath(newName);
|
||||
final exists = newName.isNotEmpty && await Directory(path).exists();
|
||||
_existsNotifier.value = exists;
|
||||
_isValidNotifier.value = newName.isNotEmpty;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text));
|
||||
|
|
88
lib/widgets/common/action_delegates/rename_album_dialog.dart
Normal file
88
lib/widgets/common/action_delegates/rename_album_dialog.dart
Normal file
|
@ -0,0 +1,88 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class RenameAlbumDialog extends StatefulWidget {
|
||||
final String album;
|
||||
|
||||
const RenameAlbumDialog(this.album);
|
||||
|
||||
@override
|
||||
_RenameAlbumDialogState createState() => _RenameAlbumDialogState();
|
||||
}
|
||||
|
||||
class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
String get album => widget.album;
|
||||
|
||||
String get initialValue => path.basename(album);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController.text = initialValue;
|
||||
_validate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
content: ValueListenableBuilder<bool>(
|
||||
valueListenable: _existsNotifier,
|
||||
builder: (context, exists, child) {
|
||||
return TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'New name',
|
||||
helperText: exists ? 'Album already exists' : '',
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
);
|
||||
}),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Apply'.toUpperCase()),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _buildAlbumPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return path.join(path.dirname(album), name);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
final newName = _nameController.text ?? '';
|
||||
final path = _buildAlbumPath(newName);
|
||||
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
|
||||
_existsNotifier.value = exists && newName != initialValue;
|
||||
_isValidNotifier.value = newName.isNotEmpty && !exists;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
|
@ -25,6 +25,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle;
|
||||
_validate();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -38,6 +39,10 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
return AvesDialog(
|
||||
content: TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'New name',
|
||||
suffixText: entry.extension,
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
|
@ -60,13 +65,17 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
String _buildEntryPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return path.join(entry.directory, name + entry.extension);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
var newName = _nameController.text ?? '';
|
||||
if (newName.isNotEmpty) {
|
||||
newName += entry.extension;
|
||||
}
|
||||
final type = await FileSystemEntity.type(join(entry.directory, newName));
|
||||
_isValidNotifier.value = type == FileSystemEntityType.notFound;
|
||||
final newName = _nameController.text ?? '';
|
||||
final path = _buildEntryPath(newName);
|
||||
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
|
||||
debugPrint('TLAD path=$path exists=$exists');
|
||||
_isValidNotifier.value = newName.isNotEmpty && !exists;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
|
@ -18,7 +17,7 @@ import 'package:aves/widgets/common/aves_dialog.dart';
|
|||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -96,7 +95,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
onPressed: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -136,8 +135,10 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
uri: newFields['uri'] as String,
|
||||
path: newFields['path'] as String,
|
||||
contentId: newFields['contentId'] as int,
|
||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
|
||||
));
|
||||
});
|
||||
await metadataDb.saveEntries(movedEntries);
|
||||
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
|
||||
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
|
||||
} else {
|
||||
|
@ -147,20 +148,12 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
|
||||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
final oldContentId = entry.contentId;
|
||||
final newContentId = newFields['contentId'] as int;
|
||||
entry.uri = newFields['uri'] as String;
|
||||
entry.path = newFields['path'] as String;
|
||||
entry.contentId = newContentId;
|
||||
movedEntries.add(entry);
|
||||
|
||||
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
|
||||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
await favourites.move(oldContentId, entry);
|
||||
await source.moveEntry(entry, newFields);
|
||||
}
|
||||
});
|
||||
}
|
||||
source.applyMove(
|
||||
source.updateAfterMove(
|
||||
entries: movedEntries,
|
||||
fromAlbums: fromAlbums,
|
||||
toAlbum: destinationAlbum,
|
||||
|
|
|
@ -6,6 +6,7 @@ class AvesDialog extends AlertDialog {
|
|||
|
||||
AvesDialog({
|
||||
String title,
|
||||
ScrollController scrollController,
|
||||
List<Widget> scrollableContent,
|
||||
Widget content,
|
||||
@required List<Widget> actions,
|
||||
|
@ -31,6 +32,7 @@ class AvesDialog extends AlertDialog {
|
|||
),
|
||||
),
|
||||
child: ListView(
|
||||
controller: scrollController ?? ScrollController(),
|
||||
shrinkWrap: true,
|
||||
children: scrollableContent,
|
||||
),
|
||||
|
@ -38,7 +40,7 @@ class AvesDialog extends AlertDialog {
|
|||
),
|
||||
)
|
||||
: content,
|
||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
actions: actions,
|
||||
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef FilterCallback = void Function(CollectionFilter filter);
|
||||
typedef OffsetFilterCallback = void Function(CollectionFilter filter, Offset tapPosition);
|
||||
|
||||
enum HeroType { always, onTap, never }
|
||||
|
||||
|
@ -13,7 +15,8 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final Widget background;
|
||||
final Widget details;
|
||||
final HeroType heroType;
|
||||
final FilterCallback onPressed;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
||||
static final BorderRadius borderRadius = BorderRadius.circular(32);
|
||||
static const double outlineWidth = 2;
|
||||
|
@ -31,7 +34,8 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.background,
|
||||
this.details,
|
||||
this.heroType = HeroType.onTap,
|
||||
@required this.onPressed,
|
||||
@required this.onTap,
|
||||
this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -42,6 +46,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
Future<Color> _colorFuture;
|
||||
Color _outlineColor;
|
||||
bool _tapped;
|
||||
Offset _tapPosition;
|
||||
|
||||
CollectionFilter get filter => widget.filter;
|
||||
|
||||
|
@ -75,7 +80,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasBackground = widget.background != null;
|
||||
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon);
|
||||
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
||||
final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null;
|
||||
|
||||
Widget content = Row(
|
||||
|
@ -122,12 +127,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
color: Colors.black54,
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2.copyWith(
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black87,
|
||||
offset: Offset(0.5, 1.0),
|
||||
)
|
||||
],
|
||||
shadows: [Constants.embossShadow],
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
|
@ -160,12 +160,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
borderRadius: borderRadius,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: widget.onPressed != null
|
||||
onTapDown: (details) => _tapPosition = details.globalPosition,
|
||||
onTap: widget.onTap != null
|
||||
? () {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onPressed(filter));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter));
|
||||
setState(() => _tapped = true);
|
||||
}
|
||||
: null,
|
||||
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
|
|
|
@ -3,14 +3,18 @@ import 'package:flutter/widgets.dart';
|
|||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
typedef TextBuilder<T> = String Function(T value);
|
||||
|
||||
class AvesSelectionDialog<T> extends StatefulWidget {
|
||||
final T initialValue;
|
||||
final Map<T, String> options;
|
||||
final TextBuilder<T> optionSubtitleBuilder;
|
||||
final String title;
|
||||
|
||||
const AvesSelectionDialog({
|
||||
@required this.initialValue,
|
||||
@required this.options,
|
||||
this.optionSubtitleBuilder,
|
||||
@required this.title,
|
||||
});
|
||||
|
||||
|
@ -41,20 +45,30 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioListTile(T value, String title) => RadioListTile<T>(
|
||||
key: Key(value.toString()),
|
||||
value: value,
|
||||
groupValue: _selectedValue,
|
||||
onChanged: (v) {
|
||||
_selectedValue = v;
|
||||
Navigator.pop(context, _selectedValue);
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
title,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
Widget _buildRadioListTile(T value, String title) {
|
||||
return RadioListTile<T>(
|
||||
key: Key(value.toString()),
|
||||
value: value,
|
||||
groupValue: _selectedValue,
|
||||
onChanged: (v) {
|
||||
_selectedValue = v;
|
||||
Navigator.pop(context, _selectedValue);
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
title,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: widget.optionSubtitleBuilder != null
|
||||
? Text(
|
||||
widget.optionSubtitleBuilder(value),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -11,13 +11,13 @@ import 'package:aves/services/image_file_service.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:outline_material_icons/outline_material_icons.dart';
|
||||
|
||||
class DebugPage extends StatefulWidget {
|
||||
static const routeName = '/debug';
|
||||
|
@ -58,10 +58,10 @@ class DebugPageState extends State<DebugPage> {
|
|||
title: Text('Debug'),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(OMIcons.whatshot)),
|
||||
Tab(icon: Icon(OMIcons.settings)),
|
||||
Tab(icon: Icon(OMIcons.sdStorage)),
|
||||
Tab(icon: Icon(OMIcons.android)),
|
||||
Tab(icon: Icon(AIcons.debug)),
|
||||
Tab(icon: Icon(AIcons.settings)),
|
||||
Tab(icon: Icon(AIcons.removableStorage)),
|
||||
Tab(icon: Icon(AIcons.android)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
|
@ -7,25 +8,27 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class AlbumListPage extends StatelessWidget {
|
||||
static const routeName = '/albums';
|
||||
|
||||
final CollectionSource source;
|
||||
|
||||
static final ChipActionDelegate actionDelegate = AlbumChipActionDelegate();
|
||||
|
||||
const AlbumListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<Settings, ChipSortFactor>(
|
||||
selector: (context, s) => s.albumSortFactor,
|
||||
builder: (context, sortFactor, child) {
|
||||
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
|
||||
builder: (context, s, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: androidFileUtils.appNameChangeNotifier,
|
||||
builder: (context, child) => StreamBuilder(
|
||||
|
@ -33,9 +36,14 @@ class AlbumListPage extends StatelessWidget {
|
|||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Albums',
|
||||
actionDelegate: actionDelegate,
|
||||
chipSetActionDelegate: AlbumChipSetActionDelegate(),
|
||||
chipActionDelegate: AlbumChipActionDelegate(source: source),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.rename,
|
||||
],
|
||||
filterEntries: getAlbumEntries(source),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
filterBuilder: (album) => AlbumFilter(album, source.getUniqueAlbumName(album)),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
|
@ -50,35 +58,40 @@ class AlbumListPage extends StatelessWidget {
|
|||
// common with album selection page to move/copy entries
|
||||
|
||||
static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) {
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
final albums = source.sortedAlbums
|
||||
.map((album) => MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
))
|
||||
.toList();
|
||||
|
||||
switch (settings.albumSortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
albums.sort(FilterNavigationPage.compareChipByDate);
|
||||
return Map.fromEntries(albums);
|
||||
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allAlbumMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
|
||||
final unpinnedMapEntries = (byPin[false] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
case ChipSortFactor.name:
|
||||
default:
|
||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (var album in source.sortedAlbums) {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(album);
|
||||
break;
|
||||
case AlbumType.app:
|
||||
appAlbums.add(album);
|
||||
break;
|
||||
default:
|
||||
specialAlbums.add(album);
|
||||
break;
|
||||
if (pinned.contains(album)) {
|
||||
pinnedAlbums.add(album);
|
||||
} else {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(album);
|
||||
break;
|
||||
case AlbumType.app:
|
||||
appAlbums.add(album);
|
||||
break;
|
||||
default:
|
||||
specialAlbums.add(album);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
|
||||
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
|
||||
return MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
enum ChipAction {
|
||||
sort,
|
||||
}
|
95
lib/widgets/filter_grids/common/chip_action_delegate.dart
Normal file
95
lib/widgets/filter_grids/common/chip_action_delegate.dart
Normal file
|
@ -0,0 +1,95 @@
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/utils/durations.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/rename_album_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class ChipActionDelegate {
|
||||
Future<void> onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
|
||||
switch (action) {
|
||||
case ChipAction.pin:
|
||||
final pinnedFilters = settings.pinnedFilters..add(filter);
|
||||
settings.pinnedFilters = pinnedFilters;
|
||||
break;
|
||||
case ChipAction.unpin:
|
||||
final pinnedFilters = settings.pinnedFilters..remove(filter);
|
||||
settings.pinnedFilters = pinnedFilters;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||
final CollectionSource source;
|
||||
|
||||
AlbumChipActionDelegate({
|
||||
@required this.source,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async {
|
||||
await super.onActionSelected(context, filter, action);
|
||||
switch (action) {
|
||||
case ChipAction.rename:
|
||||
unawaited(_showRenameDialog(context, filter as AlbumFilter));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
|
||||
final album = filter.album;
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => RenameAlbumDialog(album),
|
||||
);
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
|
||||
final result = await ImageFileService.renameDirectory(album, newName);
|
||||
final bySuccess = groupBy<Map, bool>(result, (fields) => fields['success']);
|
||||
|
||||
final albumEntries = source.rawEntries.where(filter.filter);
|
||||
final movedEntries = <ImageEntry>[];
|
||||
await Future.forEach<Map>(bySuccess[true], (newFields) async {
|
||||
final oldContentId = newFields['oldContentId'];
|
||||
final entry = albumEntries.firstWhere((entry) => entry.contentId == oldContentId, orElse: () => null);
|
||||
if (entry != null) {
|
||||
movedEntries.add(entry);
|
||||
await source.moveEntry(entry, newFields);
|
||||
}
|
||||
});
|
||||
source.updateAfterMove(
|
||||
entries: movedEntries,
|
||||
fromAlbums: {album},
|
||||
toAlbum: path.join(path.dirname(album), newName),
|
||||
copy: false,
|
||||
);
|
||||
|
||||
final failed = bySuccess[false]?.length ?? 0;
|
||||
if (failed > 0) {
|
||||
showFeedback(context, 'Failed to move ${Intl.plural(failed, one: '$failed item', other: '$failed items')}');
|
||||
} else {
|
||||
showFeedback(context, 'Done!');
|
||||
}
|
||||
}
|
||||
}
|
37
lib/widgets/filter_grids/common/chip_actions.dart
Normal file
37
lib/widgets/filter_grids/common/chip_actions.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum ChipSetAction {
|
||||
sort,
|
||||
}
|
||||
|
||||
enum ChipAction {
|
||||
pin,
|
||||
unpin,
|
||||
rename,
|
||||
}
|
||||
|
||||
extension ExtraChipAction on ChipAction {
|
||||
String getText() {
|
||||
switch (this) {
|
||||
case ChipAction.pin:
|
||||
return 'Pin to top';
|
||||
case ChipAction.unpin:
|
||||
return 'Unpin from top';
|
||||
case ChipAction.rename:
|
||||
return 'Rename';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
switch (this) {
|
||||
case ChipAction.pin:
|
||||
case ChipAction.unpin:
|
||||
return AIcons.pin;
|
||||
case ChipAction.rename:
|
||||
return AIcons.rename;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -2,21 +2,21 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/utils/durations.dart';
|
||||
import 'package:aves/widgets/common/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
abstract class ChipActionDelegate {
|
||||
abstract class ChipSetActionDelegate {
|
||||
ChipSortFactor get sortFactor;
|
||||
|
||||
set sortFactor(ChipSortFactor factor);
|
||||
|
||||
Future<void> onChipActionSelected(BuildContext context, ChipAction action) async {
|
||||
Future<void> onActionSelected(BuildContext context, ChipSetAction action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
|
||||
switch (action) {
|
||||
case ChipAction.sort:
|
||||
case ChipSetAction.sort:
|
||||
await _showSortDialog(context);
|
||||
break;
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ abstract class ChipActionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
class AlbumChipActionDelegate extends ChipActionDelegate {
|
||||
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
|
||||
@override
|
||||
ChipSortFactor get sortFactor => settings.albumSortFactor;
|
||||
|
||||
|
@ -48,7 +48,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate {
|
|||
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
|
||||
}
|
||||
|
||||
class CountryChipActionDelegate extends ChipActionDelegate {
|
||||
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
|
||||
@override
|
||||
ChipSortFactor get sortFactor => settings.countrySortFactor;
|
||||
|
||||
|
@ -56,7 +56,7 @@ class CountryChipActionDelegate extends ChipActionDelegate {
|
|||
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
|
||||
}
|
||||
|
||||
class TagChipActionDelegate extends ChipActionDelegate {
|
||||
class TagChipSetActionDelegate extends ChipSetActionDelegate {
|
||||
@override
|
||||
ChipSortFactor get sortFactor => settings.tagSortFactor;
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,28 +15,34 @@ import 'package:aves/widgets/common/data_providers/media_query_data_provider.dar
|
|||
import 'package:aves/widgets/common/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/common/menu_row.dart';
|
||||
import 'package:aves/widgets/common/search_button.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/search_button.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilterNavigationPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipActionDelegate actionDelegate;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final Widget Function() emptyBuilder;
|
||||
final List<ChipAction> Function(CollectionFilter filter) chipActionsBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
@required this.actionDelegate,
|
||||
@required this.chipSetActionDelegate,
|
||||
@required this.chipActionDelegate,
|
||||
@required this.chipActionsBuilder,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.emptyBuilder,
|
||||
|
@ -66,7 +72,7 @@ class FilterNavigationPage extends StatelessWidget {
|
|||
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onPressed: (filter) => Navigator.pushAndRemoveUntil(
|
||||
onTap: (filter) => Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: CollectionPage.routeName),
|
||||
|
@ -79,24 +85,43 @@ class FilterNavigationPage extends StatelessWidget {
|
|||
),
|
||||
settings.navRemoveRoutePredicate(CollectionPage.routeName),
|
||||
),
|
||||
onLongPress: (filter, tapPosition) => _showMenu(context, filter, tapPosition),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
|
||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||
final touchArea = Size(40, 40);
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
items: chipActionsBuilder(filter)
|
||||
.map((action) => PopupMenuItem(
|
||||
value: action,
|
||||
child: MenuRow(text: action.getText(), icon: action.getIcon()),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
if (selectedAction != null) {
|
||||
unawaited(chipActionDelegate.onActionSelected(context, filter, selectedAction));
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
return [
|
||||
SearchButton(source),
|
||||
PopupMenuButton<ChipAction>(
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
key: Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: Key('menu-sort'),
|
||||
value: ChipAction.sort,
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) => actionDelegate.onChipActionSelected(context, action),
|
||||
onSelected: (action) => chipSetActionDelegate.onActionSelected(context, action),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
@ -111,7 +136,7 @@ class FilterNavigationPage extends StatelessWidget {
|
|||
));
|
||||
}
|
||||
|
||||
static int compareChipByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) {
|
||||
static int compareChipsByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) {
|
||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
|
@ -123,7 +148,8 @@ class FilterGridPage extends StatelessWidget {
|
|||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final Widget Function() emptyBuilder;
|
||||
final FilterCallback onPressed;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
||||
const FilterGridPage({
|
||||
@required this.source,
|
||||
|
@ -131,7 +157,8 @@ class FilterGridPage extends StatelessWidget {
|
|||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.emptyBuilder,
|
||||
@required this.onPressed,
|
||||
@required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
List<String> get filterKeys => filterEntries.keys.toList();
|
||||
|
@ -141,6 +168,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: DoubleBackPopScope(
|
||||
|
@ -164,12 +192,15 @@ class FilterGridPage extends StatelessWidget {
|
|||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final key = filterKeys[i];
|
||||
final filter = filterBuilder(key);
|
||||
final child = DecoratedFilterChip(
|
||||
key: Key(key),
|
||||
source: source,
|
||||
filter: filterBuilder(key),
|
||||
filter: filter,
|
||||
entry: filterEntries[key],
|
||||
onPressed: onPressed,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
return AnimationConfiguration.staggeredGrid(
|
||||
position: i,
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -6,31 +7,37 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class CountryListPage extends StatelessWidget {
|
||||
static const routeName = '/countries';
|
||||
|
||||
final CollectionSource source;
|
||||
|
||||
static final ChipActionDelegate actionDelegate = CountryChipActionDelegate();
|
||||
|
||||
const CountryListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<Settings, ChipSortFactor>(
|
||||
selector: (context, s) => s.countrySortFactor,
|
||||
builder: (context, sortFactor, child) {
|
||||
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple2(s.countrySortFactor, s.pinnedFilters),
|
||||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Countries',
|
||||
actionDelegate: actionDelegate,
|
||||
chipSetActionDelegate: CountryChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getCountryEntries(),
|
||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
|
@ -44,9 +51,11 @@ class CountryListPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
Map<String, ImageEntry> _getCountryEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
|
||||
final countries = source.sortedCountries.map((countryNameAndCode) {
|
||||
final allMapEntries = source.sortedCountries.map((countryNameAndCode) {
|
||||
final split = countryNameAndCode.split(LocationFilter.locationSeparator);
|
||||
ImageEntry entry;
|
||||
if (split.length > 1) {
|
||||
|
@ -56,12 +65,19 @@ class CountryListPage extends StatelessWidget {
|
|||
return MapEntry(countryNameAndCode, entry);
|
||||
}).toList();
|
||||
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
switch (settings.countrySortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
countries.sort(FilterNavigationPage.compareChipByDate);
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
break;
|
||||
case ChipSortFactor.name:
|
||||
// already sorted by name at the source level
|
||||
break;
|
||||
}
|
||||
return Map.fromEntries(countries);
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -6,31 +7,37 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class TagListPage extends StatelessWidget {
|
||||
static const routeName = '/tags';
|
||||
|
||||
final CollectionSource source;
|
||||
|
||||
static final ChipActionDelegate actionDelegate = TagChipActionDelegate();
|
||||
|
||||
const TagListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<Settings, ChipSortFactor>(
|
||||
selector: (context, s) => s.tagSortFactor,
|
||||
builder: (context, sortFactor, child) {
|
||||
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple2(s.tagSortFactor, s.pinnedFilters),
|
||||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Tags',
|
||||
actionDelegate: actionDelegate,
|
||||
chipSetActionDelegate: TagChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getTagEntries(),
|
||||
filterBuilder: (s) => TagFilter(s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
|
@ -44,20 +51,29 @@ class TagListPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
Map<String, ImageEntry> _getTagEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
final tags = source.sortedTags
|
||||
final allMapEntries = source.sortedTags
|
||||
.map((tag) => MapEntry(
|
||||
tag,
|
||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
|
||||
))
|
||||
.toList();
|
||||
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
switch (settings.tagSortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
tags.sort(FilterNavigationPage.compareChipByDate);
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
break;
|
||||
case ChipSortFactor.name:
|
||||
// already sorted by name at the source level
|
||||
break;
|
||||
}
|
||||
return Map.fromEntries(tags);
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
|
@ -23,6 +24,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:screen/screen.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class FullscreenBody extends StatefulWidget {
|
||||
|
@ -97,10 +99,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
collection: collection,
|
||||
showInfo: () => _goToVerticalPage(infoPage),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initVideoController();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
|
||||
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
|
||||
Screen.keepOn(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -327,6 +332,9 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
void _onLeave() {
|
||||
if (Navigator.canPop(context)) {
|
||||
_showSystemUI();
|
||||
if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) {
|
||||
Screen.keepOn(false);
|
||||
}
|
||||
} else {
|
||||
// exit app when trying to pop a fullscreen page that is a viewer for a single entry
|
||||
SystemNavigator.pop();
|
||||
|
|
|
@ -74,7 +74,7 @@ class BasicSection extends StatelessWidget {
|
|||
children: effectiveFilters
|
||||
.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
onPressed: onFilter,
|
||||
onTap: onFilter,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
|
|
|
@ -119,7 +119,7 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
children: filters
|
||||
.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
onPressed: widget.onFilter,
|
||||
onTap: widget.onFilter,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
|
|
|
@ -6,6 +6,8 @@ import 'package:aves/services/metadata_service.dart';
|
|||
import 'package:aves/widgets/common/highlight_title.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:expansion_tile_card/expansion_tile_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -27,10 +29,16 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
String _loadedMetadataUri;
|
||||
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
bool get isVisible => widget.visibleNotifier.value;
|
||||
|
||||
static const int maxValueLength = 140;
|
||||
|
||||
// directory names from metadata-extractor
|
||||
static const exifThumbnailDirectory = 'Exif Thumbnail';
|
||||
static const xmpDirectory = 'XMP';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -94,10 +102,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
fontSize: 18,
|
||||
),
|
||||
children: [
|
||||
Divider(thickness: 1.0, height: 1.0),
|
||||
Divider(thickness: 1, height: 1),
|
||||
SizedBox(height: 4),
|
||||
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
|
||||
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: EdgeInsets.all(8),
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(dir.tags),
|
||||
),
|
||||
],
|
||||
|
@ -112,9 +123,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
}
|
||||
|
||||
Future<void> _getMetadata() async {
|
||||
if (_loadedMetadataUri == widget.entry.uri) return;
|
||||
if (_loadedMetadataUri == entry.uri) return;
|
||||
if (isVisible) {
|
||||
final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {};
|
||||
final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {};
|
||||
_metadata = rawMetadata.entries.map((dirKV) {
|
||||
final directoryName = dirKV.key as String ?? '';
|
||||
final rawTags = dirKV.value as Map ?? {};
|
||||
|
@ -126,8 +137,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
}).where((kv) => kv != null)));
|
||||
return _MetadataDirectory(directoryName, tags);
|
||||
}).toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
_loadedMetadataUri = widget.entry.uri;
|
||||
..sort((a, b) => compareAsciiUpperCase(a.name, b.name));
|
||||
_loadedMetadataUri = entry.uri;
|
||||
} else {
|
||||
_metadata = [];
|
||||
_loadedMetadataUri = null;
|
||||
|
|
67
lib/widgets/fullscreen/info/metadata_thumbnail.dart
Normal file
67
lib/widgets/fullscreen/info/metadata_thumbnail.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum MetadataThumbnailSource { exif, xmp }
|
||||
|
||||
class MetadataThumbnails extends StatefulWidget {
|
||||
final MetadataThumbnailSource source;
|
||||
final ImageEntry entry;
|
||||
|
||||
const MetadataThumbnails({
|
||||
Key key,
|
||||
@required this.source,
|
||||
@required this.entry,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_MetadataThumbnailsState createState() => _MetadataThumbnailsState();
|
||||
}
|
||||
|
||||
class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
||||
Future<List<Uint8List>> _loader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
switch (widget.source) {
|
||||
case MetadataThumbnailSource.exif:
|
||||
_loader = MetadataService.getExifThumbnails(widget.entry.uri);
|
||||
break;
|
||||
case MetadataThumbnailSource.xmp:
|
||||
_loader = MetadataService.getXmpThumbnails(widget.entry.uri);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Uint8List>>(
|
||||
future: _loader,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
|
||||
final turns = (widget.entry.orientationDegrees / 90).round();
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
return Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
|
||||
child: Wrap(
|
||||
children: snapshot.data.map((bytes) {
|
||||
return RotatedBox(
|
||||
quarterTurns: turns,
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
scale: devicePixelRatio,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox.shrink();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/main.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
|
@ -19,7 +20,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:screen/screen.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
static const routeName = '/';
|
||||
|
@ -43,7 +43,7 @@ class _HomePageState extends State<HomePage> {
|
|||
super.initState();
|
||||
_setup();
|
||||
imageCache.maximumSizeBytes = 512 * (1 << 20);
|
||||
Screen.keepOn(true);
|
||||
settings.keepScreenOn.apply();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/highlight_title.dart';
|
||||
|
@ -49,6 +51,23 @@ class SettingsPage extends StatelessWidget {
|
|||
title: Text('Tap “back” twice to exit'),
|
||||
),
|
||||
SectionTitle('Display'),
|
||||
ListTile(
|
||||
title: Text('Keep screen on'),
|
||||
subtitle: Text(settings.keepScreenOn.name),
|
||||
onTap: () async {
|
||||
final value = await showDialog<KeepScreenOn>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<KeepScreenOn>(
|
||||
initialValue: settings.keepScreenOn,
|
||||
options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.name))),
|
||||
title: 'Keep Screen On',
|
||||
),
|
||||
);
|
||||
if (value != null) {
|
||||
settings.keepScreenOn = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('SVG background'),
|
||||
trailing: SvgBackgroundSelector(),
|
||||
|
@ -62,6 +81,11 @@ class SettingsPage extends StatelessWidget {
|
|||
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
|
||||
initialValue: settings.coordinateFormat,
|
||||
options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.name))),
|
||||
optionSubtitleBuilder: (dynamic value) {
|
||||
// dynamic declaration followed by cast, as workaround for generics limitation
|
||||
final formatter = (value as CoordinateFormat);
|
||||
return formatter.format(Constants.pointNemo);
|
||||
},
|
||||
title: 'Coordinate Format',
|
||||
),
|
||||
);
|
||||
|
@ -70,6 +94,22 @@ class SettingsPage extends StatelessWidget {
|
|||
}
|
||||
},
|
||||
),
|
||||
SectionTitle('Thumbnails'),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailLocation,
|
||||
onChanged: (v) => settings.showThumbnailLocation = v,
|
||||
title: Text('Show location icon'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailRaw,
|
||||
onChanged: (v) => settings.showThumbnailRaw = v,
|
||||
title: Text('Show raw icon'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailVideoDuration,
|
||||
onChanged: (v) => settings.showThumbnailVideoDuration = v,
|
||||
title: Text('Show video duration'),
|
||||
),
|
||||
SectionTitle('Privacy'),
|
||||
SwitchListTile(
|
||||
value: settings.isCrashlyticsEnabled,
|
||||
|
|
|
@ -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)
|
||||
|
|
14
pubspec.lock
14
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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in a new issue