copy/move: album creation

This commit is contained in:
Thibault Deckers 2020-06-01 14:11:24 +09:00
parent a437c2fe9a
commit 97e3fe62c0
15 changed files with 323 additions and 133 deletions

View file

@ -85,8 +85,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
@Override
public void onFailure() {
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, null);
public void onFailure(Throwable throwable) {
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, throwable);
}
});
}
@ -114,8 +114,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
@Override
public void onFailure() {
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", null));
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", throwable));
}
});
}
@ -143,8 +143,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
@Override
public void onFailure() {
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", null));
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", throwable));
}
});
}

View file

@ -11,7 +11,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.Utils;
@ -91,25 +93,18 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
String destinationDir = (String) argMap.get("destinationPath");
if (copy == null || destinationDir == null) return;
for (Map<String, Object> entryMap : entryMapList) {
String uriString = (String) entryMap.get("uri");
Uri sourceUri = Uri.parse(uriString);
String sourcePath = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType");
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString);
}};
try {
Map<String, Object> newFields = provider.move(activity, sourcePath, sourceUri, destinationDir, mimeType, copy).get();
result.put("success", true);
result.put("newFields", newFields);
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e);
result.put("success", false);
ArrayList<ImageEntry> entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toCollection(ArrayList::new));
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> fields) {
success(fields);
}
success(result);
}
@Override
public void onFailure(Throwable throwable) {
error("move-failure", "failed to move entries", throwable);
}
});
endOfStream();
}

View file

@ -18,7 +18,7 @@ class ContentImageProvider extends ImageProvider {
if (entry.hasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();
callback.onFailure(new Exception("entry has no size"));
}
}
}

View file

@ -2,7 +2,6 @@ package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@ -10,11 +9,8 @@ import java.io.File;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.FileUtils;
import deckers.thibault.aves.utils.Utils;
class FileImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(FileImageProvider.class);
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
ImageEntry entry = new ImageEntry();
@ -32,8 +28,7 @@ class FileImageProvider extends ImageProvider {
entry.dateModifiedSecs = file.lastModified() / 1000;
}
} catch (SecurityException e) {
Log.w(LOG_TAG, "failed to get path from file at uri=" + uri);
callback.onFailure();
callback.onFailure(e);
}
}
entry.fillPreCatalogMetadata(context);
@ -41,7 +36,7 @@ class FileImageProvider extends ImageProvider {
if (entry.hasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();
callback.onFailure(new Exception("entry has no size"));
}
}
}

View file

@ -33,9 +33,11 @@ import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
@ -56,21 +58,20 @@ public abstract class ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure();
callback.onFailure(new UnsupportedOperationException());
}
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
public ListenableFuture<Map<String, Object>> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList<ImageEntry> entries, @NonNull ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException());
}
public void rename(final Activity activity, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
if (oldPath == null) {
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldMediaUri);
callback.onFailure();
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
return;
}
@ -96,13 +97,11 @@ public abstract class ImageProvider {
try {
boolean renamed = df != null && df.renameTo(newFilename);
if (!renamed) {
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath);
callback.onFailure();
callback.onFailure(new Exception("failed to rename entry at path=" + oldPath));
return;
}
} catch (FileNotFoundException e) {
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath, e);
callback.onFailure();
callback.onFailure(e);
return;
}
@ -125,8 +124,7 @@ public abstract class ImageProvider {
cursor.close();
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to update Media Store after renaming entry at path=" + oldPath, e);
callback.onFailure();
callback.onFailure(e);
return;
}
}
@ -157,8 +155,11 @@ public abstract class ImageProvider {
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
// so we retrieve it again from the file metadata
String metadataMimeType = getMimeType(activity, uri);
switch (metadataMimeType != null ? metadataMimeType : mimeType) {
String actualMimeType = getMimeType(activity, uri);
if (actualMimeType == null) {
actualMimeType = mimeType;
}
switch (actualMimeType) {
case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback);
break;
@ -166,7 +167,7 @@ public abstract class ImageProvider {
rotatePng(activity, path, uri, clockwise, callback);
break;
default:
callback.onFailure();
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType));
}
}
@ -182,8 +183,7 @@ public abstract class ImageProvider {
}
}
boolean rotated = false;
int newOrientationCode = 0;
int newOrientationCode;
try {
ExifInterface exif = new ExifInterface(editablePath);
switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
@ -205,12 +205,8 @@ public abstract class ImageProvider {
// copy the edited temporary file to the original DocumentFile
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri));
rotated = true;
} catch (IOException e) {
Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e);
}
if (!rotated) {
callback.onFailure();
callback.onFailure(e);
return;
}
@ -253,8 +249,7 @@ public abstract class ImageProvider {
Bitmap originalImage = BitmapFactory.decodeFile(path);
if (originalImage == null) {
Log.e(LOG_TAG, "failed to decode image at path=" + path);
callback.onFailure();
callback.onFailure(new Exception("failed to decode image at path=" + path));
return;
}
Matrix matrix = new Matrix();
@ -263,18 +258,13 @@ public abstract class ImageProvider {
matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1);
Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true);
boolean rotated = false;
try (FileOutputStream fos = new FileOutputStream(editablePath)) {
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
// copy the edited temporary file to the original DocumentFile
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri));
rotated = true;
} catch (IOException e) {
Log.e(LOG_TAG, "failed to save rotated image to path=" + path, e);
}
if (!rotated) {
callback.onFailure();
callback.onFailure(e);
return;
}
@ -306,8 +296,8 @@ public abstract class ImageProvider {
}
public interface ImageOpCallback {
void onSuccess(Map<String, Object> newFields);
void onSuccess(Map<String, Object> fields);
void onFailure();
void onFailure(Throwable throwable);
}
}

