Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-09-29 21:31:44 +09:00
commit 98cbde3672
94 changed files with 1112 additions and 1811 deletions

View file

@ -22,6 +22,7 @@ if (flutterVersionName == null) {
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
@ -43,6 +44,10 @@ if (keystorePropertiesFile.exists()) {
android {
compileSdkVersion 30 // latest (or latest-1 if the sources of latest SDK are unavailable)
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
lintOptions {
disable 'InvalidPackage'
}
@ -100,11 +105,12 @@ repositories {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
implementation 'androidx.core:core:1.5.0-alpha02' // v1.5.0-alpha02 for ShortcutManagerCompat.setDynamicShortcuts
implementation "androidx.exifinterface:exifinterface:1.2.0"
implementation 'androidx.core:core:1.5.0-alpha03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation "androidx.exifinterface:exifinterface:1.3.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'

View file

@ -1,179 +0,0 @@
package deckers.thibault.aves;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import app.loup.streams_channel.StreamsChannel;
import deckers.thibault.aves.channel.calls.AppAdapterHandler;
import deckers.thibault.aves.channel.calls.AppShortcutHandler;
import deckers.thibault.aves.channel.calls.ImageFileHandler;
import deckers.thibault.aves.channel.calls.MetadataHandler;
import deckers.thibault.aves.channel.calls.StorageHandler;
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler;
import deckers.thibault.aves.channel.streams.ImageOpStreamHandler;
import deckers.thibault.aves.channel.streams.IntentStreamHandler;
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler;
import deckers.thibault.aves.channel.streams.StorageAccessStreamHandler;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.Utils;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel;
public class MainActivity extends FlutterActivity {
private static final String LOG_TAG = Utils.createLogTag(MainActivity.class);
public static final String INTENT_CHANNEL = "deckers.thibault/aves/intent";
public static final String VIEWER_CHANNEL = "deckers.thibault/aves/viewer";
private IntentStreamHandler intentStreamHandler;
private Map<String, Object> intentDataMap;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleIntent(getIntent());
BinaryMessenger messenger = Objects.requireNonNull(getFlutterEngine()).getDartExecutor().getBinaryMessenger();
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
new MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(new AppShortcutHandler(this));
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this));
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
new MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(new StorageHandler(this));
new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageByteStreamHandler(this, args));
new StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageOpStreamHandler(this, args));
new StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new MediaStoreStreamHandler(this, args));
new StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new StorageAccessStreamHandler(this, args));
new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler(
(call, result) -> {
if (call.method.contentEquals("getIntentData")) {
result.success(intentDataMap);
intentDataMap = null;
} else if (call.method.contentEquals("pick")) {
result.success(intentDataMap);
intentDataMap = null;
String resultUri = call.argument("uri");
if (resultUri != null) {
Intent intent = new Intent();
intent.setData(Uri.parse(resultUri));
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
setResult(RESULT_OK, intent);
} else {
setResult(RESULT_CANCELED);
}
finish();
}
});
intentStreamHandler = new IntentStreamHandler();
new EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts();
}
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private void setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
ShortcutInfoCompat search = new ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
.setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class)
.putExtra("page", "/search"))
.build();
ShortcutInfoCompat videos = new ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
.setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class)
.putExtra("page", "/collection")
.putExtra("filters", new String[]{"{\"type\":\"mime\",\"mime\":\"video/*\"}"}))
.build();
ShortcutManagerCompat.setDynamicShortcuts(this, Arrays.asList(videos, search));
}
@Override
protected void onNewIntent(@NonNull Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
intentStreamHandler.notifyNewIntent();
}
private void handleIntent(Intent intent) {
Log.i(LOG_TAG, "handleIntent intent=" + intent);
if (intent == null) return;
String action = intent.getAction();
if (action == null) return;
switch (action) {
case Intent.ACTION_MAIN:
String page = intent.getStringExtra("page");
if (page != null) {
intentDataMap = new HashMap<>();
intentDataMap.put("page", page);
String[] filters = intent.getStringArrayExtra("filters");
intentDataMap.put("filters", filters != null ? new ArrayList<>(Arrays.asList(filters)) : null);
}
break;
case Intent.ACTION_VIEW:
Uri uri = intent.getData();
String mimeType = intent.getType();
if (uri != null && mimeType != null) {
intentDataMap = new HashMap<>();
intentDataMap.put("action", "view");
intentDataMap.put("uri", uri.toString());
intentDataMap.put("mimeType", mimeType);
}
break;
case Intent.ACTION_GET_CONTENT:
case Intent.ACTION_PICK:
intentDataMap = new HashMap<>();
intentDataMap.put("action", "pick");
intentDataMap.put("mimeType", intent.getType());
break;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
if (resultCode != RESULT_OK || data.getData() == null) {
PermissionManager.onPermissionResult(requestCode, null);
return;
}
Uri treeUri = data.getData();
// save access permissions across reboots
final int takeFlags = data.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri);
}
}
}

View file

@ -38,8 +38,6 @@ import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import static com.bumptech.glide.request.RequestOptions.centerCropTransform;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class);
@ -184,7 +182,7 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap()
.apply(options)
.apply(centerCropTransform())
.centerCrop()
.load(uri)
.signature(signature)
.submit(size, size);

View file

@ -2,6 +2,8 @@ package deckers.thibault.aves.channel.calls;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.text.TextUtils;
import androidx.annotation.NonNull;
@ -10,6 +12,9 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import java.util.List;
import deckers.thibault.aves.MainActivity;
@ -35,8 +40,9 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
}
case "pin": {
String label = call.argument("label");
byte[] iconBytes = call.argument("iconBytes");
List<String> filters = call.argument("filters");
pin(label, filters);
new Thread(() -> pin(label, iconBytes, filters)).start();
result.success(null);
break;
}
@ -46,17 +52,28 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
}
}
private void pin(String label, @Nullable List<String> filters) {
private void pin(String label, byte[] iconBytes, @Nullable List<String> filters) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context) || filters == null) {
return;
}
IconCompat icon;
if (iconBytes != null && iconBytes.length > 0) {
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
bitmap = TransformationUtils.centerCrop(new LruBitmapPool(2 << 24), bitmap, 256, 256);
icon = IconCompat.createWithBitmap(bitmap);
} else {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
}
Intent intent = new Intent(Intent.ACTION_MAIN, null, context, MainActivity.class)
.putExtra("page", "/collection")
.putExtra("filters", filters.toArray(new String[0]));
ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(context, "collection-" + TextUtils.join("-", filters))
.setShortLabel(label)
.setIcon(IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection))
.setIntent(new Intent(Intent.ACTION_MAIN, null, context, MainActivity.class)
.putExtra("page", "/collection")
.putExtra("filters", filters.toArray(new String[0])))
.setIcon(icon)
.setIntent(intent)
.build();
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null);

View file

@ -120,10 +120,14 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
AvesImageEntry entry = params.entry;
Integer width = params.width;
Integer height = params.height;
// Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path);
ContentResolver resolver = activity.getContentResolver();
return resolver.loadThumbnail(entry.uri, new Size(width, height), null);
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
}
return bitmap;
}
private Bitmap getThumbnailBytesByMediaStore(Params params) {

View file

@ -58,9 +58,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break;
case "renameDirectory":
new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
@ -182,26 +179,4 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
});
}
private void renameDirectory(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String dirPath = call.argument("path");
String newName = call.argument("newName");
if (dirPath == null || newName == null) {
result.error("renameDirectory-args", "failed because of missing arguments", null);
return;
}
ImageProvider provider = new MediaStoreImageProvider();
provider.renameDirectory(activity, dirPath, newName, new ImageProvider.AlbumRenameOpCallback() {
@Override
public void onSuccess(List<Map<String, Object>> fieldsByEntry) {
result.success(fieldsByEntry);
}
@Override
public void onFailure(Throwable throwable) {
result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage());
}
});
}
}

View file

@ -190,6 +190,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getEmbeddedPictures":
new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start();
break;
case "getExifThumbnails":
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
break;
@ -415,7 +418,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
metadataMap.put(KEY_LATITUDE, latitude);
metadataMap.put(KEY_LONGITUDE, longitude);
}
} catch (NumberFormatException ex) {
} catch (NumberFormatException e) {
// ignore
}
}
@ -530,6 +533,26 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
}
private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> pictures = new ArrayList<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
pictures.add(picture);
}
} catch (Exception e) {
result.error("getVideoEmbeddedPictures-failure", "failed to get embedded picture for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
result.success(pictures);
}
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();

View file

@ -52,10 +52,17 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
}
break;
}
case "scanFile": {
case "revokeDirectoryAccess":
String path = call.argument("path");
PermissionManager.revokeDirectoryAccess(context, path);
result.success(true);
break;
case "getGrantedDirectories":
result.success(new ArrayList<>(PermissionManager.getGrantedDirs(context)));
break;
case "scanFile":
scanFile(call, new MethodResultWrapper(result));
break;
}
default:
result.notImplemented();
break;

View file

@ -40,8 +40,8 @@ class VideoThumbnailFetcher implements DataFetcher<InputStream> {
}
callback.onDataReady(new ByteArrayInputStream(bos.toByteArray()));
}
} catch (Exception ex) {
callback.onLoadFailed(ex);
} catch (Exception e) {
callback.onLoadFailed(e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();

View file

@ -17,17 +17,14 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
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.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper;
@ -89,101 +86,6 @@ public abstract class ImageProvider {
scanNewPath(context, newFile.getPath(), mimeType, callback);
}
@SuppressWarnings("UnstableApiUsage")
public void renameDirectory(Context context, String oldDirPath, String newDirName, final AlbumRenameOpCallback callback) {
if (!oldDirPath.endsWith(File.separator)) {
oldDirPath += File.separator;
}
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, oldDirPath);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to find directory at path=" + oldDirPath));
return;
}
List<Map<String, Object>> entries = new ArrayList<>();
entries.addAll(listContentEntries(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, oldDirPath));
entries.addAll(listContentEntries(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, oldDirPath));
boolean renamed;
try {
renamed = destinationDirDocFile.renameTo(newDirName);
} catch (FileNotFoundException e) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath, e));
return;
}
if (!renamed) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath));
return;
}
List<SettableFuture<Map<String, Object>>> scanFutures = new ArrayList<>();
String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator;
for (Map<String, Object> entry : entries) {
String displayName = (String) entry.get("displayName");
String mimeType = (String) entry.get("mimeType");
String oldEntryPath = oldDirPath + displayName;
MediaScannerConnection.scanFile(context, new String[]{oldEntryPath}, new String[]{mimeType}, null);
SettableFuture<Map<String, Object>> scanFuture = SettableFuture.create();
scanFutures.add(scanFuture);
String newEntryPath = newDirPath + displayName;
scanNewPath(context, newEntryPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
entry.putAll(newFields);
entry.put("success", true);
scanFuture.set(entry);
}
@Override
public void onFailure(Throwable throwable) {
Log.w(LOG_TAG, "failed to scan entry=" + displayName + " in new directory=" + newDirPath, throwable);
entry.put("success", false);
scanFuture.set(entry);
}
});
}
try {
callback.onSuccess(Futures.allAsList(scanFutures).get());
} catch (ExecutionException | InterruptedException e) {
callback.onFailure(e);
}
}
private List<Map<String, Object>> listContentEntries(Context context, Uri contentUri, String dirPath) {
List<Map<String, Object>> entries = new ArrayList<>();
String[] projection = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
};
String selection = MediaStore.MediaColumns.DATA + " like ?";
try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[]{dirPath + "%"}, null);
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
while (cursor.moveToNext()) {
entries.add(new HashMap<String, Object>() {{
put("oldContentId", cursor.getInt(idColumn));
put("displayName", cursor.getString(displayNameColumn));
put("mimeType", cursor.getString(mimeTypeColumn));
}});
}
cursor.close();
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to list entries in contentUri=" + contentUri, e);
}
return entries;
}
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
switch (mimeType) {
case MimeTypes.JPEG:
@ -355,7 +257,7 @@ public abstract class ImageProvider {
}
Map<String, Object> newFields = new HashMap<>();
// we retrieve updated fields as the renamed file became a new entry in the Media Store
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
String[] projection = {
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
@ -392,10 +294,4 @@ public abstract class ImageProvider {
void onFailure(Throwable throwable);
}
public interface AlbumRenameOpCallback {
void onSuccess(List<Map<String, Object>> fieldsByEntry);
void onFailure(Throwable throwable);
}
}

View file

@ -1,79 +0,0 @@
package deckers.thibault.aves.utils;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MetadataHelper {
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
public static int getOrientationDegreesForExifCode(int exifOrientation) {
switch (exifOrientation) {
case ExifInterface.ORIENTATION_ROTATE_180: // bottom, right side
return 180;
case ExifInterface.ORIENTATION_ROTATE_90: // right side, top
return 90;
case ExifInterface.ORIENTATION_ROTATE_270: // left side, bottom
return 270;
}
// all other orientations (regular, flipped...) default to an angle of 0 degree
return 0;
}
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
public static long parseVideoMetadataDate(@Nullable String dateString) {
if (dateString == null) {
return 0;
}
// optional sub-second
String subSecond = null;
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
if (subSecondMatcher.find()) {
subSecond = subSecondMatcher.group(2).substring(1);
dateString = subSecondMatcher.replaceAll("$1");
}
// optional time zone
TimeZone timeZone = null;
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
dateString = timeZoneMatcher.replaceAll("");
}
Date date = null;
try {
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
date = parser.parse(dateString);
} catch (ParseException ex) {
// ignore
}
if (date == null) {
return 0;
}
long dateMillis = date.getTime();
if (subSecond != null) {
try {
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
if (millis >= 0 && millis < 1000) {
dateMillis += millis;
}
} catch (NumberFormatException e) {
// ignore
}
}
return dateMillis;
}
}

View file

@ -1,19 +0,0 @@
package deckers.thibault.aves.utils;
public class MimeTypes {
public static final String IMAGE = "image";
public static final String DNG = "image/x-adobe-dng"; // .dng
public static final String GIF = "image/gif";
public static final String HEIC = "image/heic";
public static final String HEIF = "image/heif";
public static final String JPEG = "image/jpeg";
public static final String PNG = "image/png";
public static final String PSD = "image/x-photoshop"; // .psd
public static final String SVG = "image/svg+xml"; // .svg
public static final String WEBP = "image/webp";
public static final String VIDEO = "video";
public static final String AVI = "video/avi";
public static final String MP2T = "video/mp2t"; // .m2ts
public static final String MP4 = "video/mp4";
}

View file

@ -71,11 +71,11 @@ public class PermissionManager {
}
public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst();
return getAccessibleDirs(context).stream().filter(anyPath::startsWith).findFirst();
}
public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
Set<String> grantedDirs = getGrantedDirs(context);
Set<String> accessibleDirs = getAccessibleDirs(context);
// find set of inaccessible directories for each volume
Map<String, Set<String>> dirsPerVolume = new HashMap<>();
@ -83,7 +83,7 @@ public class PermissionManager {
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
if (grantedDirs.stream().noneMatch(dirPath::startsWith)) {
if (accessibleDirs.stream().noneMatch(dirPath::startsWith)) {
// inaccessible dirs
StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath);
Set<String> dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>());
@ -135,22 +135,34 @@ public class PermissionManager {
return inaccessibleDirs;
}
private static Set<String> getGrantedDirs(Context context) {
HashSet<String> accessibleDirs = new HashSet<>();
// find paths matching URIs granted by the user
public static void revokeDirectoryAccess(Context context, String path) {
Optional<Uri> uri = StorageUtils.convertDirPathToTreeUri(context, path);
if (uri.isPresent()) {
int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
context.getContentResolver().releasePersistableUriPermission(uri.get(), flags);
}
}
// returns paths matching URIs granted by the user
public static Set<String> getGrantedDirs(Context context) {
Set<String> grantedDirs = new HashSet<>();
for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) {
Optional<String> dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri());
dirPath.ifPresent(accessibleDirs::add);
dirPath.ifPresent(grantedDirs::add);
}
return grantedDirs;
}
// returns paths accessible to the app (granted by the user or by default)
private static Set<String> getAccessibleDirs(Context context) {
Set<String> accessibleDirs = new HashSet<>(getGrantedDirs(context));
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
String primaryPath = StorageUtils.getPrimaryVolumePath();
accessibleDirs.add(primaryPath);
}
Log.d(LOG_TAG, "getGrantedDirs accessibleDirs=" + accessibleDirs);
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=" + accessibleDirs);
return accessibleDirs;
}

View file

@ -1,54 +0,0 @@
package deckers.thibault.aves.utils;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.regex.Pattern;
public class Utils {
private static final int logTagMaxLength = 23;
private static final Pattern logTagPackagePattern = Pattern.compile("(\\w)(\\w*)\\.");
public static String createLogTag(Class<?> clazz) {
// shorten class name to "a.b.CccDdd"
String logTag = logTagPackagePattern.matcher(clazz.getName()).replaceAll("$1.");
if (logTag.length() > logTagMaxLength) {
// shorten class name to "a.b.CD"
String simpleName = clazz.getSimpleName();
String shortSimpleName = simpleName.replaceAll("[a-z]", "");
logTag = logTag.replace(simpleName, shortSimpleName);
if (logTag.length() > logTagMaxLength) {
// shorten class name to "CD"
logTag = shortSimpleName;
}
}
return logTag;
}
public static void copyFile(final File source, final FileDescriptor descriptor) throws IOException {
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(descriptor)) {
final FileChannel inChannel = inStream.getChannel();
final FileChannel outChannel = outStream.getChannel();
final long size = inChannel.size();
long position = 0;
while (position < size) {
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
}
}
}
public static void copyFile(final File source, final File destination) throws IOException {
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(destination)) {
final FileChannel inChannel = inStream.getChannel();
final FileChannel outChannel = outStream.getChannel();
final long size = inChannel.size();
long position = 0;
while (position < size) {
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
}
}
}
}

View file

@ -0,0 +1,159 @@
package deckers.thibault.aves
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.Utils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = Utils.createLogTag(MainActivity::class.java)
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
private val intentStreamHandler = IntentStreamHandler()
private var intentDataMap: MutableMap<String, Any?>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getIntentData" -> {
result.success(intentDataMap)
intentDataMap = null
}
"pick" -> {
result.success(intentDataMap)
intentDataMap = null
val resultUri = call.argument<String>("uri")
if (resultUri != null) {
val intent = Intent().apply {
data = Uri.parse(resultUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
setResult(RESULT_OK, intent)
} else {
setResult(RESULT_CANCELED)
}
finish()
}
}
}
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts()
}
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
val search = ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/search"))
.build()
val videos = ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")))
.build()
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
intentStreamHandler.notifyNewIntent()
}
private fun handleIntent(intent: Intent?) {
Log.i(LOG_TAG, "handleIntent intent=$intent")
if (intent == null) return
when (intent.action) {
Intent.ACTION_MAIN -> {
val page = intent.getStringExtra("page")
if (page != null) {
intentDataMap = hashMapOf(
"page" to page,
"filters" to intent.getStringArrayExtra("filters")?.toList(),
)
}
}
Intent.ACTION_VIEW -> {
val uri = intent.data
val mimeType = intent.type
if (uri != null && mimeType != null) {
intentDataMap = hashMapOf(
"action" to "view",
"uri" to uri.toString(),
"mimeType" to mimeType,
)
}
}
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
intentDataMap = hashMapOf(
"action" to "pick",
"mimeType" to intent.type,
)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
val treeUri = data.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)
return
}
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action
PermissionManager.onPermissionResult(requestCode, treeUri)
}
}
}

View file

@ -0,0 +1,63 @@
package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
import java.text.DateFormat
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
object MetadataHelper {
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
@JvmStatic
fun getOrientationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0 // all other orientations (regular, flipped...) default to an angle of 0 degree
}
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
@JvmStatic
fun parseVideoMetadataDate(metadataDate: String?): Long {
var dateString = metadataDate ?: return 0
// optional sub-second
var subSecond: String? = null
val subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString)
if (subSecondMatcher.find()) {
subSecond = subSecondMatcher.group(2)?.substring(1)
dateString = subSecondMatcher.replaceAll("$1")
}
// optional time zone
var timeZone: TimeZone? = null
val timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString)
if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), ""))
dateString = timeZoneMatcher.replaceAll("")
}
val date: Date = try {
val parser = SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US)
parser.timeZone = timeZone ?: TimeZone.getTimeZone("GMT")
parser.parse(dateString)
} catch (e: ParseException) {
// ignore
null
} ?: return 0
var dateMillis = date.time
if (subSecond != null) {
try {
val millis = (".$subSecond".toDouble() * 1000).toInt()
if (millis in 0..999) {
dateMillis += millis.toLong()
}
} catch (e: NumberFormatException) {
// ignore
}
}
return dateMillis
}
}

View file

@ -0,0 +1,18 @@
package deckers.thibault.aves.utils
object MimeTypes {
const val IMAGE = "image"
const val DNG = "image/x-adobe-dng" // .dng
const val GIF = "image/gif"
const val HEIC = "image/heic"
const val HEIF = "image/heif"
const val JPEG = "image/jpeg"
const val PNG = "image/png"
const val PSD = "image/x-photoshop" // .psd
const val SVG = "image/svg+xml" // .svg
const val WEBP = "image/webp"
const val VIDEO = "video"
const val AVI = "video/avi"
const val MP2T = "video/mp2t" // .m2ts
const val MP4 = "video/mp4"
}

View file

@ -0,0 +1,25 @@
package deckers.thibault.aves.utils
import java.util.regex.Pattern
object Utils {
private const val LOG_TAG_MAX_LENGTH = 23
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
@JvmStatic
fun createLogTag(clazz: Class<*>): String {
// shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "a.b.CD"
val simpleName = clazz.simpleName
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "")
logTag = logTag.replace(simpleName, shortSimpleName)
if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "CD"
logTag = shortSimpleName
}
}
return logTag
}
}

View file

@ -1,13 +1,15 @@
buildscript {
ext.kotlin_version = '1.4.10'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.3'
classpath 'com.android.tools.build:gradle:3.6.3' // do not upgrade to 4+ until this is fixed: https://github.com/flutter/flutter/issues/58247
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.3'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.2.0'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'
}
}

32
ios/.gitignore vendored
View file

@ -1,32 +0,0 @@
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>8.0</string>
</dict>
</plist>

View file

@ -1,2 +0,0 @@
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View file

@ -1,2 +0,0 @@
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

View file

@ -1,87 +0,0 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '9.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def parse_KV_file(file, separator='=')
file_abs_path = File.expand_path(file)
if !File.exists? file_abs_path
return [];
end
generated_key_values = {}
skip_line_start_symbols = ["#", "/"]
File.foreach(file_abs_path) do |line|
next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ }
plugin = line.split(pattern=separator)
if plugin.length == 2
podname = plugin[0].strip()
path = plugin[1].strip()
podpath = File.expand_path("#{path}", file_abs_path)
generated_key_values[podname] = podpath
else
puts "Invalid plugin specification: #{line}"
end
end
generated_key_values
end
target 'Runner' do
use_frameworks!
use_modular_headers!
# Flutter Pod
copied_flutter_dir = File.join(__dir__, 'Flutter')
copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework')
copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec')
unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path)
# Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet.
# That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration.
# CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist.
generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig')
unless File.exist?(generated_xcode_build_settings_path)
raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path)
cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR'];
unless File.exist?(copied_framework_path)
FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir)
end
unless File.exist?(copied_podspec_path)
FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir)
end
end
# Keep pod path relative so it can be checked into Podfile.lock.
pod 'Flutter', :path => 'Flutter'
# Plugin Pods
# Prepare symlinks folder. We use symlinks to avoid having Podfile.lock
# referring to absolute paths on developers' machines.
system('rm -rf .symlinks')
system('mkdir -p .symlinks/plugins')
plugin_pods = parse_KV_file('../.flutter-plugins')
plugin_pods.each do |name, path|
symlink = File.join('.symlinks', 'plugins', name)
File.symlink(path, symlink)
pod name, :path => File.join(symlink, 'ios')
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_BITCODE'] = 'NO'
end
end
end

View file

@ -1,506 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
97C146F11CF9000F007C117D /* Supporting Files */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
97C146F11CF9000F007C117D /* Supporting Files */ = {
isa = PBXGroup;
children = (
);
name = "Supporting Files";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1020;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.aves;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.aves;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.aves;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -1,13 +0,0 @@
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View file

@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

View file

@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View file

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>aves</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"

View file

@ -1,4 +1,6 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
@ -67,6 +69,10 @@ mixin AlbumMixin on SourceBase {
if (emptyAlbums.isNotEmpty) {
_folderPaths.removeAll(emptyAlbums);
updateAlbums();
final pinnedFilters = settings.pinnedFilters;
emptyAlbums.forEach((album) => pinnedFilters.remove(AlbumFilter(album, getUniqueAlbumName(album))));
settings.pinnedFilters = pinnedFilters;
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
@ -69,7 +70,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
eventBus.fire(EntryAddedEvent());
}
void removeEntries(Iterable<ImageEntry> entries) {
void removeEntries(List<ImageEntry> entries) {
entries.forEach((entry) => entry.removeFromFavourites());
_rawEntries.removeWhere(entries.contains);
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
@ -88,12 +89,15 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
invalidateFilterEntryCounts();
}
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
Future<void> moveEntry(ImageEntry entry, Map newFields) async {
final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int;
entry.uri = newFields['uri'] as String;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs;
entry.path = newFields['path'] as String;
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.uri = newFields['uri'] as String;
entry.contentId = newContentId;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
@ -105,20 +109,53 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
}
void updateAfterMove({
@required Iterable<ImageEntry> entries,
@required Set<String> fromAlbums,
@required String toAlbum,
@required List<ImageEntry> selection,
@required bool copy,
}) {
@required String destinationAlbum,
@required Iterable<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final fromAlbums = <String>{};
final movedEntries = <ImageEntry>[];
if (copy) {
addAll(entries);
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith(
uri: newFields['uri'] as String,
path: newFields['path'] as String,
contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
));
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await moveEntry(entry, newFields);
}
});
}
if (copy) {
addAll(movedEntries);
} else {
cleanEmptyAlbums(fromAlbums);
addFolderPath({toAlbum});
addFolderPath({destinationAlbum});
}
updateAlbums();
invalidateFilterEntryCounts();
eventBus.fire(EntryMovedEvent(entries));
eventBus.fire(EntryMovedEvent(movedEntries));
}
int count(CollectionFilter filter) {

View file

@ -18,6 +18,27 @@ class AndroidFileService {
return [];
}
static Future<List<String>> getGrantedDirectories() async {
try {
final result = await platform.invokeMethod('getGrantedDirectories');
return (result as List).cast<String>();
} on PlatformException catch (e) {
debugPrint('getGrantedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return [];
}
static Future<void> revokeDirectoryAccess(String path) async {
try {
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
'path': path,
});
} on PlatformException catch (e) {
debugPrint('revokeDirectoryAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return;
}
// returns a list of directories,
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {

View file

@ -1,4 +1,8 @@
import 'dart:typed_data';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -22,10 +26,16 @@ class AppShortcutService {
return false;
}
static Future<void> pin(String label, Set<CollectionFilter> filters) async {
static Future<void> pin(String label, ImageEntry iconEntry, Set<CollectionFilter> filters) async {
Uint8List iconBytes;
if (iconEntry != null) {
final size = iconEntry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail(iconEntry, size, size);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{
'label': label,
'iconBytes': iconBytes,
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e) {

View file

@ -194,19 +194,6 @@ class ImageFileService {
}
return {};
}
static Future<List<Map>> renameDirectory(String path, String newName) async {
try {
final result = await platform.invokeMethod('renameDirectory', <String, dynamic>{
'path': path,
'newName': newName,
});
return (result as List).cast<Map>();
} on PlatformException catch (e) {
debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
}
@immutable

View file

@ -89,6 +89,18 @@ class MetadataService {
return {};
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getEmbeddedPictures failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<List<Uint8List>> getExifThumbnails(String uri) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{

View file

@ -200,33 +200,33 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
PopupMenuItem(
key: Key('menu-sort'),
value: CollectionAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
child: MenuRow(text: 'Sort', icon: AIcons.sort),
),
if (collection.sortFactor == EntrySortFactor.date)
PopupMenuItem(
key: Key('menu-group'),
value: CollectionAction.group,
child: MenuRow(text: 'Group...', icon: AIcons.group),
child: MenuRow(text: 'Group', icon: AIcons.group),
),
if (collection.isBrowsing) ...[
if (kDebugMode)
PopupMenuItem(
value: CollectionAction.refresh,
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
),
if (AvesApp.mode == AppMode.main)
if (kDebugMode)
PopupMenuItem(
value: CollectionAction.refresh,
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
),
PopupMenuItem(
value: CollectionAction.select,
child: MenuRow(text: 'Select', icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.select,
child: MenuRow(text: 'Select', icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.stats,
child: MenuRow(text: 'Stats', icon: AIcons.stats),
),
if (canAddShortcuts)
if (AvesApp.mode == AppMode.main && canAddShortcuts)
PopupMenuItem(
value: CollectionAction.addShortcut,
child: MenuRow(text: 'Add shortcut', icon: AIcons.addShortcut),
child: MenuRow(text: 'Add shortcut', icon: AIcons.addShortcut),
),
],
if (collection.isSelecting) ...[
@ -356,7 +356,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
if (name == null || name.isEmpty) return;
unawaited(AppShortcutService.pin(name, collection.filters));
final iconEntry = collection.sortedEntries.isNotEmpty ? collection.sortedEntries.first : null;
unawaited(AppShortcutService.pin(name, iconEntry, collection.filters));
}
void _goToSearch() {

View file

@ -87,8 +87,7 @@ class SectionHeader extends StatelessWidget {
// force a higher first line to match leading icon/selector dimension
style: TextStyle(height: 2.3 * textScaleFactor),
), // 23 hair spaces match a width of 40.0
if (hasTrailing)
TextSpan(text: '\u200A' * 17),
if (hasTrailing) TextSpan(text: '\u200A' * 17),
TextSpan(
text: text,
style: Constants.titleTextStyle,

View file

@ -219,7 +219,6 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
text: 'No favourites',
);
}
debugPrint('collection.filters=${collection.filters}');
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
return EmptyContent(
icon: AIcons.video,

View file

@ -43,7 +43,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
labelText: 'Shortcut label',
),
autofocus: true,
maxLength: 10,
maxLength: 25,
onChanged: (_) => _validate(),
onSubmitted: (_) => _submit(context),
),

View file

@ -1,7 +1,11 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/durations.dart';
import 'package:flushbar/flushbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
mixin FeedbackMixin {
Flushbar _flushbar;
@ -20,4 +24,65 @@ mixin FeedbackMixin {
animationDuration: Durations.opToastAnimation,
)..show(context);
}
// report overlay for multiple operations
OverlayEntry _opReportOverlayEntry;
void showOpReport<T extends ImageOpEvent>({
@required BuildContext context,
@required List<ImageEntry> selection,
@required Stream<T> opStream,
@required void Function(Set<T> processed) onDone,
}) {
final processed = <T>{};
// do not handle completion inside `StreamBuilder`
// as it could be called multiple times
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
opStream.listen(
processed.add,
onError: (error) {
debugPrint('_showOpReport error=$error');
onComplete();
},
onDone: onComplete,
);
_opReportOverlayEntry = OverlayEntry(
builder: (context) {
return AbsorbPointer(
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
Widget child = SizedBox.shrink();
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator(
percent: percent,
lineWidth: 16,
radius: 160,
backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor,
animation: true,
center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
);
}
return AnimatedSwitcher(
duration: Durations.collectionOpOverlayAnimation,
child: child,
);
}),
);
},
);
Overlay.of(context).insert(_opReportOverlayEntry);
}
Future<void> _hideOpReportOverlay() async {
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
_opReportOverlayEntry.remove();
_opReportOverlayEntry = null;
}
}

View file

@ -74,7 +74,6 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
final newName = _nameController.text ?? '';
final path = _buildEntryPath(newName);
final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound;
debugPrint('TLAD path=$path exists=$exists');
_isValidNotifier.value = newName.isNotEmpty && !exists;
}

View file

@ -1,13 +1,10 @@
import 'dart:async';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/collection/collection_actions.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart';
@ -20,10 +17,8 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final CollectionLens collection;
@ -61,7 +56,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
}
}
Future _moveSelection(BuildContext context, {@required bool copy}) async {
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
final source = collection.source;
final destinationAlbum = await Navigator.push(
context,
@ -106,12 +101,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final selection = collection.selection.toList();
if (!await checkStoragePermission(context, selection)) return;
_showOpReport<MoveOpEvent>(
showOpReport<MoveOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
onDone: (processed) async {
debugPrint('$runtimeType _moveSelection onDone');
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final selectionCount = selection.length;
@ -122,51 +116,19 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final count = movedCount;
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (movedCount > 0) {
final fromAlbums = <String>{};
final movedEntries = <ImageEntry>[];
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith(
uri: newFields['uri'] as String,
path: newFields['path'] as String,
contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
));
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await source.moveEntry(entry, newFields);
}
});
}
source.updateAfterMove(
entries: movedEntries,
fromAlbums: fromAlbums,
toAlbum: destinationAlbum,
copy: copy,
);
}
await source.updateAfterMove(
selection: selection,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
collection.clearSelection();
collection.browse();
},
);
}
void _refreshSelectionMetadata() async {
Future<void> _refreshSelectionMetadata() async {
collection.selection.forEach((entry) => entry.clearMetadata());
final source = collection.source;
source.stateNotifier.value = SourceState.cataloguing;
@ -176,7 +138,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
source.stateNotifier.value = SourceState.ready;
}
void _showDeleteDialog(BuildContext context) async {
Future<void> _showDeleteDialog(BuildContext context) async {
final selection = collection.selection.toList();
final count = selection.length;
@ -202,12 +164,12 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
if (!await checkStoragePermission(context, selection)) return;
_showOpReport<ImageOpEvent>(
showOpReport<ImageOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.delete(selection),
onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri);
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
final deletedCount = deletedUris.length;
final selectionCount = selection.length;
if (deletedCount < selectionCount) {
@ -215,72 +177,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (deletedCount > 0) {
collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)));
collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toList());
}
collection.clearSelection();
collection.browse();
},
);
}
// selection action report overlay
OverlayEntry _opReportOverlayEntry;
void _showOpReport<T extends ImageOpEvent>({
@required BuildContext context,
@required List<ImageEntry> selection,
@required Stream<T> opStream,
@required void Function(Set<T> processed) onDone,
}) {
final processed = <T>{};
// do not handle completion inside `StreamBuilder`
// as it could be called multiple times
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
opStream.listen(
processed.add,
onError: (error) {
debugPrint('_showOpReport error=$error');
onComplete();
},
onDone: onComplete,
);
_opReportOverlayEntry = OverlayEntry(
builder: (context) {
return AbsorbPointer(
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
Widget child = SizedBox.shrink();
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator(
percent: percent,
lineWidth: 16,
radius: 160,
backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor,
animation: true,
center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
);
}
return AnimatedSwitcher(
duration: Durations.collectionOpOverlayAnimation,
child: child,
);
}),
);
},
);
Overlay.of(context).insert(_opReportOverlayEntry);
}
Future<void> _hideOpReportOverlay() async {
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
_opReportOverlayEntry.remove();
_opReportOverlayEntry = null;
}
}

View file

