rename
This commit is contained in:
parent
6206dbde62
commit
c78241e204
18 changed files with 662 additions and 29 deletions
|
@ -58,6 +58,7 @@ dependencies {
|
||||||
implementation 'com.github.bumptech.glide:glide:4.9.0'
|
implementation 'com.github.bumptech.glide:glide:4.9.0'
|
||||||
annotationProcessor 'androidx.annotation:annotation:1.1.0'
|
annotationProcessor 'androidx.annotation:annotation:1.1.0'
|
||||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
|
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
|
||||||
|
implementation 'com.google.guava:guava:28.0-android'
|
||||||
implementation 'com.karumi:dexter:5.0.0'
|
implementation 'com.karumi:dexter:5.0.0'
|
||||||
testImplementation 'junit:junit:4.12'
|
testImplementation 'junit:junit:4.12'
|
||||||
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
package deckers.thibault.aves;
|
package deckers.thibault.aves;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
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.ImageDecodeHandler;
|
||||||
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.Env;
|
||||||
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
import io.flutter.app.FlutterActivity;
|
import io.flutter.app.FlutterActivity;
|
||||||
import io.flutter.plugin.common.EventChannel;
|
import io.flutter.plugin.common.EventChannel;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
@ -26,5 +31,26 @@ public class MainActivity extends FlutterActivity {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE && resultCode == RESULT_OK) {
|
||||||
|
Uri sdCardDocumentUri = data.getData();
|
||||||
|
if (sdCardDocumentUri == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Env.setSdCardDocumentUri(this, sdCardDocumentUri.toString());
|
||||||
|
|
||||||
|
// save access permissions across reboots
|
||||||
|
final int takeFlags = data.getFlags()
|
||||||
|
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
|
getContentResolver().takePersistableUriPermission(sdCardDocumentUri, takeFlags);
|
||||||
|
|
||||||
|
// resume pending action
|
||||||
|
PermissionManager.onPermissionGranted(requestCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ import android.app.Activity;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
@ -19,6 +21,8 @@ import com.karumi.dexter.listener.single.PermissionListener;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.ImageEntry;
|
import deckers.thibault.aves.model.ImageEntry;
|
||||||
|
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@ -41,30 +45,68 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
||||||
case "getImageEntries":
|
case "getImageEntries":
|
||||||
getPermissionResult(result, activity);
|
getPermissionResult(result, activity);
|
||||||
break;
|
break;
|
||||||
case "getImageBytes": {
|
case "getImageBytes":
|
||||||
Map map = call.argument("entry");
|
getImageBytes(call, result);
|
||||||
Integer width = call.argument("width");
|
|
||||||
Integer height = call.argument("height");
|
|
||||||
if (map == null) {
|
|
||||||
result.error("getImageBytes-args", "failed to get image bytes because 'entry' is null", null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ImageEntry entry = new ImageEntry(map);
|
|
||||||
imageDecodeTaskManager.fetch(result, entry, width, height);
|
|
||||||
break;
|
break;
|
||||||
}
|
case "cancelGetImageBytes":
|
||||||
case "cancelGetImageBytes": {
|
cancelGetImageBytes(call, result);
|
||||||
String uri = call.argument("uri");
|
break;
|
||||||
imageDecodeTaskManager.cancel(uri);
|
case "rename":
|
||||||
result.success(null);
|
rename(call, result);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
result.notImplemented();
|
result.notImplemented();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void getImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
Map map = call.argument("entry");
|
||||||
|
Integer width = call.argument("width");
|
||||||
|
Integer height = call.argument("height");
|
||||||
|
if (map == null || width == null || height == null) {
|
||||||
|
result.error("getImageBytes-args", "failed because of missing arguments", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ImageEntry entry = new ImageEntry(map);
|
||||||
|
imageDecodeTaskManager.fetch(result, entry, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelGetImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
String uri = call.argument("uri");
|
||||||
|
imageDecodeTaskManager.cancel(uri);
|
||||||
|
result.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
Map map = call.argument("entry");
|
||||||
|
String newName = call.argument("newName");
|
||||||
|
if (map == null || newName == null) {
|
||||||
|
result.error("rename-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("rename-provider", "failed to find provider for uri=" + uri, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() {
|
||||||
|
@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("rename-failure", "failed to rename", 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)
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ContentUris;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.media.MediaScannerConnection;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import deckers.thibault.aves.utils.Env;
|
||||||
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils;
|
||||||
|
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) {
|
||||||
|
if (oldPath == null) {
|
||||||
|
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
|
File oldFile = new File(oldPath);
|
||||||
|
File newFile = new File(oldFile.getParent(), newFilename);
|
||||||
|
if (oldFile.equals(newFile)) {
|
||||||
|
Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath);
|
||||||
|
callback.onSuccess(newFields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before KitKat, we do whatever we want on the SD card.
|
||||||
|
// 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 {
|
||||||
|
// rename with DocumentFile
|
||||||
|
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||||
|
if (sdCardTreeUri == null) {
|
||||||
|
Runnable runnable = () -> rename(activity, oldPath, oldUri, mimeType, newFilename, callback);
|
||||||
|
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renamed) {
|
||||||
|
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath);
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null);
|
||||||
|
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, uri) -> {
|
||||||
|
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", uri=" + uri);
|
||||||
|
if (uri != null) {
|
||||||
|
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||||
|
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
||||||
|
try {
|
||||||
|
Cursor cursor = activity.getContentResolver().query(uri, projection, null, null, null);
|
||||||
|
if (cursor != null) {
|
||||||
|
if (cursor.moveToNext()) {
|
||||||
|
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
||||||
|
Uri itemUri = ContentUris.withAppendedId(MediaStoreImageProvider.FILES_URI, contentId);
|
||||||
|
newFields.put("uri", itemUri.toString());
|
||||||
|
newFields.put("contentId", contentId);
|
||||||
|
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
|
||||||
|
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(LOG_TAG, "failed to update MediaStore after renaming entry at path=" + oldPath, e);
|
||||||
|
callback.onFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback.onSuccess(newFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface RenameCallback {
|
||||||
|
void onSuccess(Map<String, Object> newFields);
|
||||||
|
|
||||||
|
void onFailure();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
|
import android.content.ContentResolver;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public class ImageProviderFactory {
|
||||||
|
public static ImageProvider getProvider(@NonNull Uri uri) {
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
if (scheme != null) {
|
||||||
|
switch (scheme) {
|
||||||
|
case ContentResolver.SCHEME_CONTENT: // content://
|
||||||
|
String authority = uri.getAuthority();
|
||||||
|
if (authority != null) {
|
||||||
|
switch (authority) {
|
||||||
|
case MediaStore.AUTHORITY:
|
||||||
|
return new MediaStoreImageProvider();
|
||||||
|
// case Constants.DOWNLOADS_AUTHORITY:
|
||||||
|
// return new DownloadImageProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
// case ContentResolver.SCHEME_FILE: // file://
|
||||||
|
// return new FileImageProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,10 +13,10 @@ import java.util.stream.Stream;
|
||||||
import deckers.thibault.aves.model.ImageEntry;
|
import deckers.thibault.aves.model.ImageEntry;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.Utils;
|
||||||
|
|
||||||
public class MediaStoreImageProvider {
|
public class MediaStoreImageProvider extends ImageProvider {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
|
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
|
||||||
|
|
||||||
private Uri FILES_URI = MediaStore.Files.getContentUri("external");
|
public static Uri FILES_URI = MediaStore.Files.getContentUri("external");
|
||||||
|
|
||||||
private static final String[] PROJECTION = {
|
private static final String[] PROJECTION = {
|
||||||
// image & video
|
// image & video
|
||||||
|
|
|
@ -6,6 +6,8 @@ import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class Constants {
|
public class Constants {
|
||||||
|
public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1;
|
||||||
|
|
||||||
// mime types
|
// mime types
|
||||||
|
|
||||||
public static final String MIME_VIDEO = "video";
|
public static final String MIME_VIDEO = "video";
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
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;
|
||||||
|
// SD card path as a content URI from the Documents Provider
|
||||||
|
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
|
||||||
|
private static String mSdCardDocumentUri;
|
||||||
|
|
||||||
|
private static final String PREF_SD_CARD_DOCUMENT_URI = "sd_card_document_uri";
|
||||||
|
|
||||||
|
public static void setSdCardDocumentUri(final Activity activity, String SdCardDocumentUri) {
|
||||||
|
mSdCardDocumentUri = SdCardDocumentUri;
|
||||||
|
SharedPreferences.Editor preferences = activity.getPreferences(Context.MODE_PRIVATE).edit();
|
||||||
|
preferences.putString(PREF_SD_CARD_DOCUMENT_URI, mSdCardDocumentUri);
|
||||||
|
preferences.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getSdCardDocumentUri(final Activity activity) {
|
||||||
|
if (mSdCardDocumentUri == null) {
|
||||||
|
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
|
||||||
|
mSdCardDocumentUri = preferences.getString(PREF_SD_CARD_DOCUMENT_URI, null);
|
||||||
|
}
|
||||||
|
return mSdCardDocumentUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String[] getStorageVolumes(final Activity activity) {
|
||||||
|
if (mStorageVolumes == null) {
|
||||||
|
mStorageVolumes = StorageUtils.getStorageVolumes(activity);
|
||||||
|
}
|
||||||
|
return mStorageVolumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getExternalStorage() {
|
||||||
|
if (mExternalStorage == null) {
|
||||||
|
mExternalStorage = Environment.getExternalStorageDirectory().toString();
|
||||||
|
}
|
||||||
|
return mExternalStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isOnSdCard(final Activity activity, @NonNull String path) {
|
||||||
|
return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
public class PathComponents {
|
||||||
|
private String storage;
|
||||||
|
private String folder;
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
public PathComponents(@NonNull String path, @NonNull String[] storageVolumes) {
|
||||||
|
for (int i = 0; i < storageVolumes.length && storage == null; i++) {
|
||||||
|
if (path.startsWith(storageVolumes[i])) {
|
||||||
|
storage = storageVolumes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int lastSeparatorIndex = path.lastIndexOf(File.separator) + 1;
|
||||||
|
if (lastSeparatorIndex > storage.length()) {
|
||||||
|
filename = path.substring(lastSeparatorIndex);
|
||||||
|
folder = path.substring(storage.length(), lastSeparatorIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorage() {
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFolder() {
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.UriPermission;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class PermissionManager {
|
||||||
|
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
|
||||||
|
|
||||||
|
// permission request code to pending runnable
|
||||||
|
private static ConcurrentHashMap<Integer, Runnable> pendingPermissionMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// check access permission to SD card directory & return its content URI if available
|
||||||
|
public static Uri getSdCardTreeUri(Activity activity) {
|
||||||
|
final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity);
|
||||||
|
Optional<UriPermission> uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream()
|
||||||
|
.filter(uriPermission -> uriPermission.getUri().toString().equals(sdCardDocumentUri))
|
||||||
|
.findFirst();
|
||||||
|
return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
|
public static void showSdCardAccessDialog(final Activity activity, final Runnable pendingRunnable) {
|
||||||
|
new AlertDialog.Builder(activity)
|
||||||
|
.setTitle("SD Card Access")
|
||||||
|
.setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.")
|
||||||
|
.setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
|
||||||
|
Log.i(LOG_TAG, "request user to select and grant access permission to SD card");
|
||||||
|
pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, pendingRunnable);
|
||||||
|
ActivityCompat.startActivityForResult(activity,
|
||||||
|
new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
|
||||||
|
Constants.SD_CARD_PERMISSION_REQUEST_CODE, null);
|
||||||
|
})
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onPermissionGranted(int requestCode) {
|
||||||
|
Runnable runnable = pendingPermissionMap.remove(requestCode);
|
||||||
|
if (runnable != null) {
|
||||||
|
runnable.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,187 @@
|
||||||
|
package deckers.thibault.aves.utils;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.documentfile.provider.DocumentFile;
|
||||||
|
|
||||||
|
import com.google.common.base.Splitter;
|
||||||
|
import com.google.common.collect.Lists;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class StorageUtils {
|
||||||
|
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all available SD-Cards in the system (include emulated)
|
||||||
|
* <p/>
|
||||||
|
* Warning: Hack! Based on Android source code of version 4.3 (API 18)
|
||||||
|
* Because there is no standard way to get it.
|
||||||
|
* Edited by hendrawd
|
||||||
|
*
|
||||||
|
* @return paths to all available SD-Cards in the system (include emulated)
|
||||||
|
*/
|
||||||
|
@SuppressLint("ObsoleteSdkInt")
|
||||||
|
public static String[] getStorageVolumes(Context context) {
|
||||||
|
// Final set of paths
|
||||||
|
final Set<String> rv = new HashSet<>();
|
||||||
|
|
||||||
|
// Primary emulated SD-CARD
|
||||||
|
final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET");
|
||||||
|
|
||||||
|
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||||
|
// fix of empty raw emulated storage on marshmallow
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
File[] files = context.getExternalFilesDirs(null);
|
||||||
|
for (File file : files) {
|
||||||
|
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||||
|
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||||
|
rv.add(emulatedRootPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Primary physical SD-CARD (not emulated)
|
||||||
|
final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE");
|
||||||
|
|
||||||
|
// Device has physical external storage; use plain paths.
|
||||||
|
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||||
|
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||||
|
rv.addAll(Arrays.asList(getPhysicalPaths()));
|
||||||
|
} else {
|
||||||
|
rv.add(rawExternalStorage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||||
|
final String rawUserId;
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
rawUserId = "";
|
||||||
|
} else {
|
||||||
|
final String path = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||||
|
final String[] folders = path.split(File.separator);
|
||||||
|
final String lastFolder = folders[folders.length - 1];
|
||||||
|
boolean isDigit = TextUtils.isDigitsOnly(lastFolder);
|
||||||
|
rawUserId = isDigit ? lastFolder : "";
|
||||||
|
}
|
||||||
|
// /storage/emulated/0[1,2,...]
|
||||||
|
if (TextUtils.isEmpty(rawUserId)) {
|
||||||
|
rv.add(rawEmulatedStorageTarget);
|
||||||
|
} else {
|
||||||
|
rv.add(rawEmulatedStorageTarget + File.separator + rawUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||||
|
final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE");
|
||||||
|
|
||||||
|
// Add all secondary storages
|
||||||
|
if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) {
|
||||||
|
// All Secondary SD-CARDs split into array
|
||||||
|
final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator);
|
||||||
|
Collections.addAll(rv, rawSecondaryStorages);
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] paths = rv.toArray(new String[0]);
|
||||||
|
for (int i = 0; i < paths.length; i++) {
|
||||||
|
String path = paths[i];
|
||||||
|
if (path.endsWith(File.separator)) {
|
||||||
|
paths[i] = path.substring(0, path.length() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return physicalPaths based on phone model
|
||||||
|
*/
|
||||||
|
@SuppressLint("SdCardPath")
|
||||||
|
private static String[] getPhysicalPaths() {
|
||||||
|
return new String[]{
|
||||||
|
"/storage/sdcard0",
|
||||||
|
"/storage/sdcard1", //Motorola Xoom
|
||||||
|
"/storage/extsdcard", //Samsung SGS3
|
||||||
|
"/storage/sdcard0/external_sdcard", //User request
|
||||||
|
"/mnt/extsdcard",
|
||||||
|
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||||
|
"/mnt/external_sd",
|
||||||
|
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||||
|
"/removable/microsd", //Asus transformer prime
|
||||||
|
"/mnt/emmc",
|
||||||
|
"/storage/external_SD", //LG
|
||||||
|
"/storage/ext_sd", //HTC One Max
|
||||||
|
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||||
|
"/data/sdext",
|
||||||
|
"/data/sdext2",
|
||||||
|
"/data/sdext3",
|
||||||
|
"/data/sdext4",
|
||||||
|
"/sdcard1", //Sony Xperia Z
|
||||||
|
"/sdcard2", //HTC One M8s
|
||||||
|
"/storage/microsd" //ASUS ZenFone 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<DocumentFile> getSdCardDocumentFile(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) {
|
||||||
|
if (sdCardTreeUri == null || storageVolumes == null || path == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
PathComponents pathComponents = new PathComponents(path, storageVolumes);
|
||||||
|
ArrayList<String> pathSegments = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||||
|
.trimResults().omitEmptyStrings().split(pathComponents.getFolder()));
|
||||||
|
pathSegments.add(pathComponents.getFilename());
|
||||||
|
Iterator<String> pathIterator = pathSegments.iterator();
|
||||||
|
|
||||||
|
// follow the entry path down the document tree
|
||||||
|
boolean found = true;
|
||||||
|
DocumentFile documentFile = DocumentFile.fromTreeUri(context, sdCardTreeUri);
|
||||||
|
while (pathIterator.hasNext() && found) {
|
||||||
|
String segment = pathIterator.next();
|
||||||
|
found = false;
|
||||||
|
if (documentFile != null) {
|
||||||
|
DocumentFile[] children = documentFile.listFiles();
|
||||||
|
for (int i = children.length - 1; i >= 0 && !found; i--) {
|
||||||
|
DocumentFile child = children[i];
|
||||||
|
if (segment.equals(child.getName())) {
|
||||||
|
found = true;
|
||||||
|
documentFile = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found && documentFile != null ? Optional.of(documentFile) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the specified file on SD card
|
||||||
|
* Note that it does not update related content providers such as the Media Store.
|
||||||
|
*/
|
||||||
|
public static boolean deleteFromSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) {
|
||||||
|
Optional<DocumentFile> documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path);
|
||||||
|
boolean success = documentFile.isPresent() && documentFile.get().delete();
|
||||||
|
Log.d(LOG_TAG, "deleteFromSdCard success=" + success + " for sdCardTreeUri=" + sdCardTreeUri + ", path=" + path);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the specified file on SD card
|
||||||
|
* Note that it does not update related content providers such as the Media Store.
|
||||||
|
*/
|
||||||
|
public static boolean renameOnSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path, String newFilename) {
|
||||||
|
Log.d(LOG_TAG, "renameOnSdCard with path=" + path + ", newFilename=" + newFilename);
|
||||||
|
Optional<DocumentFile> documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path);
|
||||||
|
return documentFile.isPresent() && documentFile.get().renameTo(newFilename);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
|
||||||
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
|
||||||
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -41,4 +41,18 @@ class ImageDecodeService {
|
||||||
debugPrint('cancelGetImageBytes failed with exception=${e.message}');
|
debugPrint('cancelGetImageBytes failed with exception=${e.message}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map> rename(ImageEntry entry, String newName) async {
|
||||||
|
try {
|
||||||
|
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||||
|
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||||
|
'entry': entry.toMap(),
|
||||||
|
'newName': newName,
|
||||||
|
}) as Map;
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('rename failed with exception=${e.message}');
|
||||||
|
}
|
||||||
|
return Map();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:aves/model/image_decode_service.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
import 'package:aves/model/metadata_service.dart';
|
import 'package:aves/model/metadata_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geocoder/geocoder.dart';
|
import 'package:geocoder/geocoder.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
import 'mime_types.dart';
|
import 'mime_types.dart';
|
||||||
|
|
||||||
class ImageEntry with ChangeNotifier {
|
class ImageEntry with ChangeNotifier {
|
||||||
final String uri;
|
String uri;
|
||||||
final String path;
|
String path;
|
||||||
final int contentId;
|
int contentId;
|
||||||
final String mimeType;
|
final String mimeType;
|
||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final int orientationDegrees;
|
final int orientationDegrees;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
final String title;
|
String title;
|
||||||
final int dateModifiedSecs;
|
final int dateModifiedSecs;
|
||||||
final int sourceDateTakenMillis;
|
final int sourceDateTakenMillis;
|
||||||
final String bucketDisplayName;
|
final String bucketDisplayName;
|
||||||
|
@ -82,6 +84,8 @@ class ImageEntry with ChangeNotifier {
|
||||||
return 'ImageEntry{uri=$uri, path=$path}';
|
return 'ImageEntry{uri=$uri, path=$path}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get filename => basenameWithoutExtension(path);
|
||||||
|
|
||||||
bool get isGif => mimeType == MimeTypes.MIME_GIF;
|
bool get isGif => mimeType == MimeTypes.MIME_GIF;
|
||||||
|
|
||||||
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
||||||
|
@ -183,4 +187,22 @@ class ImageEntry with ChangeNotifier {
|
||||||
if (isLocated && addressDetails.addressLine.toLowerCase().contains(query)) return true;
|
if (isLocated && addressDetails.addressLine.toLowerCase().contains(query)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> rename(String newName) async {
|
||||||
|
if (newName == filename) return true;
|
||||||
|
|
||||||
|
final newFields = await ImageDecodeService.rename(this, '$newName${extension(this.path)}');
|
||||||
|
if (newFields.isEmpty) return false;
|
||||||
|
|
||||||
|
final uri = newFields['uri'];
|
||||||
|
if (uri != null) this.uri = uri;
|
||||||
|
final path = newFields['path'];
|
||||||
|
if (path != null) this.path = path;
|
||||||
|
final contentId = newFields['contentId'];
|
||||||
|
if (contentId != null) this.contentId = contentId;
|
||||||
|
final title = newFields['title'];
|
||||||
|
if (title != null) this.title = title;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:photo_view/photo_view_gallery.dart';
|
import 'package:photo_view/photo_view_gallery.dart';
|
||||||
import 'package:screen/screen.dart';
|
import 'package:screen/screen.dart';
|
||||||
|
|
||||||
class FullscreenPage extends StatefulWidget {
|
class FullscreenPage extends StatelessWidget {
|
||||||
final List<ImageEntry> entries;
|
final List<ImageEntry> entries;
|
||||||
final String initialUri;
|
final String initialUri;
|
||||||
|
|
||||||
|
@ -25,10 +25,39 @@ class FullscreenPage extends StatefulWidget {
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FullscreenPageState createState() => FullscreenPageState();
|
Widget build(BuildContext context) {
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () {
|
||||||
|
Screen.keepOn(false);
|
||||||
|
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
|
||||||
|
return Future.value(true);
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: FullscreenBody(
|
||||||
|
entries: entries,
|
||||||
|
initialUri: initialUri,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FullscreenPageState extends State<FullscreenPage> with SingleTickerProviderStateMixin {
|
class FullscreenBody extends StatefulWidget {
|
||||||
|
final List<ImageEntry> entries;
|
||||||
|
final String initialUri;
|
||||||
|
|
||||||
|
const FullscreenBody({
|
||||||
|
Key key,
|
||||||
|
this.entries,
|
||||||
|
this.initialUri,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FullscreenBodyState createState() => FullscreenBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProviderStateMixin {
|
||||||
bool _isInitialScale = true;
|
bool _isInitialScale = true;
|
||||||
int _currentHorizontalPage, _currentVerticalPage = 0;
|
int _currentHorizontalPage, _currentVerticalPage = 0;
|
||||||
PageController _horizontalPager, _verticalPager;
|
PageController _horizontalPager, _verticalPager;
|
||||||
|
@ -193,6 +222,35 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showRenameDialog(ImageEntry entry) async {
|
||||||
|
final currentName = entry.title;
|
||||||
|
final controller = TextEditingController(text: currentName);
|
||||||
|
final newName = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FlatButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text('CANCEL'),
|
||||||
|
),
|
||||||
|
FlatButton(
|
||||||
|
onPressed: () => Navigator.pop(context, controller.text),
|
||||||
|
child: Text('APPLY'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (newName == null || newName.isEmpty) return;
|
||||||
|
final success = await entry.rename(newName);
|
||||||
|
final snackBar = SnackBar(content: Text(success ? 'Done!' : 'Failed'));
|
||||||
|
Scaffold.of(context).showSnackBar(snackBar);
|
||||||
|
}
|
||||||
|
|
||||||
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case FullscreenAction.edit:
|
case FullscreenAction.edit:
|
||||||
|
@ -201,6 +259,9 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
||||||
case FullscreenAction.info:
|
case FullscreenAction.info:
|
||||||
goToVerticalPage(1);
|
goToVerticalPage(1);
|
||||||
break;
|
break;
|
||||||
|
case FullscreenAction.rename:
|
||||||
|
showRenameDialog(entry);
|
||||||
|
break;
|
||||||
case FullscreenAction.setAs:
|
case FullscreenAction.setAs:
|
||||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||||
break;
|
break;
|
||||||
|
@ -214,7 +275,7 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FullscreenAction { edit, info, setAs, share, showOnMap }
|
enum FullscreenAction { edit, info, rename, setAs, share, showOnMap }
|
||||||
|
|
||||||
class ImagePage extends StatefulWidget {
|
class ImagePage extends StatefulWidget {
|
||||||
final List<ImageEntry> entries;
|
final List<ImageEntry> entries;
|
||||||
|
|
|
@ -17,6 +17,8 @@ class MetadataSection extends StatefulWidget {
|
||||||
class MetadataSectionState extends State<MetadataSection> {
|
class MetadataSectionState extends State<MetadataSection> {
|
||||||
Future<Map> _metadataLoader;
|
Future<Map> _metadataLoader;
|
||||||
|
|
||||||
|
static const int maxValueLength = 140;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -56,7 +58,10 @@ class MetadataSectionState extends State<MetadataSection> {
|
||||||
padding: EdgeInsets.symmetric(vertical: 4.0),
|
padding: EdgeInsets.symmetric(vertical: 4.0),
|
||||||
child: Text(directoryName, style: TextStyle(fontSize: 18)),
|
child: Text(directoryName, style: TextStyle(fontSize: 18)),
|
||||||
),
|
),
|
||||||
...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])),
|
...tagKeys.map((tagKey) {
|
||||||
|
final value = directory[tagKey] as String;
|
||||||
|
return InfoRow(tagKey, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}…' : value);
|
||||||
|
}),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
@ -56,6 +56,10 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
value: FullscreenAction.edit,
|
value: FullscreenAction.edit,
|
||||||
child: Text("Edit"),
|
child: Text("Edit"),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: FullscreenAction.rename,
|
||||||
|
child: Text("Rename"),
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.setAs,
|
value: FullscreenAction.setAs,
|
||||||
child: Text("Set as"),
|
child: Text("Set as"),
|
||||||
|
|
Loading…
Reference in a new issue