View file

@ -23,6 +23,7 @@ import com.google.common.util.concurrent.SettableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@ -88,7 +89,7 @@ public class MediaStoreImageProvider extends ImageProvider {
entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION);
}
if (entryCount == 0) {
callback.onFailure();
callback.onFailure(new Exception("failed to fetch entry at uri=" + uri));
}
}
@ -224,9 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider {
}
@Override
public ListenableFuture<Map<String, Object>> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create();
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList<ImageEntry> entries, ImageOpCallback callback) {
String volumeName = "external";
StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) {
@ -241,6 +240,34 @@ public class MediaStoreImageProvider extends ImageProvider {
}
}
if (!StorageUtils.createDirectoryIfAbsent(activity, destinationDir)) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
return;
}
for (ImageEntry entry : entries) {
Uri sourceUri = entry.uri;
String sourcePath = entry.path;
String mimeType = entry.mimeType;
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", sourceUri.toString());
}};
try {
Map<String, Object> newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get();
result.put("success", true);
result.put("newFields", newFields);
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e);
result.put("success", false);
}
callback.onSuccess(result);
}
}
private ListenableFuture<Map<String, Object>> moveSingle(final Activity activity, final String volumeName, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create();
try {
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
// from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)`

View file

@ -59,20 +59,24 @@ public class StorageUtils {
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri, String path) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
}
retriever.setDataSource(context, uri);
return retriever;
}
retriever.setDataSource(context, uri);
return retriever;
}
// on Android <Q, we directly work with file paths if possible
if (path != null) {
retriever.setDataSource(path);
} else {
retriever.setDataSource(context, uri);
// on Android <Q, we directly work with file paths if possible
if (path != null) {
retriever.setDataSource(path);
} else {
retriever.setDataSource(context, uri);
}
} catch (IllegalArgumentException e) {
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri + ", path=" + path);
}
return retriever;
}
@ -189,17 +193,13 @@ public class StorageUtils {
return Optional.empty();
}
PathSegments pathSegments = new PathSegments(path, storageVolumeRoots);
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
.trimResults().omitEmptyStrings().split(pathSegments.getRelativePath()));
pathSteps.add(pathSegments.getFilename());
Iterator<String> pathIterator = pathSteps.iterator();
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (documentFile == null) {
return Optional.empty();
}
// follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
while (pathIterator.hasNext()) {
documentFile = documentFile.findFile(pathIterator.next());
if (documentFile == null) {
@ -209,9 +209,20 @@ public class StorageUtils {
return Optional.of(documentFile);
}
private static Iterator<String> getPathStepIterator(String[] storageVolumeRoots, String path) {
PathSegments pathSegments = new PathSegments(path, storageVolumeRoots);
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
.trimResults().omitEmptyStrings().split(pathSegments.getRelativePath()));
String filename = pathSegments.getFilename();
if (filename != null && filename.length() > 0) {
pathSteps.add(filename);
}
return pathSteps.iterator();
}
@Nullable
public static DocumentFileCompat getDocumentFile(Activity activity, @NonNull String path, @NonNull Uri mediaUri) {
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String path, @NonNull Uri mediaUri) {
if (Env.requireAccessPermission(path)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Uri docUri = MediaStore.getDocumentUri(activity, mediaUri);
@ -227,6 +238,40 @@ public class StorageUtils {
}
}
public static boolean createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
if (Env.requireAccessPermission(directoryPath)) {
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
if (parentFile == null) return false;
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
if (!directoryPath.endsWith(File.separator)) {
directoryPath += File.separator;
}
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
while (pathIterator.hasNext()) {
String dirName = pathIterator.next();
DocumentFileCompat dirFile = parentFile.findFile(dirName);
if (dirFile == null || !dirFile.exists()) {
try {
dirFile = parentFile.createDirectory(dirName);
if (dirFile != null) {
parentFile = dirFile;
}
} catch (FileNotFoundException e) {
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
return false;
}
}
}
return true;
} else {
File directory = new File(directoryPath);
if (directory.exists()) return true;
return directory.mkdirs();
}
}
public static String copyFileToTemp(String path) {
try {
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());

View file

@ -121,6 +121,7 @@ class MetadataDb {
}
void _batchInsertMetadata(Batch batch, CatalogMetadata metadata) {
if (metadata == null) return;
if (metadata.dateMillis != 0) {
batch.insert(
dateTakenTable,
@ -171,6 +172,7 @@ class MetadataDb {
}
void _batchInsertAddress(Batch batch, AddressDetails address) {
if (address == null) return;
batch.insert(
addressTable,
address.toMap(),
@ -214,6 +216,7 @@ class MetadataDb {
}
void _batchInsertFavourite(Batch batch, FavouriteRow row) {
if (row == null) return;
batch.insert(
favouriteTable,
row.toMap(),

View file

@ -5,21 +5,20 @@ import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils {
String externalStorage, dcimPath, downloadPath, moviesPath, picturesPath;
static List<StorageVolume> storageVolumes = [];
static Map appNameMap = {};
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
Set<StorageVolume> storageVolumes = {};
Map appNameMap = {};
AndroidFileUtils._private();
Future<void> init() async {
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList();
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toSet();
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
externalStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
dcimPath = join(externalStorage, 'DCIM');
downloadPath = join(externalStorage, 'Download');
moviesPath = join(externalStorage, 'Movies');
picturesPath = join(externalStorage, 'Pictures');
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
dcimPath = join(primaryStorage, 'DCIM');
downloadPath = join(primaryStorage, 'Download');
moviesPath = join(primaryStorage, 'Movies');
picturesPath = join(primaryStorage, 'Pictures');
appNameMap = await AndroidAppService.getAppNames()
..addAll({'KakaoTalkDownload': 'com.kakao.talk'});
}
@ -38,20 +37,20 @@ class AndroidFileUtils {
AlbumType getAlbumType(String albumDirectory) {
if (albumDirectory != null) {
if (androidFileUtils.isCameraPath(albumDirectory)) return AlbumType.camera;
if (androidFileUtils.isDownloadPath(albumDirectory)) return AlbumType.download;
if (androidFileUtils.isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
if (androidFileUtils.isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
if (isCameraPath(albumDirectory)) return AlbumType.camera;
if (isDownloadPath(albumDirectory)) return AlbumType.download;
if (isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
if (isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
final parts = albumDirectory.split(separator);
if (albumDirectory.startsWith(androidFileUtils.externalStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
if (albumDirectory.startsWith(primaryStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
}
return AlbumType.regular;
}
String getAlbumAppPackageName(String albumDirectory) {
final parts = albumDirectory.split(separator);
return AndroidFileUtils.appNameMap[parts.last];
return appNameMap[parts.last];
}
}

View file

@ -64,6 +64,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void dispose() {
_unregisterWidget(widget);
_browseToSelectAnimation.dispose();
_searchFieldController.dispose();
super.dispose();
}

View file

@ -0,0 +1,92 @@
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
class CreateAlbumDialog extends StatefulWidget {
@override
_CreateAlbumDialogState createState() => _CreateAlbumDialogState();
}
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final TextEditingController _nameController = TextEditingController();
Set<StorageVolume> allVolumes;
StorageVolume primaryVolume, selectedVolume;
@override
void initState() {
super.initState();
// TODO TLAD improve new album default name
_nameController.text = 'Album 1';
allVolumes = androidFileUtils.storageVolumes;
primaryVolume = allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => allVolumes.first);
selectedVolume = primaryVolume;
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('New Album'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
// autofocus: true,
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Storage:'),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<StorageVolume>(
isExpanded: true,
items: allVolumes
.map((volume) => DropdownMenuItem(
value: volume,
child: Text(
volume.description,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: selectedVolume,
onChanged: (volume) => setState(() => selectedVolume = volume),
),
),
],
),
],
),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, _buildAlbumPath()),
child: Text('Create'.toUpperCase()),
),
],
);
}
String _buildAlbumPath() {
final newName = _nameController.text;
if (newName == null || newName.isEmpty) return null;
return join(selectedVolume == primaryVolume ? androidFileUtils.dcimPath : selectedVolume.path, newName);
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
@ -167,28 +168,10 @@ class EntryActionDelegate with PermissionAwareMixin {
}
Future<void> _showRenameDialog(BuildContext context, ImageEntry entry) async {
final currentName = entry.filenameWithoutExtension ?? entry.sourceTitle;
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'.toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text('Apply'.toUpperCase()),
),
],
);
});
context: context,
builder: (context) => RenameEntryDialog(entry),
);
if (newName == null || newName.isEmpty) return;
if (!await checkStoragePermission(context, [entry])) return;

View file

@ -0,0 +1,48 @@
import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart';
class RenameEntryDialog extends StatefulWidget {
final ImageEntry entry;
const RenameEntryDialog(this.entry);
@override
_RenameEntryDialogState createState() => _RenameEntryDialogState();
}
class _RenameEntryDialogState extends State<RenameEntryDialog> {
final TextEditingController _nameController = TextEditingController();
@override
void initState() {
super.initState();
final entry = widget.entry;
_nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle;
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
controller: _nameController,
autofocus: true,
),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, _nameController.text),
child: Text('Apply'.toUpperCase()),
),
],
);
}
}

View file

@ -8,6 +8,7 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/album/app_bar.dart';
import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart';
@ -16,6 +17,7 @@ import 'package:collection/collection.dart';
import 'package:flushbar/flushbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
@ -53,9 +55,9 @@ class SelectionActionDelegate with PermissionAwareMixin {
}
Future _moveSelection(BuildContext context, {@required bool copy}) async {
final filter = await Navigator.push(
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<AlbumFilter>(
MaterialPageRoute<String>(
builder: (context) {
final source = collection.source;
return FilterGridPage(
@ -64,10 +66,17 @@ class SelectionActionDelegate with PermissionAwareMixin {
leading: const BackButton(),
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
actions: [
const IconButton(
IconButton(
icon: Icon(AIcons.createAlbum),
// TODO TLAD album creation
onPressed: null,
onPressed: () async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: 'Create album',
),
],
@ -75,12 +84,12 @@ class SelectionActionDelegate with PermissionAwareMixin {
),
filterEntries: source.getAlbumEntries(),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
onPressed: (filter) => Navigator.pop<AlbumFilter>(context, filter),
onPressed: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
);
},
),
);
if (filter == null) return;
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
final selection = collection.selection.toList();
if (!await checkStoragePermission(context, selection)) return;
@ -88,7 +97,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
_showOpReport(
context: context,
selection: selection,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: filter.album),
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
onDone: (Set<MoveOpEvent> processed) {
debugPrint('$runtimeType _moveSelection onDone');
final movedOps = processed.where((e) => e.success);
@ -97,6 +106,9 @@ class SelectionActionDelegate with PermissionAwareMixin {
if (movedCount < selectionCount) {
final count = selectionCount - movedCount;
_showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}');
} else {
final count = movedCount;
_showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '${count} item', other: '${count} items')}');
}
if (movedCount > 0) {
final source = collection.source;

View file

@ -43,7 +43,7 @@ class DebugPageState extends State<DebugPage> {
super.initState();
_startDbReport();
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
AndroidFileUtils.storageVolumes.map(
androidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
(value) => Tuple2(volume.path, value),
),
@ -259,7 +259,7 @@ class DebugPageState extends State<DebugPage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AndroidFileUtils.storageVolumes.expand((v) => [
...androidFileUtils.storageVolumes.expand((v) => [
Text(v.path),
InfoRowGroup({
'description': '${v.description}',