@ -146,7 +146,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
child: Stack(
fit: StackFit.passthrough,
children: [
if (widget.background != null)
if (hasBackground)
ClipRRect(
borderRadius: borderRadius,
child: widget.background,

View file

@ -66,6 +66,7 @@ class ThumbnailProviderKey {
final ImageEntry entry;
final double extent;
final double scale;
// do not access `contentId` via `entry` for hashCode and equality purposes
// as an entry is not constant and its contentId can change
final int contentId;

View file

@ -299,6 +299,7 @@ class DebugPageState extends State<DebugPage> {
'collectionSortFactor': '${settings.collectionSortFactor}',
'collectionTileExtent': '${settings.collectionTileExtent}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': '${settings.pinnedFilters}',
}),
],
);

View file

@ -36,11 +36,12 @@ class AlbumListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage(
source: source,
title: 'Albums',
chipSetActionDelegate: AlbumChipSetActionDelegate(),
chipSetActionDelegate: AlbumChipSetActionDelegate(source: source),
chipActionDelegate: AlbumChipActionDelegate(source: source),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.rename,
ChipAction.delete,
],
filterEntries: getAlbumEntries(source),
filterBuilder: (album) => AlbumFilter(album, source.getUniqueAlbumName(album)),

View file

@ -1,6 +1,5 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
@ -8,8 +7,8 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/action_delegates/rename_album_dialog.dart';
import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
@ -45,6 +44,9 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
Future<void> onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async {
await super.onActionSelected(context, filter, action);
switch (action) {
case ChipAction.delete:
unawaited(_showDeleteDialog(context, filter as AlbumFilter));
break;
case ChipAction.rename:
unawaited(_showRenameDialog(context, filter as AlbumFilter));
break;
@ -53,6 +55,51 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
}
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
final selection = source.rawEntries.where(filter.filter).toList();
final count = selection.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
if (!await checkStoragePermission(context, selection)) return;
showOpReport<ImageOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.delete(selection),
onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
final deletedCount = deletedUris.length;
final selectionCount = selection.length;
if (deletedCount < selectionCount) {
final count = selectionCount - deletedCount;
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (deletedCount > 0) {
source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toList());
}
},
);
}
Future<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
final album = filter.album;
final newName = await showDialog<String>(
@ -63,36 +110,36 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkStoragePermissionForAlbums(context, {album})) return;
final result = await ImageFileService.renameDirectory(album, newName);
final bySuccess = groupBy<Map, bool>(result, (fields) => fields['success']);
final selection = source.rawEntries.where(filter.filter).toList();
final destinationAlbum = path.join(path.dirname(album), newName);
final albumEntries = source.rawEntries.where(filter.filter);
final movedEntries = <ImageEntry>[];
await Future.forEach<Map>(bySuccess[true], (newFields) async {
final oldContentId = newFields['oldContentId'];
final entry = albumEntries.firstWhere((entry) => entry.contentId == oldContentId, orElse: () => null);
if (entry != null) {
movedEntries.add(entry);
await source.moveEntry(entry, newFields);
}
});
final newAlbum = path.join(path.dirname(album), newName);
source.updateAfterMove(
entries: movedEntries,
fromAlbums: {album},
toAlbum: newAlbum,
copy: false,
showOpReport<MoveOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.move(selection, copy: false, destinationAlbum: destinationAlbum),
onDone: (processed) async {
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.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')}');
} else {
showFeedback(context, 'Done!');
}
final pinned = settings.pinnedFilters.contains(filter);
await source.updateAfterMove(
selection: selection,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// repin new album after obsolete album got removed and unpinned
if (pinned) {
final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(destinationAlbum));
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
},
);
final newFilter = AlbumFilter(newAlbum, source.getUniqueAlbumName(newAlbum));
settings.pinnedFilters = settings.pinnedFilters
..remove(filter)
..add(newFilter);
final failed = bySuccess[false]?.length ?? 0;
if (failed > 0) {
showFeedback(context, 'Failed to move ${Intl.plural(failed, one: '$failed item', other: '$failed items')}');
} else {
showFeedback(context, 'Done!');
}
}
}

View file

@ -3,9 +3,12 @@ import 'package:flutter/widgets.dart';
enum ChipSetAction {
sort,
refresh,
stats,
}
enum ChipAction {
delete,
pin,
unpin,
rename,
@ -14,6 +17,8 @@ enum ChipAction {
extension ExtraChipAction on ChipAction {
String getText() {
switch (this) {
case ChipAction.delete:
return 'Delete';
case ChipAction.pin:
return 'Pin to top';
case ChipAction.unpin:
@ -26,6 +31,8 @@ extension ExtraChipAction on ChipAction {
IconData getIcon() {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.pin:
case ChipAction.unpin:
return AIcons.pin;

View file

@ -1,12 +1,19 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pedantic/pedantic.dart';
abstract class ChipSetActionDelegate {
CollectionSource get source;
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
@ -19,6 +26,15 @@ abstract class ChipSetActionDelegate {
case ChipSetAction.sort:
await _showSortDialog(context);
break;
case ChipSetAction.refresh:
if (source is MediaStoreSource) {
source.clearEntries();
unawaited((source as MediaStoreSource).refresh());
}
break;
case ChipSetAction.stats:
_goToStats(context);
break;
}
}
@ -38,9 +54,32 @@ abstract class ChipSetActionDelegate {
sortFactor = factor;
}
}
void _goToStats(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
collection: CollectionLens(
source: source,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
),
),
),
);
}
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
AlbumChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@ -49,6 +88,13 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
}
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
CountryChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@ -57,6 +103,13 @@ class CountryChipSetActionDelegate extends ChipSetActionDelegate {
}
class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
TagChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;

View file

@ -34,18 +34,17 @@ class DecoratedFilterChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget backgroundImage;
if (entry != null) {
backgroundImage = entry.isSvg
? ThumbnailVectorImage(
entry: entry,
extent: FilterGridPage.maxCrossAxisExtent,
)
: ThumbnailRasterImage(
entry: entry,
extent: FilterGridPage.maxCrossAxisExtent,
);
}
final backgroundImage = entry == null
? Container(color: Colors.white)
: entry.isSvg
? ThumbnailVectorImage(
entry: entry,
extent: FilterGridPage.maxCrossAxisExtent,
)
: ThumbnailRasterImage(
entry: entry,
extent: FilterGridPage.maxCrossAxisExtent,
);
return AvesFilterChip(
filter: filter,
showGenericIcon: false,

View file

@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:aves/main.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
@ -24,6 +25,7 @@ import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:collection/collection.dart';
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:pedantic/pedantic.dart';
@ -87,7 +89,7 @@ class FilterNavigationPage extends StatelessWidget {
),
settings.navRemoveRoutePredicate(CollectionPage.routeName),
),
onLongPress: (filter, tapPosition) => _showMenu(context, filter, tapPosition),
onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null,
);
}
@ -119,7 +121,16 @@ class FilterNavigationPage extends StatelessWidget {
PopupMenuItem(
key: Key('menu-sort'),
value: ChipSetAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
),
if (kDebugMode)
PopupMenuItem(
value: ChipSetAction.refresh,
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
),
PopupMenuItem(
value: ChipSetAction.stats,
child: MenuRow(text: 'Stats', icon: AIcons.stats),
),
];
},

View file

