debug: content resolver metadata, copy/move WIP
This commit is contained in:
parent
cdf435420f
commit
1cd333d419
19 changed files with 613 additions and 176 deletions
|
@ -64,10 +64,18 @@ flutter {
|
|||
source '../..'
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url "https://s3.amazonaws.com/repo.commonsware.com"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
|
||||
|
||||
implementation "androidx.exifinterface:exifinterface:1.2.0"
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.14.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
||||
implementation 'com.google.guava:guava:29.0-android'
|
||||
|
|
|
@ -4,36 +4,40 @@ import android.app.Activity;
|
|||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
||||
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
||||
private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class);
|
||||
|
||||
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
|
||||
|
||||
private Activity activity;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private List<Map> entryMapList;
|
||||
private Map<String, Object> argMap;
|
||||
private List<Map<String, Object>> entryMapList;
|
||||
private String op;
|
||||
|
||||
public ImageOpStreamHandler(Activity activity, Object arguments) {
|
||||
this.activity = activity;
|
||||
if (arguments instanceof Map) {
|
||||
Map argMap = (Map) arguments;
|
||||
argMap = (Map<String, Object>) arguments;
|
||||
this.op = (String) argMap.get("op");
|
||||
this.entryMapList = new ArrayList<>();
|
||||
List rawEntries = (List) argMap.get("entries");
|
||||
List<Map<String, Object>> rawEntries = (List<Map<String, Object>>) argMap.get("entries");
|
||||
if (rawEntries != null) {
|
||||
for (Object entry : rawEntries) {
|
||||
entryMapList.add((Map) entry);
|
||||
}
|
||||
entryMapList.addAll(rawEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +48,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
this.handler = new Handler(Looper.getMainLooper());
|
||||
if ("delete".equals(op)) {
|
||||
new Thread(this::delete).start();
|
||||
} else if ("move".equals(op)) {
|
||||
new Thread(this::move).start();
|
||||
} else {
|
||||
endOfStream();
|
||||
}
|
||||
|
@ -53,7 +59,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
public void onCancel(Object o) {
|
||||
}
|
||||
|
||||
// {String uri, bool success}
|
||||
// {String uri, bool success, [Map<String, Object> newFields]}
|
||||
private void success(final Map<String, Object> result) {
|
||||
handler.post(() -> eventSink.success(result));
|
||||
}
|
||||
|
@ -66,6 +72,47 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
handler.post(() -> eventSink.endOfStream());
|
||||
}
|
||||
|
||||
private void move() {
|
||||
if (entryMapList.size() == 0) {
|
||||
endOfStream();
|
||||
return;
|
||||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
Map<String, Object> firstEntry = entryMapList.get(0);
|
||||
Uri firstUri = Uri.parse((String) firstEntry.get("uri"));
|
||||
ImageProvider provider = ImageProviderFactory.getProvider(firstUri);
|
||||
if (provider == null) {
|
||||
error("move-provider", "failed to find provider for uri=" + firstUri, null);
|
||||
return;
|
||||
}
|
||||
|
||||
Boolean copy = (Boolean) argMap.get("copy");
|
||||
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);
|
||||
}
|
||||
success(result);
|
||||
}
|
||||
endOfStream();
|
||||
}
|
||||
|
||||
private void delete() {
|
||||
if (entryMapList.size() == 0) {
|
||||
endOfStream();
|
||||
|
@ -73,7 +120,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
}
|
||||
|
||||
// assume same provider for all entries
|
||||
Map firstEntry = entryMapList.get(0);
|
||||
Map<String, Object> firstEntry = entryMapList.get(0);
|
||||
Uri firstUri = Uri.parse((String) firstEntry.get("uri"));
|
||||
ImageProvider provider = ImageProviderFactory.getProvider(firstUri);
|
||||
if (provider == null) {
|
||||
|
@ -81,29 +128,23 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
for (Map entryMap : entryMapList) {
|
||||
for (Map<String, Object> entryMap : entryMapList) {
|
||||
String uriString = (String) entryMap.get("uri");
|
||||
Uri uri = Uri.parse(uriString);
|
||||
String path = (String) entryMap.get("path");
|
||||
provider.delete(activity, path, uri, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> newFields) {
|
||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||
put("uri", uriString);
|
||||
put("success", true);
|
||||
}};
|
||||
success(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||
put("uri", uriString);
|
||||
put("success", false);
|
||||
}};
|
||||
success(result);
|
||||
}
|
||||
});
|
||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||
put("uri", uriString);
|
||||
}};
|
||||
try {
|
||||
provider.delete(activity, path, uri).get();
|
||||
result.put("success", true);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);
|
||||
result.put("success", false);
|
||||
}
|
||||
success(result);
|
||||
|
||||
}
|
||||
endOfStream();
|
||||
}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package deckers.thibault.aves.channelhandlers;
|
||||
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.format.Formatter;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -87,6 +90,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
case "getOverlayMetadata":
|
||||
new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
case "getContentResolverMetadata":
|
||||
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
|
@ -329,6 +335,56 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) {
|
||||
String mimeType = call.argument("mimeType");
|
||||
String uriString = call.argument("uri");
|
||||
if (mimeType == null || uriString == null) {
|
||||
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = Uri.parse(uriString);
|
||||
long id = ContentUris.parseId(uri);
|
||||
Uri contentUri = uri;
|
||||
if (mimeType.startsWith(MimeTypes.IMAGE)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
|
||||
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
|
||||
}
|
||||
|
||||
Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
Map<String, Object> metadataMap = new HashMap<>();
|
||||
int columnCount = cursor.getColumnCount();
|
||||
String[] columnNames = cursor.getColumnNames();
|
||||
for (int i = 0; i < columnCount; i++) {
|
||||
String key = columnNames[i];
|
||||
switch (cursor.getType(i)) {
|
||||
case Cursor.FIELD_TYPE_NULL:
|
||||
default:
|
||||
metadataMap.put(key, null);
|
||||
break;
|
||||
case Cursor.FIELD_TYPE_INTEGER:
|
||||
metadataMap.put(key, cursor.getInt(i));
|
||||
break;
|
||||
case Cursor.FIELD_TYPE_FLOAT:
|
||||
metadataMap.put(key, cursor.getFloat(i));
|
||||
break;
|
||||
case Cursor.FIELD_TYPE_STRING:
|
||||
metadataMap.put(key, cursor.getString(i));
|
||||
break;
|
||||
case Cursor.FIELD_TYPE_BLOB:
|
||||
metadataMap.put(key, cursor.getBlob(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursor.close();
|
||||
result.success(metadataMap);
|
||||
} else {
|
||||
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=" + contentUri, null);
|
||||
}
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
|
||||
|
|
|
@ -8,7 +8,6 @@ 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.Build;
|
||||
|
@ -20,11 +19,14 @@ import android.util.Log;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import com.drew.imaging.ImageMetadataReader;
|
||||
import com.drew.imaging.ImageProcessingException;
|
||||
import com.drew.metadata.Metadata;
|
||||
import com.drew.metadata.file.FileTypeDirectory;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
|
@ -49,8 +51,12 @@ public abstract class ImageProvider {
|
|||
callback.onFailure();
|
||||
}
|
||||
|
||||
public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) {
|
||||
callback.onFailure();
|
||||
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 rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider;
|
|||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
|
@ -14,6 +15,11 @@ import android.util.Log;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.SettableFuture;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -21,6 +27,7 @@ import java.util.stream.Stream;
|
|||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.PathComponents;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
@ -171,38 +178,112 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
return !MimeTypes.SVG.equals(mimeType);
|
||||
}
|
||||
|
||||
// check write access permission to SD card
|
||||
// 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.
|
||||
|
||||
@Override
|
||||
public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) {
|
||||
// check write access permission to SD card
|
||||
// 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.
|
||||
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) {
|
||||
SettableFuture<Object> future = SettableFuture.create();
|
||||
|
||||
if (Env.isOnSdCard(activity, path)) {
|
||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
if (sdCardTreeUri == null) {
|
||||
Runnable runnable = () -> delete(activity, path, uri, callback);
|
||||
Runnable runnable = () -> {
|
||||
try {
|
||||
future.set(delete(activity, path, uri).get());
|
||||
} catch (Exception e) {
|
||||
future.setException(e);
|
||||
}
|
||||
};
|
||||
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
|
||||
return;
|
||||
return future;
|
||||
}
|
||||
|
||||
// if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store
|
||||
// but it doesn't delete the file, even if the app has the permission
|
||||
StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path);
|
||||
Log.d(LOG_TAG, "deleted from SD card at path=" + uri);
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
future.set(null);
|
||||
return future;
|
||||
}
|
||||
|
||||
try {
|
||||
if (activity.getContentResolver().delete(uri, null, null) > 0) {
|
||||
Log.d(LOG_TAG, "deleted from content resolver uri=" + uri);
|
||||
callback.onSuccess(null);
|
||||
return;
|
||||
future.set(null);
|
||||
} else {
|
||||
future.setException(new Exception("failed to delete row from content provider"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(LOG_TAG, "failed to delete entry", e);
|
||||
future.setException(e);
|
||||
}
|
||||
callback.onFailure();
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
// if (Env.isOnSdCard(activity, path)) {
|
||||
// Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
// if (sdCardTreeUri == null) {
|
||||
// Runnable runnable = () -> move(activity, path, uri, copy, destinationPath, callback);
|
||||
// new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable));
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store
|
||||
// // but it doesn't delete the file, even if the app has the permission
|
||||
// StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path);
|
||||
// Log.d(LOG_TAG, "deleted from SD card at path=" + uri);
|
||||
// callback.onSuccess(null);
|
||||
// return;
|
||||
// }
|
||||
|
||||
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(...)`
|
||||
// DocumentFile.getUri() is same as original uri: "content://media/external/images/media/58457"
|
||||
// DocumentFile.getParentFile() is null without picking a tree first
|
||||
// DocumentsContract.copyDocument() and moveDocument() need parent doc uri
|
||||
|
||||
// TODO TLAD copy/move
|
||||
// TODO TLAD cannot copy to SD card, even with the permission to the volume root, by inserting to MediaStore
|
||||
|
||||
PathComponents sourcePathComponents = new PathComponents(sourcePath, Env.getStorageVolumes(activity));
|
||||
String destinationPath = destinationDir + File.separator + sourcePathComponents.getFilename();
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
||||
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
||||
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI : MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
||||
Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues);
|
||||
// Log.d("TLAD", "move copy from=" + sourcePath + " to=" + destinationPath + " (destinationUri=" + destinationUri + ")");
|
||||
if (destinationUri == null) {
|
||||
future.setException(new Exception("failed to insert row to content resolver"));
|
||||
} else {
|
||||
DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath));
|
||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||
source.copyTo(destination);
|
||||
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("uri", destinationUri.toString());
|
||||
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
||||
newFields.put("path", destinationPath);
|
||||
future.set(newFields);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
|
||||
future.setException(e);
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public interface NewEntryHandler {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.media.ExifInterface;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
|
|
|
@ -27,11 +27,11 @@ public class PathComponents {
|
|||
return storage;
|
||||
}
|
||||
|
||||
String getFolder() {
|
||||
public String getFolder() {
|
||||
return folder;
|
||||
}
|
||||
|
||||
String getFilename() {
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,4 +65,9 @@ class AlbumFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
int get hashCode => hashValues('AlbumFilter', album);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AlbumFilter{album=$album}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,11 +35,16 @@ class LocationFilter extends CollectionFilter {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is LocationFilter && other._location == _location;
|
||||
return other is LocationFilter && other.level == level && other._location == _location;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues('LocationFilter', _location);
|
||||
int get hashCode => hashValues('LocationFilter', level, _location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LocationFilter{level=$level, location=$_location}';
|
||||
}
|
||||
|
||||
// U+0041 Latin Capital letter A
|
||||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||
|
|
|
@ -33,4 +33,9 @@ class TagFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
int get hashCode => hashValues('TagFilter', tag);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TagFilter{tag=$tag}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
|
@ -95,6 +96,21 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
static Stream<MoveOpEvent> move(List<ImageEntry> entries, {@required bool copy, @required String destinationPath}) {
|
||||
debugPrint('move ${entries.length} entries');
|
||||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'move',
|
||||
'entries': entries.map((e) => e.toMap()).toList(),
|
||||
'copy': copy,
|
||||
'destinationPath': destinationPath,
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map> rename(ImageEntry entry, String newName) async {
|
||||
try {
|
||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
|
@ -125,15 +141,55 @@ class ImageFileService {
|
|||
}
|
||||
|
||||
class ImageOpEvent {
|
||||
final String uri;
|
||||
final bool success;
|
||||
final String uri;
|
||||
|
||||
ImageOpEvent({this.uri, this.success});
|
||||
ImageOpEvent({
|
||||
this.success,
|
||||
this.uri,
|
||||
});
|
||||
|
||||
factory ImageOpEvent.fromMap(Map map) {
|
||||
return ImageOpEvent(
|
||||
uri: map['uri'],
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues('ImageOpEvent', success, uri);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageOpEvent{success=$success, uri=$uri}';
|
||||
}
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
final Map newFields;
|
||||
|
||||
MoveOpEvent({bool success, String uri, this.newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
);
|
||||
|
||||
factory MoveOpEvent.fromMap(Map map) {
|
||||
return MoveOpEvent(
|
||||
success: map['success'] ?? false,
|
||||
uri: map['uri'],
|
||||
newFields: map['newFields'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,4 +70,18 @@ class MetadataService {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the content resolver
|
||||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -160,7 +160,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
builder: (context, child) {
|
||||
return IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onActionSelected(context, action),
|
||||
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
|
||||
tooltip: action.getText(),
|
||||
);
|
||||
},
|
||||
|
@ -182,6 +182,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
),
|
||||
],
|
||||
if (collection.isSelecting) ...[
|
||||
const PopupMenuItem(
|
||||
value: CollectionAction.copy,
|
||||
child: MenuRow(text: 'Copy to album'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: CollectionAction.move,
|
||||
child: MenuRow(text: 'Move to album'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: CollectionAction.selectAll,
|
||||
child: MenuRow(text: 'Select all'),
|
||||
|
@ -253,6 +261,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Constants.popupMenuTransitionDuration);
|
||||
switch (action) {
|
||||
case CollectionAction.copy:
|
||||
case CollectionAction.move:
|
||||
_actionDelegate.onCollectionActionSelected(context, action);
|
||||
break;
|
||||
case CollectionAction.select:
|
||||
collection.select();
|
||||
break;
|
||||
|
@ -312,4 +324,4 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
enum CollectionAction { select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
|
||||
enum CollectionAction { copy, move, select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
|
||||
|
|
|
@ -105,9 +105,11 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
videoEntry,
|
||||
favouriteEntry,
|
||||
_buildSpecialAlbumSection(),
|
||||
const Divider(),
|
||||
_buildRegularAlbumSection(),
|
||||
_buildCountrySection(),
|
||||
_buildTagSection(),
|
||||
const Divider(),
|
||||
aboutEntry,
|
||||
if (kDebugMode) ...[
|
||||
const Divider(),
|
||||
|
@ -252,7 +254,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterGridPage(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Albums',
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
|
@ -267,7 +269,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterGridPage(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Countries',
|
||||
filterEntries: source.getCountryEntries(),
|
||||
|
@ -282,7 +284,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterGridPage(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Tags',
|
||||
filterEntries: source.getTagEntries(),
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/collection_lens.dart';
|
||||
import 'package:aves/model/filters/album.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/album/app_bar.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';
|
||||
import 'package:aves/widgets/filter_grid_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flushbar/flushbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -20,7 +24,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
@required this.collection,
|
||||
});
|
||||
|
||||
void onActionSelected(BuildContext context, EntryAction action) {
|
||||
void onEntryActionSelected(BuildContext context, EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.delete:
|
||||
_showDeleteDialog(context);
|
||||
|
@ -33,6 +37,77 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
}
|
||||
}
|
||||
|
||||
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
||||
switch (action) {
|
||||
case CollectionAction.copy:
|
||||
_moveSelection(context, copy: true);
|
||||
break;
|
||||
case CollectionAction.move:
|
||||
_moveSelection(context, copy: false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||
final filter = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<AlbumFilter>(
|
||||
builder: (context) {
|
||||
final source = collection.source;
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
leading: const BackButton(),
|
||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.createAlbum),
|
||||
onPressed: () {
|
||||
// TODO TLAD album creation
|
||||
},
|
||||
tooltip: 'Create album',
|
||||
),
|
||||
],
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
onPressed: (filter) => Navigator.pop<AlbumFilter>(context, filter),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (filter == null) return;
|
||||
|
||||
final selection = collection.selection.toList();
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
||||
_showOpReport(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.move(selection, copy: copy, destinationPath: filter.album),
|
||||
onDone: (Set<MoveOpEvent> processed) {
|
||||
debugPrint('$runtimeType _moveSelection onDone');
|
||||
final movedUris = processed.where((e) => e.success).map((e) => e.uri);
|
||||
final movedCount = movedUris.length;
|
||||
final selectionCount = selection.length;
|
||||
if (movedCount < selectionCount) {
|
||||
final count = selectionCount - movedCount;
|
||||
_showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}');
|
||||
}
|
||||
if (movedCount > 0) {
|
||||
processed.forEach((event) {
|
||||
debugPrint('$runtimeType _moveSelection moved entry uri=${event.uri} newFields=${event.newFields}');
|
||||
// TODO TLAD update source
|
||||
});
|
||||
}
|
||||
collection.browse();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context) async {
|
||||
final selection = collection.selection.toList();
|
||||
final count = selection.length;
|
||||
|
@ -63,7 +138,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.delete(selection),
|
||||
onDone: (processed) {
|
||||
onDone: (Set<ImageOpEvent> processed) {
|
||||
final deletedUris = processed.where((e) => e.success).map((e) => e.uri);
|
||||
final deletedCount = deletedUris.length;
|
||||
final selectionCount = selection.length;
|
||||
|
@ -90,16 +165,22 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
|
||||
static const _overlayAnimationDuration = Duration(milliseconds: 300);
|
||||
|
||||
void _showOpReport({
|
||||
void _showOpReport<T extends ImageOpEvent>({
|
||||
@required BuildContext context,
|
||||
@required List<ImageEntry> selection,
|
||||
@required Stream<ImageOpEvent> opStream,
|
||||
@required void Function(Set<ImageOpEvent> processed) onDone,
|
||||
@required Stream<T> opStream,
|
||||
@required void Function(Set<T> processed) onDone,
|
||||
}) {
|
||||
final processed = <ImageOpEvent>{};
|
||||
final processed = <T>{};
|
||||
|
||||
// do not handle completion inside `StreamBuilder`
|
||||
// as it could be called multiple times
|
||||
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||
opStream.listen(null, onError: (error) => onComplete(), onDone: onComplete);
|
||||
|
||||
_opReportOverlayEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return StreamBuilder<ImageOpEvent>(
|
||||
return StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
|
@ -107,9 +188,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Widget child = const SizedBox.shrink();
|
||||
if (snapshot.hasError || snapshot.connectionState == ConnectionState.done) {
|
||||
_hideOpReportOverlay().then((_) => onDone(processed));
|
||||
} else if (snapshot.connectionState == ConnectionState.active) {
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
percent: percent,
|
||||
|
|
|
@ -25,6 +25,7 @@ class AIcons {
|
|||
// actions
|
||||
static const IconData clear = OMIcons.clear;
|
||||
static const IconData collapse = OMIcons.expandLess;
|
||||
static const IconData createAlbum = OMIcons.addCircleOutline;
|
||||
static const IconData debug = OMIcons.whatshot;
|
||||
static const IconData delete = OMIcons.delete;
|
||||
static const IconData expand = OMIcons.expandMore;
|
||||
|
|
|
@ -49,7 +49,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
bottom: TabBar(
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(OMIcons.whatshot)),
|
||||
Tab(icon: Icon(OMIcons.settings)),
|
||||
|
|
|
@ -12,19 +12,60 @@ import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilterGridPage extends StatelessWidget {
|
||||
class FilterNavigationPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
|
||||
const FilterGridPage({
|
||||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
title: Text(title),
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: filterEntries,
|
||||
filterBuilder: filterBuilder,
|
||||
onPressed: (filter) => Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CollectionPage(CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
)),
|
||||
),
|
||||
(route) => false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final Widget appBar;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final FilterCallback onPressed;
|
||||
|
||||
const FilterGridPage({
|
||||
@required this.source,
|
||||
@required this.appBar,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.onPressed,
|
||||
});
|
||||
|
||||
List<String> get filterKeys => filterEntries.keys.toList();
|
||||
|
||||
@override
|
||||
|
@ -34,10 +75,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(title),
|
||||
floating: true,
|
||||
),
|
||||
appBar,
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.all(AvesFilterChip.buttonBorderWidth),
|
||||
sliver: SliverGrid(
|
||||
|
@ -62,18 +100,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
filter: filterBuilder(key),
|
||||
showGenericIcon: false,
|
||||
decoration: decoration,
|
||||
onPressed: (filter) => Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CollectionPage(CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
)),
|
||||
),
|
||||
(route) => false,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
childCount: filterKeys.length,
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -17,115 +20,152 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
Future<DateMetadata> _dbDateLoader;
|
||||
Future<CatalogMetadata> _dbMetadataLoader;
|
||||
Future<AddressDetails> _dbAddressLoader;
|
||||
Future<Map> _contentResolverMetadataLoader;
|
||||
|
||||
int get contentId => widget.entry.contentId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startDbReport();
|
||||
_initFutures();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final catalog = widget.entry.catalogMetadata;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, AsyncSnapshot<DateMetadata> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB date:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
future: _dbMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<CatalogMetadata> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
'isAnimated': '${data.isAnimated}',
|
||||
'videoRotation': '${data.videoRotation}',
|
||||
'latitude': '${data.latitude}',
|
||||
'longitude': '${data.longitude}',
|
||||
'xmpSubjects': '${data.xmpSubjects}',
|
||||
'xmpTitleDescription': '${data.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, AsyncSnapshot<AddressDetails> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB address:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'addressLine': '${data.addressLine}',
|
||||
'countryCode': '${data.countryCode}',
|
||||
'countryName': '${data.countryName}',
|
||||
'adminArea': '${data.adminArea}',
|
||||
'locality': '${data.locality}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
|
||||
if (catalog != null)
|
||||
InfoRowGroup({
|
||||
'contentId': '${catalog.contentId}',
|
||||
'dateMillis': '${catalog.dateMillis}',
|
||||
'isAnimated': '${catalog.isAnimated}',
|
||||
'videoRotation': '${catalog.videoRotation}',
|
||||
'latitude': '${catalog.latitude}',
|
||||
'longitude': '${catalog.longitude}',
|
||||
'xmpSubjects': '${catalog.xmpSubjects}',
|
||||
'xmpTitleDescription': '${catalog.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'DB'),
|
||||
Tab(text: 'Content Resolver'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_buildDbTabView(),
|
||||
_buildContentResolverTabView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startDbReport() {
|
||||
Widget _buildDbTabView() {
|
||||
final catalog = widget.entry.catalogMetadata;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, AsyncSnapshot<DateMetadata> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB date:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
future: _dbMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<CatalogMetadata> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
'isAnimated': '${data.isAnimated}',
|
||||
'videoRotation': '${data.videoRotation}',
|
||||
'latitude': '${data.latitude}',
|
||||
'longitude': '${data.longitude}',
|
||||
'xmpSubjects': '${data.xmpSubjects}',
|
||||
'xmpTitleDescription': '${data.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, AsyncSnapshot<AddressDetails> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB address:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'addressLine': '${data.addressLine}',
|
||||
'countryCode': '${data.countryCode}',
|
||||
'countryName': '${data.countryName}',
|
||||
'adminArea': '${data.adminArea}',
|
||||
'locality': '${data.locality}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
|
||||
if (catalog != null)
|
||||
InfoRowGroup({
|
||||
'contentId': '${catalog.contentId}',
|
||||
'dateMillis': '${catalog.dateMillis}',
|
||||
'isAnimated': '${catalog.isAnimated}',
|
||||
'videoRotation': '${catalog.videoRotation}',
|
||||
'latitude': '${catalog.latitude}',
|
||||
'longitude': '${catalog.longitude}',
|
||||
'xmpSubjects': '${catalog.xmpSubjects}',
|
||||
'xmpTitleDescription': '${catalog.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentResolverTabView() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<Map> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
|
||||
return InfoRowGroup(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _initFutures() {
|
||||
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(widget.entry);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue