fullscreen: added rotate action
This commit is contained in:
parent
5571f9f236
commit
0c8318444b
14 changed files with 515 additions and 148 deletions
|
@ -5,7 +5,7 @@ import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
|
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.ImageDecodeHandler;
|
import deckers.thibault.aves.channelhandlers.ImageFileHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
||||||
import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
||||||
import deckers.thibault.aves.utils.Constants;
|
import deckers.thibault.aves.utils.Constants;
|
||||||
|
@ -27,7 +27,7 @@ public class MainActivity extends FlutterActivity {
|
||||||
|
|
||||||
FlutterView messenger = getFlutterView();
|
FlutterView messenger = getFlutterView();
|
||||||
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
|
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
|
||||||
new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler));
|
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
|
||||||
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
||||||
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
|
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,14 +26,14 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
public static final String CHANNEL = "deckers.thibault/aves/image";
|
public static final String CHANNEL = "deckers.thibault/aves/image";
|
||||||
|
|
||||||
private Activity activity;
|
private Activity activity;
|
||||||
private ImageDecodeTaskManager imageDecodeTaskManager;
|
private ImageDecodeTaskManager imageDecodeTaskManager;
|
||||||
private MediaStoreStreamHandler mediaStoreStreamHandler;
|
private MediaStoreStreamHandler mediaStoreStreamHandler;
|
||||||
|
|
||||||
public ImageDecodeHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
|
public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
|
||||||
this.activity = activity;
|
this.activity = activity;
|
||||||
imageDecodeTaskManager = new ImageDecodeTaskManager(activity);
|
imageDecodeTaskManager = new ImageDecodeTaskManager(activity);
|
||||||
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
|
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
|
||||||
|
@ -54,6 +54,9 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
||||||
case "rename":
|
case "rename":
|
||||||
rename(call, result);
|
rename(call, result);
|
||||||
break;
|
break;
|
||||||
|
case "rotate":
|
||||||
|
rotate(call, result);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result.notImplemented();
|
result.notImplemented();
|
||||||
break;
|
break;
|
||||||
|
@ -94,7 +97,7 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
||||||
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
|
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() {
|
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Map<String, Object> newFields) {
|
public void onSuccess(Map<String, Object> newFields) {
|
||||||
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||||
|
@ -107,6 +110,35 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
Map map = call.argument("entry");
|
||||||
|
Boolean clockwise = call.argument("clockwise");
|
||||||
|
if (map == null || clockwise == null) {
|
||||||
|
result.error("rotate-args", "failed because of missing arguments", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri uri = Uri.parse((String) map.get("uri"));
|
||||||
|
String path = (String) map.get("path");
|
||||||
|
String mimeType = (String) map.get("mimeType");
|
||||||
|
|
||||||
|
ImageProvider provider = ImageProviderFactory.getProvider(uri);
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Map<String, Object> newFields) {
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure() {
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void getPermissionResult(final MethodChannel.Result result, final Activity activity) {
|
private void getPermissionResult(final MethodChannel.Result result, final Activity activity) {
|
||||||
Dexter.withActivity(activity)
|
Dexter.withActivity(activity)
|
||||||
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
@ -30,7 +30,7 @@ import java.util.Map;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
|
||||||
import deckers.thibault.aves.utils.Constants;
|
import deckers.thibault.aves.utils.Constants;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.MetadataHelper;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
|
@ -200,7 +200,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
retriever.release();
|
retriever.release();
|
||||||
|
|
||||||
if (dateString != null) {
|
if (dateString != null) {
|
||||||
long dateMillis = Utils.parseVideoMetadataDate(dateString);
|
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
|
||||||
// some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time
|
// some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time
|
||||||
if (dateMillis > 0) {
|
if (dateMillis > 0) {
|
||||||
metadataMap.put("dateMillis", dateMillis);
|
metadataMap.put("dateMillis", dateMillis);
|
||||||
|
|
|
@ -2,17 +2,29 @@ package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.ContentUris;
|
import android.content.ContentUris;
|
||||||
|
import android.content.ContentValues;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.media.ExifInterface;
|
||||||
import android.media.MediaScannerConnection;
|
import android.media.MediaScannerConnection;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
import android.provider.MediaStore;
|
import android.provider.MediaStore;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileDescriptor;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import deckers.thibault.aves.utils.Constants;
|
||||||
import deckers.thibault.aves.utils.Env;
|
import deckers.thibault.aves.utils.Env;
|
||||||
|
import deckers.thibault.aves.utils.MetadataHelper;
|
||||||
import deckers.thibault.aves.utils.PermissionManager;
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
import deckers.thibault.aves.utils.StorageUtils;
|
import deckers.thibault.aves.utils.StorageUtils;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.Utils;
|
||||||
|
@ -20,7 +32,7 @@ import deckers.thibault.aves.utils.Utils;
|
||||||
public abstract class ImageProvider {
|
public abstract class ImageProvider {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
|
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
|
||||||
|
|
||||||
public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final RenameCallback callback) {
|
public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
|
||||||
if (oldPath == null) {
|
if (oldPath == null) {
|
||||||
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
|
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
|
||||||
callback.onFailure();
|
callback.onFailure();
|
||||||
|
@ -40,10 +52,7 @@ public abstract class ImageProvider {
|
||||||
// From KitKat, we need access permission from the Document Provider, at the file level.
|
// From KitKat, we need access permission from the Document Provider, at the file level.
|
||||||
// From Lollipop, we can request the permission at the SD card root level.
|
// From Lollipop, we can request the permission at the SD card root level.
|
||||||
boolean renamed;
|
boolean renamed;
|
||||||
if (!Env.isOnSdCard(activity, oldPath)) {
|
if (Env.isOnSdCard(activity, oldPath)) {
|
||||||
// rename with File
|
|
||||||
renamed = oldFile.renameTo(newFile);
|
|
||||||
} else {
|
|
||||||
// rename with DocumentFile
|
// rename with DocumentFile
|
||||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||||
if (sdCardTreeUri == null) {
|
if (sdCardTreeUri == null) {
|
||||||
|
@ -52,6 +61,9 @@ public abstract class ImageProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
|
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
|
||||||
|
} else {
|
||||||
|
// rename with File
|
||||||
|
renamed = oldFile.renameTo(newFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!renamed) {
|
if (!renamed) {
|
||||||
|
@ -89,7 +101,148 @@ public abstract class ImageProvider {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface RenameCallback {
|
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||||
|
switch (mimeType) {
|
||||||
|
case Constants.MIME_JPEG:
|
||||||
|
rotateJpeg(activity, path, uri, mimeType, clockwise, callback);
|
||||||
|
break;
|
||||||
|
case Constants.MIME_PNG:
|
||||||
|
rotatePng(activity, path, uri, mimeType, clockwise, callback);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
callback.onFailure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rotateJpeg(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) {
|
||||||
|
String editablePath = path;
|
||||||
|
boolean onSdCard = Env.isOnSdCard(activity, path);
|
||||||
|
if (onSdCard) {
|
||||||
|
if (PermissionManager.getSdCardTreeUri(activity) == null) {
|
||||||
|
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
|
||||||
|
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// copy original file to a temporary file for editing
|
||||||
|
editablePath = StorageUtils.copyFileToTemp(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editablePath == null) {
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean rotated = false;
|
||||||
|
int newOrientationCode = 0;
|
||||||
|
try {
|
||||||
|
ExifInterface exif = new ExifInterface(editablePath);
|
||||||
|
switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_90:
|
||||||
|
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_180 : ExifInterface.ORIENTATION_NORMAL;
|
||||||
|
break;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_180:
|
||||||
|
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_270 : ExifInterface.ORIENTATION_ROTATE_90;
|
||||||
|
break;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_270:
|
||||||
|
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_NORMAL : ExifInterface.ORIENTATION_ROTATE_180;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_90 : ExifInterface.ORIENTATION_ROTATE_270;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode));
|
||||||
|
exif.saveAttributes();
|
||||||
|
|
||||||
|
// if the image is on the SD card, copy the edited temporary file to the original DocumentFile
|
||||||
|
rotated = !onSdCard || StorageUtils.writeToDocumentFile(activity, editablePath, uri);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e);
|
||||||
|
}
|
||||||
|
if (!rotated) {
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update fields in media store
|
||||||
|
ContentValues values = new ContentValues();
|
||||||
|
int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode);
|
||||||
|
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
||||||
|
if (activity.getContentResolver().update(uri, values, null, null) > 0) {
|
||||||
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
|
||||||
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
|
newFields.put("orientationDegrees", orientationDegrees);
|
||||||
|
callback.onSuccess(newFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rotatePng(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) {
|
||||||
|
if (path == null) {
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean onSdCard = Env.isOnSdCard(activity, path);
|
||||||
|
if (onSdCard && PermissionManager.getSdCardTreeUri(activity) == null) {
|
||||||
|
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
|
||||||
|
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap originalImage = BitmapFactory.decodeFile(path);
|
||||||
|
Matrix matrix = new Matrix();
|
||||||
|
int originalWidth = originalImage.getWidth();
|
||||||
|
int originalHeight = originalImage.getHeight();
|
||||||
|
matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1);
|
||||||
|
Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true);
|
||||||
|
|
||||||
|
boolean rotated = false;
|
||||||
|
if (onSdCard) {
|
||||||
|
FileDescriptor fd = null;
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor pfd = activity.getContentResolver().openFileDescriptor(uri, "rw");
|
||||||
|
if (pfd != null) fd = pfd.getFileDescriptor();
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to get file descriptor for document at uri=" + path, e);
|
||||||
|
}
|
||||||
|
if (fd != null) {
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(fd)) {
|
||||||
|
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
|
||||||
|
rotated = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to save rotated image to document at uri=" + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(path)) {
|
||||||
|
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
|
||||||
|
rotated = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to save rotated image to path=" + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rotated) {
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update fields in media store
|
||||||
|
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight;
|
||||||
|
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth;
|
||||||
|
ContentValues values = new ContentValues();
|
||||||
|
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||||
|
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||||
|
if (activity.getContentResolver().update(uri, values, null, null) > 0) {
|
||||||
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
|
||||||
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
|
newFields.put("width", rotatedWidth);
|
||||||
|
newFields.put("height", rotatedHeight);
|
||||||
|
callback.onSuccess(newFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ImageOpCallback {
|
||||||
void onSuccess(Map<String, Object> newFields);
|
void onSuccess(Map<String, Object> newFields);
|
||||||
|
|
||||||
void onFailure();
|
void onFailure();
|
||||||
|
|
|
@ -10,9 +10,11 @@ public class Constants {
|
||||||
|
|
||||||
// mime types
|
// mime types
|
||||||
|
|
||||||
public static final String MIME_VIDEO = "video";
|
|
||||||
public static final String MIME_GIF = "image/gif";
|
public static final String MIME_GIF = "image/gif";
|
||||||
|
public static final String MIME_JPEG = "image/jpeg";
|
||||||
|
public static final String MIME_PNG = "image/png";
|
||||||
public static final String MIME_MP2TS = "video/mp2ts";
|
public static final String MIME_MP2TS = "video/mp2ts";
|
||||||
|
public static final String MIME_VIDEO = "video";
|
||||||
|
|
||||||
// video metadata keys, from android.media.MediaMetadataRetriever
|
// video metadata keys, from android.media.MediaMetadataRetriever
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,6 @@ import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
|
|
||||||
public class Env {
|
public class Env {
|
||||||
private static String[] mStorageVolumes;
|
private static String[] mStorageVolumes;
|
||||||
private static String mExternalStorage;
|
private static String mExternalStorage;
|
||||||
|
@ -45,7 +43,7 @@ public class Env {
|
||||||
return mExternalStorage;
|
return mExternalStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isOnSdCard(final Activity activity, @NonNull String path) {
|
public static boolean isOnSdCard(final Activity activity, String path) {
|
||||||
return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
return path != null && !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
|
import android.media.ExifInterface;
|
||||||
|
|
||||||
|
import java.text.DateFormat;
|
||||||
|
import java.text.ParseException;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public class MetadataHelper {
|
||||||
|
|
||||||
|
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||||
|
public static int getOrientationDegreesForExifCode(int exifOrientation) {
|
||||||
|
switch (exifOrientation) {
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_180: // bottom, right side
|
||||||
|
return 180;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_90: // right side, top
|
||||||
|
return 90;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_270: // left side, bottom
|
||||||
|
return 270;
|
||||||
|
}
|
||||||
|
// all other orientations (regular, flipped...) default to an angle of 0 degree
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||||
|
public static long parseVideoMetadataDate(String dateString) {
|
||||||
|
// optional sub-second
|
||||||
|
String subSecond = null;
|
||||||
|
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
|
||||||
|
if (subSecondMatcher.find()) {
|
||||||
|
subSecond = subSecondMatcher.group(2).substring(1);
|
||||||
|
dateString = subSecondMatcher.replaceAll("$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional time zone
|
||||||
|
TimeZone timeZone = null;
|
||||||
|
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
|
||||||
|
if (timeZoneMatcher.find()) {
|
||||||
|
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
|
||||||
|
dateString = timeZoneMatcher.replaceAll("");
|
||||||
|
}
|
||||||
|
|
||||||
|
Date date = null;
|
||||||
|
try {
|
||||||
|
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
||||||
|
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
|
||||||
|
date = parser.parse(dateString);
|
||||||
|
} catch (ParseException ex) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
long dateMillis = date.getTime();
|
||||||
|
if (subSecond != null) {
|
||||||
|
try {
|
||||||
|
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
|
||||||
|
if (millis >= 0 && millis < 1000) {
|
||||||
|
dateMillis += millis;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dateMillis;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,8 +5,10 @@ import android.content.Context;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
import android.webkit.MimeTypeMap;
|
||||||
|
|
||||||
import androidx.documentfile.provider.DocumentFile;
|
import androidx.documentfile.provider.DocumentFile;
|
||||||
|
|
||||||
|
@ -14,6 +16,7 @@ import com.google.common.base.Splitter;
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
@ -164,6 +167,34 @@ public class StorageUtils {
|
||||||
return found && documentFile != null ? Optional.of(documentFile) : Optional.empty();
|
return found && documentFile != null ? Optional.of(documentFile) : Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String copyFileToTemp(String path) {
|
||||||
|
try {
|
||||||
|
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
|
||||||
|
File temp = File.createTempFile("aves", '.' + extension);
|
||||||
|
Utils.copyFile(new File(path), temp);
|
||||||
|
temp.deleteOnExit();
|
||||||
|
return temp.getPath();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to copy file at path=" + path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean writeToDocumentFile(Context context, String from, Uri documentUri) {
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(documentUri, "rw");
|
||||||
|
if (pfd == null) {
|
||||||
|
Log.w(LOG_TAG, "failed to get file descriptor for documentUri=" + documentUri);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Utils.copyFile(new File(from), pfd.getFileDescriptor());
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(LOG_TAG, "failed to write to DocumentFile at documentUri=" + documentUri);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the specified file on SD card
|
* Delete the specified file on SD card
|
||||||
* Note that it does not update related content providers such as the Media Store.
|
* Note that it does not update related content providers such as the Media Store.
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
package deckers.thibault.aves.utils;
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
import java.text.DateFormat;
|
import java.io.File;
|
||||||
import java.text.ParseException;
|
import java.io.FileDescriptor;
|
||||||
import java.text.SimpleDateFormat;
|
import java.io.FileInputStream;
|
||||||
import java.util.Date;
|
import java.io.FileOutputStream;
|
||||||
import java.util.Locale;
|
import java.io.IOException;
|
||||||
import java.util.TimeZone;
|
import java.nio.channels.FileChannel;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class Utils {
|
public class Utils {
|
||||||
|
@ -29,48 +28,27 @@ public class Utils {
|
||||||
return logTag;
|
return logTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
public static void copyFile(final File source, final FileDescriptor descriptor) throws IOException {
|
||||||
public static long parseVideoMetadataDate(String dateString) {
|
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(descriptor)) {
|
||||||
// optional sub-second
|
final FileChannel inChannel = inStream.getChannel();
|
||||||
String subSecond = null;
|
final FileChannel outChannel = outStream.getChannel();
|
||||||
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
|
final long size = inChannel.size();
|
||||||
if (subSecondMatcher.find()) {
|
long position = 0;
|
||||||
subSecond = subSecondMatcher.group(2).substring(1);
|
while (position < size) {
|
||||||
dateString = subSecondMatcher.replaceAll("$1");
|
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// optional time zone
|
}
|
||||||
TimeZone timeZone = null;
|
|
||||||
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
|
public static void copyFile(final File source, final File destination) throws IOException {
|
||||||
if (timeZoneMatcher.find()) {
|
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(destination)) {
|
||||||
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
|
final FileChannel inChannel = inStream.getChannel();
|
||||||
dateString = timeZoneMatcher.replaceAll("");
|
final FileChannel outChannel = outStream.getChannel();
|
||||||
}
|
final long size = inChannel.size();
|
||||||
|
long position = 0;
|
||||||
Date date = null;
|
while (position < size) {
|
||||||
try {
|
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
|
||||||
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
|
||||||
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
|
|
||||||
date = parser.parse(dateString);
|
|
||||||
} catch (ParseException ex) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if (date == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
long dateMillis = date.getTime();
|
|
||||||
if (subSecond != null) {
|
|
||||||
try {
|
|
||||||
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
|
|
||||||
if (millis >= 0 && millis < 1000) {
|
|
||||||
dateMillis += millis;
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dateMillis;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -15,9 +15,9 @@ class ImageEntry with ChangeNotifier {
|
||||||
String path;
|
String path;
|
||||||
int contentId;
|
int contentId;
|
||||||
final String mimeType;
|
final String mimeType;
|
||||||
final int width;
|
int width;
|
||||||
final int height;
|
int height;
|
||||||
final int orientationDegrees;
|
int orientationDegrees;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
String title;
|
String title;
|
||||||
final int dateModifiedSecs;
|
final int dateModifiedSecs;
|
||||||
|
@ -205,4 +205,20 @@ class ImageEntry with ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get canRotate => mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG;
|
||||||
|
|
||||||
|
Future<bool> rotate({@required bool clockwise}) async {
|
||||||
|
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||||||
|
if (newFields.isEmpty) return false;
|
||||||
|
|
||||||
|
final width = newFields['width'];
|
||||||
|
if (width != null) this.width = width;
|
||||||
|
final height = newFields['height'];
|
||||||
|
if (height != null) this.height = height;
|
||||||
|
final orientationDegrees = newFields['orientationDegrees'];
|
||||||
|
if (orientationDegrees != null) this.orientationDegrees = orientationDegrees;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,4 +55,18 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
return Map();
|
return Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||||
|
try {
|
||||||
|
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
|
||||||
|
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||||
|
'entry': entry.toMap(),
|
||||||
|
'clockwise': clockwise,
|
||||||
|
}) as Map;
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('rotate failed with exception=${e.message}');
|
||||||
|
}
|
||||||
|
return Map();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,13 +33,14 @@ class ThumbnailState extends State<Thumbnail> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
entry.addListener(onEntryChange);
|
||||||
initByteLoader();
|
initByteLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(Thumbnail oldWidget) {
|
void didUpdateWidget(Thumbnail oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (uri == oldWidget.entry.uri && widget.extent == oldWidget.extent) return;
|
if (widget.extent == oldWidget.extent && uri == oldWidget.entry.uri && widget.entry.width == oldWidget.entry.width && widget.entry.height == oldWidget.entry.height && widget.entry.orientationDegrees == oldWidget.entry.orientationDegrees) return;
|
||||||
initByteLoader();
|
initByteLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,8 +49,11 @@ class ThumbnailState extends State<Thumbnail> {
|
||||||
_byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim);
|
_byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEntryChange() => setState(() => initByteLoader());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
entry.removeListener(onEntryChange);
|
||||||
ImageFileService.cancelGetImageBytes(uri);
|
ImageFileService.cancelGetImageBytes(uri);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
@ -74,43 +78,61 @@ class ThumbnailState extends State<Thumbnail> {
|
||||||
future: _byteLoader,
|
future: _byteLoader,
|
||||||
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
|
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
|
||||||
final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage;
|
final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage;
|
||||||
return Stack(
|
return ThumbnailImage(entry: entry, bytes: bytes, iconSize: iconSize);
|
||||||
alignment: AlignmentDirectional.bottomStart,
|
|
||||||
children: [
|
|
||||||
Hero(
|
|
||||||
tag: uri,
|
|
||||||
child: LayoutBuilder(builder: (context, constraints) {
|
|
||||||
// during hero animation back from a fullscreen image,
|
|
||||||
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
|
|
||||||
// so we wrap the image to apply better constraints
|
|
||||||
final dim = min(constraints.maxWidth, constraints.maxHeight);
|
|
||||||
return Container(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
constraints: BoxConstraints.tight(Size(dim, dim)),
|
|
||||||
child: bytes.length > 0
|
|
||||||
? Image.memory(
|
|
||||||
bytes,
|
|
||||||
width: dim,
|
|
||||||
height: dim,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: Icon(Icons.error),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
if (entry.isVideo)
|
|
||||||
VideoTag(
|
|
||||||
entry: entry,
|
|
||||||
iconSize: iconSize,
|
|
||||||
)
|
|
||||||
else if (entry.isGif)
|
|
||||||
GifTag(iconSize: iconSize)
|
|
||||||
else if (entry.hasGps)
|
|
||||||
GpsTag(iconSize: iconSize)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ThumbnailImage extends StatelessWidget {
|
||||||
|
final Uint8List bytes;
|
||||||
|
final ImageEntry entry;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const ThumbnailImage({
|
||||||
|
Key key,
|
||||||
|
@required this.bytes,
|
||||||
|
@required this.entry,
|
||||||
|
@required this.iconSize,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
alignment: AlignmentDirectional.bottomStart,
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: entry.uri,
|
||||||
|
child: LayoutBuilder(builder: (context, constraints) {
|
||||||
|
// during hero animation back from a fullscreen image,
|
||||||
|
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
|
||||||
|
// so we wrap the image to apply better constraints
|
||||||
|
final dim = min(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
constraints: BoxConstraints.tight(Size(dim, dim)),
|
||||||
|
child: bytes.length > 0
|
||||||
|
? Image.memory(
|
||||||
|
bytes,
|
||||||
|
width: dim,
|
||||||
|
height: dim,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Icon(Icons.error),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (entry.isVideo)
|
||||||
|
VideoTag(
|
||||||
|
entry: entry,
|
||||||
|
iconSize: iconSize,
|
||||||
|
)
|
||||||
|
else if (entry.isGif)
|
||||||
|
GifTag(iconSize: iconSize)
|
||||||
|
else if (entry.hasGps)
|
||||||
|
GpsTag(iconSize: iconSize)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -213,6 +213,56 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
||||||
|
switch (action) {
|
||||||
|
case FullscreenAction.edit:
|
||||||
|
AndroidAppService.edit(entry.uri, entry.mimeType);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.info:
|
||||||
|
goToVerticalPage(1);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.rename:
|
||||||
|
showRenameDialog(entry);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.open:
|
||||||
|
AndroidAppService.open(entry.uri, entry.mimeType);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.openMap:
|
||||||
|
AndroidAppService.openMap(entry.geoUri);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.rotateCCW:
|
||||||
|
rotate(entry, clockwise: false);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.rotateCW:
|
||||||
|
rotate(entry, clockwise: true);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.setAs:
|
||||||
|
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||||
|
break;
|
||||||
|
case FullscreenAction.share:
|
||||||
|
AndroidAppService.share(entry.uri, entry.mimeType);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showFeedback(String message) {
|
||||||
|
Flushbar(
|
||||||
|
message: message,
|
||||||
|
margin: EdgeInsets.all(8),
|
||||||
|
borderRadius: 8,
|
||||||
|
borderColor: Colors.white30,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
flushbarPosition: FlushbarPosition.TOP,
|
||||||
|
animationDuration: Duration(milliseconds: 600),
|
||||||
|
)..show(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||||
|
final success = await entry.rotate(clockwise: clockwise);
|
||||||
|
showFeedback(success ? 'Done!' : 'Failed');
|
||||||
|
}
|
||||||
|
|
||||||
showRenameDialog(ImageEntry entry) async {
|
showRenameDialog(ImageEntry entry) async {
|
||||||
final currentName = entry.title;
|
final currentName = entry.title;
|
||||||
final controller = TextEditingController(text: currentName);
|
final controller = TextEditingController(text: currentName);
|
||||||
|
@ -238,46 +288,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
||||||
});
|
});
|
||||||
if (newName == null || newName.isEmpty) return;
|
if (newName == null || newName.isEmpty) return;
|
||||||
final success = await entry.rename(newName);
|
final success = await entry.rename(newName);
|
||||||
Flushbar(
|
showFeedback(success ? 'Done!' : 'Failed');
|
||||||
message: success ? 'Done!' : 'Failed',
|
|
||||||
margin: EdgeInsets.all(8),
|
|
||||||
borderRadius: 8,
|
|
||||||
borderColor: Colors.white30,
|
|
||||||
borderWidth: 0.5,
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
flushbarPosition: FlushbarPosition.TOP,
|
|
||||||
animationDuration: Duration(milliseconds: 600),
|
|
||||||
)..show(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
|
||||||
switch (action) {
|
|
||||||
case FullscreenAction.edit:
|
|
||||||
AndroidAppService.edit(entry.uri, entry.mimeType);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.info:
|
|
||||||
goToVerticalPage(1);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.rename:
|
|
||||||
showRenameDialog(entry);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.open:
|
|
||||||
AndroidAppService.open(entry.uri, entry.mimeType);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.openMap:
|
|
||||||
AndroidAppService.openMap(entry.geoUri);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.setAs:
|
|
||||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
|
||||||
break;
|
|
||||||
case FullscreenAction.share:
|
|
||||||
AndroidAppService.share(entry.uri, entry.mimeType);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FullscreenAction { edit, info, open, openMap, rename, setAs, share }
|
enum FullscreenAction { edit, info, open, openMap, rename, rotateCCW, rotateCW, setAs, share }
|
||||||
|
|
||||||
class ImagePage extends StatefulWidget {
|
class ImagePage extends StatefulWidget {
|
||||||
final List<ImageEntry> entries;
|
final List<ImageEntry> entries;
|
||||||
|
|
|
@ -50,29 +50,39 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.info,
|
value: FullscreenAction.info,
|
||||||
child: Text("Info"),
|
child: MenuRow(text: 'Info', icon: Icons.info_outline),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.rename,
|
value: FullscreenAction.rename,
|
||||||
child: Text("Rename"),
|
child: MenuRow(text: 'Rename', icon: Icons.title),
|
||||||
),
|
),
|
||||||
|
if (entry.canRotate)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: FullscreenAction.rotateCCW,
|
||||||
|
child: MenuRow(text: 'Rotate left', icon: Icons.rotate_left),
|
||||||
|
),
|
||||||
|
if (entry.canRotate)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: FullscreenAction.rotateCW,
|
||||||
|
child: MenuRow(text: 'Rotate right', icon: Icons.rotate_right),
|
||||||
|
),
|
||||||
PopupMenuDivider(),
|
PopupMenuDivider(),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.edit,
|
value: FullscreenAction.edit,
|
||||||
child: Text("Edit with…"),
|
child: Text('Edit with…'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.open,
|
value: FullscreenAction.open,
|
||||||
child: Text("Open with…"),
|
child: Text('Open with…'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.setAs,
|
value: FullscreenAction.setAs,
|
||||||
child: Text("Set as…"),
|
child: Text('Set as…'),
|
||||||
),
|
),
|
||||||
if (entry.hasGps)
|
if (entry.hasGps)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.openMap,
|
value: FullscreenAction.openMap,
|
||||||
child: Text("Show on map…"),
|
child: Text('Show on map…'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: onActionSelected,
|
onSelected: onActionSelected,
|
||||||
|
@ -85,6 +95,28 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MenuRow extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const MenuRow({
|
||||||
|
Key key,
|
||||||
|
this.text,
|
||||||
|
this.icon,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(text),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class OverlayButton extends StatelessWidget {
|
class OverlayButton extends StatelessWidget {
|
||||||
final Animation<double> scale;
|
final Animation<double> scale;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
Loading…
Reference in a new issue