@ -33,7 +33,7 @@ class CountryListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage(
source: source,
title: 'Countries',
chipSetActionDelegate: CountryChipSetActionDelegate(),
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,

View file

@ -33,7 +33,7 @@ class TagListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage(
source: source,
title: 'Tags',
chipSetActionDelegate: TagChipSetActionDelegate(),
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,

View file

@ -5,8 +5,12 @@ 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/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tuple/tuple.dart';
class FullscreenDebugPage extends StatefulWidget {
@ -41,7 +45,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
final tabs = <Tuple2<Tab, Widget>>[
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
Tuple2(Tab(text: 'DB'), _buildDbTabView()),
Tuple2(Tab(text: 'Content Resolver'), _buildContentResolverTabView()),
Tuple2(Tab(icon: Icon(AIcons.android)), _buildContentResolverTabView()),
Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
];
return DefaultTabController(
length: tabs.length,
@ -132,6 +137,33 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
);
}
Widget _buildThumbnailsTabView() {
const extent = 128.0;
return ListView(
padding: EdgeInsets.all(16),
children: [
if (entry.isSvg) ...[
Text('SVG ($extent)'),
SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
width: extent,
height: extent,
)
],
if (!entry.isSvg) ...[
Text('Raster (fast)'),
Center(child: Image(image: ThumbnailProvider(entry: entry))),
SizedBox(height: 16),
Text('Raster ($extent)'),
Center(child: Image(image: ThumbnailProvider(entry: entry, extent: extent))),
],
],
);
}
Widget _buildDbTabView() {
final catalog = entry.catalogMetadata;
return ListView(
@ -230,6 +262,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
return ListView(
padding: EdgeInsets.all(16),
children: [
Text('Content Resolver (Media Store):'),
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) {

View file

@ -9,6 +9,7 @@ import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/maps/common.dart';
import 'package:aves/widgets/fullscreen/info/maps/google_map.dart';
import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart';
import 'package:aves/widgets/fullscreen/info/maps/marker.dart';
import 'package:flutter/material.dart';
class LocationSection extends StatefulWidget {
@ -34,6 +35,9 @@ class LocationSection extends StatefulWidget {
class _LocationSectionState extends State<LocationSection> {
String _loadedUri;
static const extent = 48.0;
static const pointerSize = Size(8.0, 6.0);
CollectionLens get collection => widget.collection;
ImageEntry get entry => widget.entry;
@ -85,6 +89,14 @@ class _LocationSectionState extends State<LocationSection> {
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
}
Widget buildMarker(BuildContext context) {
return ImageMarker(
entry: entry,
extent: extent,
pointerSize: pointerSize,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -96,16 +108,19 @@ class _LocationSectionState extends State<LocationSection> {
},
child: settings.infoMapStyle.isGoogleMaps
? EntryGoogleMap(
markerId: entry.uri ?? entry.path,
latLng: entry.latLng,
geoUri: entry.geoUri,
initialZoom: settings.infoMapZoom,
markerId: entry.uri ?? entry.path,
markerBuilder: buildMarker,
)
: EntryLeafletMap(
latLng: entry.latLng,
geoUri: entry.geoUri,
initialZoom: settings.infoMapZoom,
style: settings.infoMapStyle,
markerSize: Size(extent, extent + pointerSize.height),
markerBuilder: buildMarker,
),
),
if (entry.hasGps)

View file

@ -62,7 +62,7 @@ class MapButtonPanel extends StatelessWidget {
MapOverlayButton(
icon: AIcons.openInNew,
onPressed: () => AndroidAppService.openMap(geoUri),
tooltip: 'Show on map...',
tooltip: 'Show on map',
),
SizedBox(height: padding),
MapOverlayButton(
@ -81,7 +81,7 @@ class MapButtonPanel extends StatelessWidget {
MapStyleChangedNotification().dispatch(context);
}
},
tooltip: 'Style map...',
tooltip: 'Style map',
),
Spacer(),
MapOverlayButton(

View file

@ -1,22 +1,28 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:aves/widgets/fullscreen/info/maps/common.dart';
import 'package:aves/widgets/fullscreen/info/maps/marker.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:tuple/tuple.dart';
class EntryGoogleMap extends StatefulWidget {
final String markerId;
final LatLng latLng;
final String geoUri;
final double initialZoom;
final String markerId;
final WidgetBuilder markerBuilder;
EntryGoogleMap({
Key key,
this.markerId,
Tuple2<double, double> latLng,
this.geoUri,
this.initialZoom,
this.markerId,
this.markerBuilder,
}) : latLng = LatLng(latLng.item1, latLng.item2),
super(key: key);
@ -26,6 +32,13 @@ class EntryGoogleMap extends StatefulWidget {
class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveClientMixin {
GoogleMapController _controller;
Completer<Uint8List> _markerLoaderCompleter;
@override
void initState() {
super.initState();
_markerLoaderCompleter = Completer<Uint8List>();
}
@override
void didUpdateWidget(EntryGoogleMap oldWidget) {
@ -33,6 +46,9 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
if (widget.latLng != oldWidget.latLng && _controller != null) {
_controller.moveCamera(CameraUpdate.newLatLng(widget.latLng));
}
if (widget.markerId != oldWidget.markerId) {
_markerLoaderCompleter = Completer<Uint8List>();
}
}
@override
@ -46,6 +62,11 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
super.build(context);
return Stack(
children: [
MarkerGeneratorWidget(
key: Key(widget.markerId),
markers: [widget.markerBuilder(context)],
onComplete: (bitmaps) => _markerLoaderCompleter.complete(bitmaps.first),
),
MapDecorator(
child: _buildMap(),
),
@ -58,34 +79,40 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
}
Widget _buildMap() {
final accentHue = HSVColor.fromColor(Theme.of(context).accentColor).hue;
return GoogleMap(
// GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493
initialCameraPosition: CameraPosition(
target: widget.latLng,
zoom: widget.initialZoom,
),
onMapCreated: (controller) => setState(() => _controller = controller),
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapStyle(settings.infoMapStyle),
rotateGesturesEnabled: false,
scrollGesturesEnabled: false,
zoomControlsEnabled: false,
zoomGesturesEnabled: false,
liteModeEnabled: false,
// no camera animation in lite mode
tiltGesturesEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: {
Marker(
markerId: MarkerId(widget.markerId),
icon: BitmapDescriptor.defaultMarkerWithHue(accentHue),
position: widget.latLng,
)
},
);
return FutureBuilder<Uint8List>(
future: _markerLoaderCompleter.future,
builder: (context, snapshot) {
final markers = <Marker>{};
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) {
final markerBytes = snapshot.data;
markers.add(Marker(
markerId: MarkerId(widget.markerId),
icon: BitmapDescriptor.fromBytes(markerBytes),
position: widget.latLng,
));
}
return GoogleMap(
// GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493
initialCameraPosition: CameraPosition(
target: widget.latLng,
zoom: widget.initialZoom,
),
onMapCreated: (controller) => setState(() => _controller = controller),
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapStyle(settings.infoMapStyle),
rotateGesturesEnabled: false,
scrollGesturesEnabled: false,
zoomControlsEnabled: false,
zoomGesturesEnabled: false,
liteModeEnabled: false,
// no camera animation in lite mode
tiltGesturesEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: markers,
);
});
}
void _zoomBy(double amount) {

View file

@ -15,6 +15,8 @@ class EntryLeafletMap extends StatefulWidget {
final String geoUri;
final double initialZoom;
final EntryMapStyle style;
final Size markerSize;
final WidgetBuilder markerBuilder;
EntryLeafletMap({
Key key,
@ -22,6 +24,8 @@ class EntryLeafletMap extends StatefulWidget {
this.geoUri,
this.initialZoom,
this.style,
this.markerBuilder,
this.markerSize,
}) : latLng = LatLng(latLng.item1, latLng.item2),
super(key: key);
@ -32,8 +36,6 @@ class EntryLeafletMap extends StatefulWidget {
class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
final MapController _mapController = MapController();
static const markerSize = 40.0;
@override
void didUpdateWidget(EntryLeafletMap oldWidget) {
super.didUpdateWidget(oldWidget);
@ -80,16 +82,10 @@ class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliv
options: MarkerLayerOptions(
markers: [
Marker(
width: markerSize,
height: markerSize,
width: widget.markerSize.width,
height: widget.markerSize.height,
point: widget.latLng,
builder: (ctx) {
return Icon(
Icons.place,
size: markerSize,
color: Theme.of(context).accentColor,
);
},
builder: widget.markerBuilder,
anchorPos: AnchorPos.align(AnchorAlign.top),
),
],

View file

@ -0,0 +1,182 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/collection/thumbnail/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ImageMarker extends StatelessWidget {
final ImageEntry entry;
final double extent;
final Size pointerSize;
static const double outerBorderRadiusDim = 8;
static const double outerBorderWidth = 1.5;
static const double innerBorderWidth = 2;
static const outerBorderColor = Colors.white30;
static final innerBorderColor = Colors.grey[900];
const ImageMarker({
@required this.entry,
@required this.extent,
this.pointerSize = Size.zero,
});
@override
Widget build(BuildContext context) {
final thumbnail = entry.isSvg
? ThumbnailVectorImage(
entry: entry,
extent: extent,
)
: ThumbnailRasterImage(
entry: entry,
extent: extent,
);
final outerBorderRadius = BorderRadius.circular(outerBorderRadiusDim);
final innerBorderRadius = BorderRadius.circular(outerBorderRadiusDim - outerBorderWidth);
return CustomPaint(
foregroundPainter: MarkerPointerPainter(
color: innerBorderColor,
outlineColor: outerBorderColor,
outlineWidth: outerBorderWidth,
size: pointerSize,
),
child: Padding(
padding: EdgeInsets.only(bottom: pointerSize.height),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: outerBorderColor,
width: outerBorderWidth,
),
borderRadius: outerBorderRadius,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: innerBorderColor,
width: innerBorderWidth,
),
borderRadius: innerBorderRadius,
),
position: DecorationPosition.foreground,
child: ClipRRect(
borderRadius: innerBorderRadius,
child: thumbnail,
),
),
),
),
);
}
}
class MarkerPointerPainter extends CustomPainter {
final Color color, outlineColor;
final double outlineWidth;
final Size size;
const MarkerPointerPainter({
this.color,
this.outlineColor,
this.outlineWidth,
this.size,
});
@override
void paint(Canvas canvas, Size size) {
final pointerWidth = this.size.width;
final pointerHeight = this.size.height;
final bottomCenter = Offset(size.width / 2, size.height);
final topLeft = bottomCenter + Offset(-pointerWidth / 2, -pointerHeight);
final topRight = bottomCenter + Offset(pointerWidth / 2, -pointerHeight);
canvas.drawPath(
Path()
..moveTo(bottomCenter.dx, bottomCenter.dy)
..lineTo(topRight.dx, topRight.dy)
..lineTo(topLeft.dx, topLeft.dy)
..close(),
Paint()..color = outlineColor);
canvas.translate(0, -outlineWidth.ceilToDouble());
canvas.drawPath(
Path()
..moveTo(bottomCenter.dx, bottomCenter.dy)
..lineTo(topRight.dx, topRight.dy)
..lineTo(topLeft.dx, topLeft.dy)
..close(),
Paint()..color = color);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// generate bitmap from widget, for Google Maps
class MarkerGeneratorWidget extends StatefulWidget {
final List<Widget> markers;
final Duration delay;
final Function(List<Uint8List> bitmaps) onComplete;
const MarkerGeneratorWidget({
Key key,
@required this.markers,
this.delay = Duration.zero,
@required this.onComplete,
}) : super(key: key);
@override
_MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState();
}
class _MarkerGeneratorWidgetState extends State<MarkerGeneratorWidget> {
final _globalKeys = <GlobalKey>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.delay > Duration.zero) {
await Future.delayed(widget.delay);
}
widget.onComplete(await _getBitmaps(context));
});
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(MediaQuery.of(context).size.width, 0),
child: Material(
type: MaterialType.transparency,
child: Stack(
children: widget.markers.map((i) {
final key = GlobalKey();
_globalKeys.add(key);
return RepaintBoundary(
key: key,
child: i,
);
}).toList(),
),
),
);
}
Future<List<Uint8List>> _getBitmaps(BuildContext context) async {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
return Future.wait(_globalKeys.map((key) async {
RenderRepaintBoundary boundary = key.currentContext.findRenderObject();
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData.buffer.asUint8List();
}));
}
}

View file

@ -75,7 +75,12 @@ class ScaleLayer extends StatelessWidget {
builder: (context, snapshot) {
final center = map.center;
final latitude = center.latitude.abs();
final level = map.zoom.round() + (latitude > 80 ? 4 : latitude > 60 ? 3 : 2);
final level = map.zoom.round() +
(latitude > 80
? 4
: latitude > 60
? 3
: 2);
final distance = scale[max(0, min(20, level))].toDouble();
final start = map.project(center);
final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance);

View file

@ -36,8 +36,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
static const int maxValueLength = 140;
// directory names from metadata-extractor
static const exifThumbnailDirectory = 'Exif Thumbnail';
static const xmpDirectory = 'XMP';
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const videoDirectory = 'Video'; // additional generic video directory
@override
void initState() {
@ -106,6 +107,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
SizedBox(height: 4),
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
Container(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),

View file

@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter/material.dart';
enum MetadataThumbnailSource { exif, xmp }
enum MetadataThumbnailSource { embedded, exif, xmp }
class MetadataThumbnails extends StatefulWidget {
final MetadataThumbnailSource source;
@ -24,15 +24,22 @@ class MetadataThumbnails extends StatefulWidget {
class _MetadataThumbnailsState extends State<MetadataThumbnails> {
Future<List<Uint8List>> _loader;
ImageEntry get entry => widget.entry;
String get uri => entry.uri;
@override
void initState() {
super.initState();
switch (widget.source) {
case MetadataThumbnailSource.embedded:
_loader = MetadataService.getEmbeddedPictures(uri);
break;
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(widget.entry.uri);
_loader = MetadataService.getExifThumbnails(uri);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(widget.entry.uri);
_loader = MetadataService.getXmpThumbnails(uri);
break;
}
}
@ -43,7 +50,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
future: _loader,
builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
final turns = (widget.entry.orientationDegrees / 90).round();
final turns = (entry.orientationDegrees / 90).round();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
alignment: AlignmentDirectional.topStart,

View file

@ -0,0 +1,65 @@
import 'package:aves/services/android_file_service.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class GrantedDirectories extends StatefulWidget {
@override
_GrantedDirectoriesState createState() => _GrantedDirectoriesState();
}
class _GrantedDirectoriesState extends State<GrantedDirectories> {
Future<List<String>> _pathLoader;
List<String> _lastPaths;
@override
void initState() {
super.initState();
_load();
}
void _load() => _pathLoader = AndroidFileService.getGrantedDirectories();
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: FutureBuilder<List<String>>(
future: _pathLoader,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
if (snapshot.connectionState != ConnectionState.done && _lastPaths == null) {
return SizedBox.shrink();
}
_lastPaths = snapshot.data..sort();
final count = _lastPaths.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aves has access to ${Intl.plural(count, zero: 'no directories.', one: 'one directory:', other: '$count directories:')}',
style: textTheme.subtitle1,
),
..._lastPaths.map((path) => Row(
children: [
Expanded(child: Text(path, style: textTheme.caption)),
SizedBox(width: 8),
OutlineButton(
onPressed: () async {
await AndroidFileService.revokeDirectoryAccess(path);
_load();
setState(() {});
},
child: Text('Revoke'),
),
],
)),
],
);
},
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/svg_background.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -116,6 +117,7 @@ class SettingsPage extends StatelessWidget {
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text('Allow anonymous crash reporting'),
),
GrantedDirectories(),
],
),
),

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.13+25
version: 1.1.14+26
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)