fullscreen: added rotate action

This commit is contained in:
Thibault Deckers 2019-08-16 01:20:09 +09:00
parent 5571f9f236
commit 0c8318444b
14 changed files with 515 additions and 148 deletions

View file

@ -5,7 +5,7 @@ import android.net.Uri;
import android.os.Bundle;
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.MetadataHandler;
import deckers.thibault.aves.utils.Constants;
@ -27,7 +27,7 @@ public class MainActivity extends FlutterActivity {
FlutterView messenger = getFlutterView();
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 EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
}

View file

@ -26,14 +26,14 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory;
import io.flutter.plugin.common.MethodCall;
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";
private Activity activity;
private ImageDecodeTaskManager imageDecodeTaskManager;
private MediaStoreStreamHandler mediaStoreStreamHandler;
public ImageDecodeHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
this.activity = activity;
imageDecodeTaskManager = new ImageDecodeTaskManager(activity);
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
@ -54,6 +54,9 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
case "rename":
rename(call, result);
break;
case "rotate":
rotate(call, result);
break;
default:
result.notImplemented();
break;
@ -94,7 +97,7 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() {
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> 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) {
Dexter.withActivity(activity)
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)

View file

@ -30,7 +30,7 @@ import java.util.Map;
import java.util.TimeZone;
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.MethodChannel;
@ -200,7 +200,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
retriever.release();
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
if (dateMillis > 0) {
metadataMap.put("dateMillis", dateMillis);

View file

@ -2,17 +2,29 @@ package deckers.thibault.aves.model.provider;
import android.app.Activity;
import android.content.ContentUris;
import android.content.ContentValues;
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.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.util.Log;
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.Map;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
@ -20,7 +32,7 @@ import deckers.thibault.aves.utils.Utils;
public abstract class ImageProvider {
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) {
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
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 Lollipop, we can request the permission at the SD card root level.
boolean renamed;
if (!Env.isOnSdCard(activity, oldPath)) {
// rename with File
renamed = oldFile.renameTo(newFile);
} else {
if (Env.isOnSdCard(activity, oldPath)) {
// rename with DocumentFile
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) {
@ -52,6 +61,9 @@ public abstract class ImageProvider {
return;
}
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
} else {
// rename with File
renamed = oldFile.renameTo(newFile);
}
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 onFailure();

View file

@ -10,9 +10,11 @@ public class Constants {
// mime types
public static final String MIME_VIDEO = "video";
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_VIDEO = "video";
// video metadata keys, from android.media.MediaMetadataRetriever

View file

@ -5,8 +5,6 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.os.Environment;
import androidx.annotation.NonNull;
public class Env {
private static String[] mStorageVolumes;
private static String mExternalStorage;
@ -45,7 +43,7 @@ public class Env {
return mExternalStorage;
}
public static boolean isOnSdCard(final Activity activity, @NonNull String path) {
return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
public static boolean isOnSdCard(final Activity activity, String path) {
return path != null && !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
}
}

View file

@ -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;
}
}

View file

@ -5,8 +5,10 @@ import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
import androidx.documentfile.provider.DocumentFile;
@ -14,6 +16,7 @@ import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@ -164,6 +167,34 @@ public class StorageUtils {
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
* Note that it does not update related content providers such as the Media Store.

View file

@ -1,12 +1,11 @@
package deckers.thibault.aves.utils;
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.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.regex.Pattern;
public class Utils {
@ -29,48 +28,27 @@ public class Utils {
return logTag;
}
// 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");
public static void copyFile(final File source, final FileDescriptor descriptor) throws IOException {
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(descriptor)) {
final FileChannel inChannel = inStream.getChannel();
final FileChannel outChannel = outStream.getChannel();
final long size = inChannel.size();
long position = 0;
while (position < size) {
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
}
}
}
// 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
public static void copyFile(final File source, final File destination) throws IOException {
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(destination)) {
final FileChannel inChannel = inStream.getChannel();
final FileChannel outChannel = outStream.getChannel();
final long size = inChannel.size();
long position = 0;
while (position < size) {
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
}
}
return dateMillis;
}
}

View file

@ -15,9 +15,9 @@ class ImageEntry with ChangeNotifier {
String path;
int contentId;
final String mimeType;
final int width;
final int height;
final int orientationDegrees;
int width;
int height;
int orientationDegrees;
final int sizeBytes;
String title;
final int dateModifiedSecs;
@ -205,4 +205,20 @@ class ImageEntry with ChangeNotifier {
notifyListeners();
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;
}
}

View file

@ -55,4 +55,18 @@ class ImageFileService {
}
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();
}
}

View file

@ -33,13 +33,14 @@ class ThumbnailState extends State<Thumbnail> {
@override
void initState() {
super.initState();
entry.addListener(onEntryChange);
initByteLoader();
}
@override
void didUpdateWidget(Thumbnail 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();
}
@ -48,8 +49,11 @@ class ThumbnailState extends State<Thumbnail> {
_byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim);
}
onEntryChange() => setState(() => initByteLoader());
@override
void dispose() {
entry.removeListener(onEntryChange);
ImageFileService.cancelGetImageBytes(uri);
super.dispose();
}
@ -74,11 +78,32 @@ class ThumbnailState extends State<Thumbnail> {
future: _byteLoader,
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage;
return ThumbnailImage(entry: entry, bytes: bytes, 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: uri,
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)
@ -109,8 +134,5 @@ class ThumbnailState extends State<Thumbnail> {
GpsTag(iconSize: iconSize)
],
);
}),
),
);
}
}

View file

@ -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 {
final currentName = entry.title;
final controller = TextEditingController(text: currentName);
@ -238,46 +288,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
});
if (newName == null || newName.isEmpty) return;
final success = await entry.rename(newName);
Flushbar(
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;
}
showFeedback(success ? 'Done!' : 'Failed');
}
}
enum FullscreenAction { edit, info, open, openMap, rename, setAs, share }
enum FullscreenAction { edit, info, open, openMap, rename, rotateCCW, rotateCW, setAs, share }
class ImagePage extends StatefulWidget {
final List<ImageEntry> entries;

View file

@ -50,29 +50,39 @@ class FullscreenTopOverlay extends StatelessWidget {
itemBuilder: (context) => [
PopupMenuItem(
value: FullscreenAction.info,
child: Text("Info"),
child: MenuRow(text: 'Info', icon: Icons.info_outline),
),
PopupMenuItem(
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(),
PopupMenuItem(
value: FullscreenAction.edit,
child: Text("Edit with…"),
child: Text('Edit with…'),
),
PopupMenuItem(
value: FullscreenAction.open,
child: Text("Open with…"),
child: Text('Open with…'),
),
PopupMenuItem(
value: FullscreenAction.setAs,
child: Text("Set as…"),
child: Text('Set as…'),
),
if (entry.hasGps)
PopupMenuItem(
value: FullscreenAction.openMap,
child: Text("Show on map…"),
child: Text('Show on map…'),
),
],
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 {
final Animation<double> scale;
final Widget child;