diff --git a/CHANGELOG.md b/CHANGELOG.md index a805011e6..01178df31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.2.8] - 2020-11-27 +### Added +- Albums / Countries / Tags: pinch to change tile size +- Album picker: added a field to filter by name +- check free space before moving entries +- SVG source viewer + +### Changed +- Navigation: changed page history handling +- Info: improved layout, especially for XMP +- About: improved layout +- faster locating of new entries + ## [v1.2.7] - 2020-11-15 ### Added - Support for TIFF images (single page) diff --git a/android/app/build.gradle b/android/app/build.gradle index 6e9bfd207..8880a3e39 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,7 +98,7 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' - implementation 'androidx.core:core-ktx:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts + implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts implementation 'androidx.exifinterface:exifinterface:1.3.1' implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.15.0' @@ -109,6 +109,9 @@ dependencies { implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636' implementation 'com.github.bumptech.glide:glide:4.11.0' + // TODO TLAD remove when this is fixed: https://github.com/firebase/firebase-android-sdk/issues/1662 https://github.com/FirebaseExtended/flutterfire/issues/3990 + implementation 'com.google.firebase:firebase-analytics:18.0.0' + kapt 'androidx.annotation:annotation:1.1.0' kapt 'com.github.bumptech.glide:compiler:4.11.0' diff --git a/android/app/google-services.json b/android/app/google-services.json index 3e529cffb..21cde835b 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -14,6 +14,14 @@ } }, "oauth_client": [ + { + "client_id": "100907092477-1mredcehjo66opfirr6k3kokjqmc99ee.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "deckers.thibault.aves", + "certificate_hash": "59a50013fa7a2f97911b52d681cafaebf83505e8" + } + }, { "client_id": "100907092477-ml1c4hr4l24ekg7l7nqid06n03kek6c8.apps.googleusercontent.com", "client_type": 1, @@ -51,6 +59,14 @@ } }, "oauth_client": [ + { + "client_id": "100907092477-8vgakbtass73c6dad5mqflq2dd4h4904.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "deckers.thibault.aves.debug", + "certificate_hash": "744592fedb021fd82372966a6cb30569579fa9cc" + } + }, { "client_id": "100907092477-u7sm8gp5t2sotn42oq20ufhtn3craodu.apps.googleusercontent.com", "client_type": 3 @@ -80,6 +96,14 @@ } }, "oauth_client": [ + { + "client_id": "100907092477-4a6968gloaaq70uti1offkk7raduond6.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "deckers.thibault.aves.profile", + "certificate_hash": "744592fedb021fd82372966a6cb30569579fa9cc" + } + }, { "client_id": "100907092477-u7sm8gp5t2sotn42oq20ufhtn3craodu.apps.googleusercontent.com", "client_type": 3 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 181d0955d..7f942d055 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -162,6 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { uri ?: return false val intent = Intent(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(uri, mimeType) return safeStartActivityChooser(title, intent) } @@ -177,12 +178,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { uri ?: return false val intent = Intent(Intent.ACTION_ATTACH_DATA) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(uri, mimeType) return safeStartActivityChooser(title, intent) } private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean { val intent = Intent(Intent.ACTION_SEND) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType(mimeType) when (uri.scheme?.toLowerCase(Locale.ROOT)) { ContentResolver.SCHEME_FILE -> { @@ -190,7 +193,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val applicationId = context.applicationContext.packageName val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) intent.putExtra(Intent.EXTRA_STREAM, apkUri) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } else -> intent.putExtra(Intent.EXTRA_STREAM, uri) } @@ -222,25 +224,32 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } val intent = Intent(Intent.ACTION_SEND_MULTIPLE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) .setType(mimeType) return safeStartActivityChooser(title, intent) } private fun safeStartActivity(intent: Intent): Boolean { - val canResolve = intent.resolveActivity(context.packageManager) != null - if (canResolve) { + if (intent.resolveActivity(context.packageManager) == null) return false + try { context.startActivity(intent) + return true + } catch (e: SecurityException) { + Log.w(LOG_TAG, "failed to start activity for intent=$intent", e) } - return canResolve + return false } private fun safeStartActivityChooser(title: String?, intent: Intent): Boolean { - val canResolve = intent.resolveActivity(context.packageManager) != null - if (canResolve) { + if (intent.resolveActivity(context.packageManager) == null) return false + try { context.startActivity(Intent.createChooser(intent, title)) + return true + } catch (e: SecurityException) { + Log.w(LOG_TAG, "failed to start activity chooser for intent=$intent", e) } - return canResolve + return false } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 446649bdb..b5f9197cd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -103,7 +103,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) { // directory name - val dirName = dir.name ?: "" + var dirName = dir.name + // optional parent to distinguish child directories of the same type + dir.parent?.name?.let { dirName = "$it/$dirName" } + val dirMap = metadataMap.getOrDefault(dirName, HashMap()) metadataMap[dirName] = dirMap diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index 5115f7c70..ab2147817 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -28,6 +28,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { } result.success(volumes) } + "getFreeSpace" -> getFreeSpace(call, result) "getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context))) "getInaccessibleDirectories" -> getInaccessibleDirectories(call, result) "revokeDirectoryAccess" -> revokeDirectoryAccess(call, result) @@ -62,6 +63,35 @@ class StorageHandler(private val context: Context) : MethodCallHandler { return volumes } + private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) { + val path = call.argument("path") + if (path == null) { + result.error("getFreeSpace-args", "failed because of missing arguments", null) + return + } + + val sm = context.getSystemService(StorageManager::class.java) + if (sm == null) { + result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null) + return + } + + val file = File(path) + val volume = sm.getStorageVolume(file) + if (volume == null) { + result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null) + return + } + + // `StorageStatsManager` `getFreeBytes()` is only available from API 26, + // and non-primary volume UUIDs cannot be used with it + try { + result.success(file.freeSpace) + } catch (e: SecurityException) { + result.error("getFreeSpace-security", "failed because of missing access", e.message) + } + } + private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) { val dirPaths = call.argument>("dirPaths") if (dirPaths == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index e7815d942..9eb1de5c6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -217,8 +217,11 @@ object ExifInterfaceHelper { // so that we can rely on metadata-extractor descriptions val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap() + // exclude Exif directory when it only includes image size + val isUselessExif: (Map) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") } + return HashMap>().apply { - put("Exif", describeDir(exif, dirs, baseTags)) + put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf()) put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags)) put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags)) put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags)) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 44b153617..aaf961622 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -122,17 +122,15 @@ class SourceImageEntry { if (isVideo) { fillVideoByMediaMetadataRetriever(context) if (isSized && hasDuration) return this - } - // skip metadata-extractor for raw images because it reports the decoded dimensions instead of the raw dimensions - if (!MimeTypes.isRaw(sourceMimeType) && MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { + fillByMetadataExtractor(context) + } else { fillByMetadataExtractor(context) if (isSized && foundExif) return this - } - if (ExifInterface.isSupportedMimeType(sourceMimeType)) { fillByExifInterface(context) - if (isSized) return this } - fillByBitmapDecode(context) + if (!isSized) { + fillByBitmapDecode(context) + } return this } @@ -156,6 +154,9 @@ class SourceImageEntry { // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { + // skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions + if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType) || MimeTypes.isRaw(sourceMimeType)) return + try { StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -206,6 +207,8 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { + if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return; + try { StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index cbf6d5e2b..c1eeca279 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -182,7 +182,7 @@ abstract class ImageProvider { } if (newFields.isEmpty()) { - cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri")) + cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) } else { cont.resume(newFields) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index ce3b59aca..5c285de53 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -47,10 +47,10 @@ class MediaStoreImageProvider : ImageProvider() { val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id) if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return } - // the uri can be a file media uri (e.g. "content://0@media/external/file/30050") + // the uri can be a file media URI (e.g. "content://0@media/external/file/30050") // without an equivalent image/video if it is shared from a file browser // but the file is not publicly visible - if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION) > 0) return + if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = mimeType) > 0) return callback.onFailure(Exception("failed to fetch entry at uri=$uri")) } @@ -87,6 +87,7 @@ class MediaStoreImageProvider : ImageProvider() { handleNewEntry: NewEntryHandler, contentUri: Uri, projection: Array, + fileMimeType: String? = null, ): Int { var newEntryCount = 0 val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC" @@ -123,45 +124,51 @@ class MediaStoreImageProvider : ImageProvider() { // for multiple items, `contentUri` is the root without ID, // but for single items, `contentUri` already contains the ID val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, contentId.toLong()) - val mimeType = cursor.getString(mimeTypeColumn) + // `mimeType` can be registered as null for file media URIs with unsupported media types (e.g. TIFF on old devices) + // in that case we try to use the mime type provided along the URI + val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType val width = cursor.getInt(widthColumn) val height = cursor.getInt(heightColumn) val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L - var entryMap: FieldMap = hashMapOf( - "uri" to itemUri.toString(), - "path" to cursor.getString(pathColumn), - "sourceMimeType" to mimeType, - "width" to width, - "height" to height, - "sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, - "sizeBytes" to cursor.getLong(sizeColumn), - "title" to cursor.getString(titleColumn), - "dateModifiedSecs" to dateModifiedSecs, - "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, - "durationMillis" to durationMillis, - // only for map export - "contentId" to contentId, - ) + if (mimeType == null) { + Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type") + } else { + var entryMap: FieldMap = hashMapOf( + "uri" to itemUri.toString(), + "path" to cursor.getString(pathColumn), + "sourceMimeType" to mimeType, + "width" to width, + "height" to height, + "sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, + "sizeBytes" to cursor.getLong(sizeColumn), + "title" to cursor.getString(titleColumn), + "dateModifiedSecs" to dateModifiedSecs, + "sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, + "durationMillis" to durationMillis, + // only for map export + "contentId" to contentId, + ) - if (MimeTypes.isRaw(mimeType) - || (width <= 0 || height <= 0) && needSize(mimeType) - || durationMillis == 0L && needDuration - ) { - // Some images are incorrectly registered in the Media Store, - // missing some attributes such as width, height, orientation. - // Also, the reported size of raw images is inconsistent across devices - // and Android versions (sometimes the raw size, sometimes the decoded size). - val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context) - entryMap = entry.toMap() - } + if (MimeTypes.isRaw(mimeType) + || (width <= 0 || height <= 0) && needSize(mimeType) + || durationMillis == 0L && needDuration + ) { + // Some images are incorrectly registered in the Media Store, + // missing some attributes such as width, height, orientation. + // Also, the reported size of raw images is inconsistent across devices + // and Android versions (sometimes the raw size, sometimes the decoded size). + val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context) + entryMap = entry.toMap() + } - handleNewEntry(entryMap) - // TODO TLAD is this necessary? - if (newEntryCount % 30 == 0) { - delay(10) + handleNewEntry(entryMap) + // TODO TLAD is this necessary? + if (newEntryCount % 30 == 0) { + delay(10) + } + newEntryCount++ } - newEntryCount++ } } cursor.close() @@ -314,7 +321,8 @@ class MediaStoreImageProvider : ImageProvider() { MediaStore.MediaColumns._ID, MediaColumns.PATH, MediaStore.MediaColumns.MIME_TYPE, - MediaStore.MediaColumns.SIZE, // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? + MediaStore.MediaColumns.SIZE, + // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 924d50b65..be9117260 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -44,7 +44,7 @@ object MimeTypes { else -> isVideo(mimeType) } - fun isRaw(mimeType: String?): Boolean { + fun isRaw(mimeType: String): Boolean { return when (mimeType) { ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true else -> false diff --git a/android/build.gradle b/android/build.gradle index 4f36b3be7..82eba64c0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,11 +6,11 @@ buildscript { jcenter() } dependencies { - // TODO TLAD upgrade AGP to 4+ when this is fixed: https://github.com/flutter/flutter/issues/58247 + // TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808 classpath 'com.android.tools.build:gradle:3.6.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.3.4' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1' } } diff --git a/lib/widgets/common/image_providers/app_icon_image_provider.dart b/lib/image_providers/app_icon_image_provider.dart similarity index 100% rename from lib/widgets/common/image_providers/app_icon_image_provider.dart rename to lib/image_providers/app_icon_image_provider.dart diff --git a/lib/widgets/common/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart similarity index 100% rename from lib/widgets/common/image_providers/region_provider.dart rename to lib/image_providers/region_provider.dart diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart similarity index 100% rename from lib/widgets/common/image_providers/thumbnail_provider.dart rename to lib/image_providers/thumbnail_provider.dart diff --git a/lib/widgets/common/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart similarity index 100% rename from lib/widgets/common/image_providers/uri_image_provider.dart rename to lib/image_providers/uri_image_provider.dart diff --git a/lib/widgets/common/image_providers/uri_picture_provider.dart b/lib/image_providers/uri_picture_provider.dart similarity index 79% rename from lib/widgets/common/image_providers/uri_picture_provider.dart rename to lib/image_providers/uri_picture_provider.dart index f2e86ddaa..9165fc3f1 100644 --- a/lib/widgets/common/image_providers/uri_picture_provider.dart +++ b/lib/image_providers/uri_picture_provider.dart @@ -8,10 +8,13 @@ class UriPicture extends PictureProvider { const UriPicture({ @required this.uri, @required this.mimeType, + this.colorFilter, }) : assert(uri != null); final String uri, mimeType; + final ColorFilter colorFilter; + @override Future obtainKey(PictureConfiguration configuration) { return SynchronousFuture(this); @@ -34,22 +37,22 @@ class UriPicture extends PictureProvider { final decoder = SvgPicture.svgByteDecoder; if (onError != null) { - final future = decoder(data, null, key.toString()); + final future = decoder(data, colorFilter, key.toString()); unawaited(future.catchError(onError)); return future; } - return decoder(data, null, key.toString()); + return decoder(data, colorFilter, key.toString()); } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is UriPicture && other.uri == uri; + return other is UriPicture && other.uri == uri && other.colorFilter == colorFilter; } @override - int get hashCode => uri.hashCode; + int get hashCode => hashValues(uri, colorFilter); @override - String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType)'; + String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)'; } diff --git a/lib/main.dart b/lib/main.dart index e5ee4e35a..92cd32fbc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,10 @@ import 'dart:isolate'; import 'dart:ui'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/utils/route_tracker.dart'; -import 'package:aves/widgets/common/data_providers/settings_provider.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/routes.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/behaviour/route_tracker.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/providers/settings_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; @@ -57,6 +57,7 @@ class _AvesAppState extends State { accentColor: accentColor, scaffoldBackgroundColor: Colors.grey[900], buttonColor: accentColor, + dialogBackgroundColor: Colors.grey[850], toggleableActiveColor: accentColor, tooltipTheme: TooltipThemeData( verticalOffset: 32, diff --git a/lib/widgets/filter_grids/common/chip_actions.dart b/lib/model/actions/chip_actions.dart similarity index 93% rename from lib/widgets/filter_grids/common/chip_actions.dart rename to lib/model/actions/chip_actions.dart index a63edfd8f..f5cfd1a6e 100644 --- a/lib/widgets/filter_grids/common/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/widgets.dart'; enum ChipSetAction { diff --git a/lib/widgets/collection/collection_actions.dart b/lib/model/actions/collection_actions.dart similarity index 86% rename from lib/widgets/collection/collection_actions.dart rename to lib/model/actions/collection_actions.dart index c71ba093c..531c8e5f9 100644 --- a/lib/widgets/collection/collection_actions.dart +++ b/lib/model/actions/collection_actions.dart @@ -1,13 +1,14 @@ enum CollectionAction { addShortcut, - copy, + sort, group, - move, refresh, - refreshMetadata, select, selectAll, selectNone, - sort, stats, + // apply to entry set + copy, + move, + refreshMetadata, } diff --git a/lib/widgets/common/entry_actions.dart b/lib/model/actions/entry_actions.dart similarity index 91% rename from lib/widgets/common/entry_actions.dart rename to lib/model/actions/entry_actions.dart index 2fadd1889..805df42f0 100644 --- a/lib/widgets/common/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -1,5 +1,5 @@ -import 'package:aves/widgets/common/icons.dart'; -import 'package:flutter/material.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:flutter/widgets.dart'; enum EntryAction { delete, @@ -15,6 +15,7 @@ enum EntryAction { setAs, share, toggleFavourite, + viewSource, debug, } @@ -31,6 +32,7 @@ class EntryActions { EntryAction.delete, EntryAction.rename, EntryAction.print, + EntryAction.viewSource, ]; static const externalApp = [ @@ -64,6 +66,8 @@ extension ExtraEntryAction on EntryAction { return 'Print'; case EntryAction.share: return 'Share'; + case EntryAction.viewSource: + return 'View source'; // external app actions case EntryAction.edit: return 'Edit with…'; @@ -101,6 +105,8 @@ extension ExtraEntryAction on EntryAction { return AIcons.print; case EntryAction.share: return AIcons.share; + case EntryAction.viewSource: + return AIcons.vector; // external app actions case EntryAction.edit: case EntryAction.open: diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index a00303656..b0ef230cc 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:math'; -import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; +import 'package:aves/image_providers/thumbnail_provider.dart'; +import 'package:aves/image_providers/uri_image_provider.dart'; class EntryCache { static Future evict( diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index aec589bb7..b5062e80e 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -1,8 +1,9 @@ +import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart'; +import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:palette_generator/palette_generator.dart'; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index cee2aff50..d4e9716c1 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index a8bafbe0e..2c4a4a7cc 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 891bd48d6..02ac50252 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/mime_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -35,7 +35,7 @@ class MimeFilter extends CollectionFilter { _label ??= lowMime.split('/')[0].toUpperCase(); } else { _filter = (entry) => entry.mimeType == lowMime; - _label = MimeTypes.displayType(lowMime); + _label = MimeUtils.displayType(lowMime); } _icon ??= AIcons.vector; } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 4a07c2671..b23b27f38 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 30198b8f9..3188d583c 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/model/highlight.dart b/lib/model/highlight.dart new file mode 100644 index 000000000..ca620a153 --- /dev/null +++ b/lib/model/highlight.dart @@ -0,0 +1,24 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +class HighlightInfo extends ChangeNotifier { + final Queue _items = Queue(); + + void add(Object item) { + if (_items.contains(item)) return; + + _items.addFirst(item); + while (_items.length > 5) { + _items.removeLast(); + } + notifyListeners(); + } + + void remove(Object item) { + _items.removeWhere((element) => element == item); + notifyListeners(); + } + + bool contains(Object item) => _items.contains(item); +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 1c6e1d02c..63b78dde1 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -8,21 +8,24 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:geocoder/geocoder.dart'; +import 'package:latlong/latlong.dart'; import 'package:path/path.dart' as ppath; -import 'package:tuple/tuple.dart'; -import 'mime_types.dart'; +import '../ref/mime_types.dart'; class ImageEntry { String uri; String _path, _directory, _filename, _extension; int contentId; final String sourceMimeType; + + // TODO TLAD use SVG viewport as width/height int width; int height; int sourceRotationDegrees; @@ -37,6 +40,9 @@ class ImageEntry { final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); + // TODO TLAD make it dynamic if it depends on OS/lib versions + static const List undecodable = [MimeTypes.crw, MimeTypes.psd]; + ImageEntry({ this.uri, String path, @@ -56,7 +62,7 @@ class ImageEntry { this.dateModifiedSecs = dateModifiedSecs; } - bool get canDecode => !MimeTypes.undecodable.contains(mimeType); + bool get canDecode => !undecodable.contains(mimeType); ImageEntry copyWith({ @required String uri, @@ -217,7 +223,12 @@ class ImageEntry { } } - bool get isPortrait => rotationDegrees % 180 == 90; + // The additional comparison of width to height is a workaround for badly registered entries. + // e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90, + // but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0 + // Double-checking the width/height during loading or cataloguing is the proper solution, + // but it would take space and time, so a basic workaround will do. + bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); String get resolutionText { final w = width ?? '?'; @@ -288,9 +299,14 @@ class ImageEntry { bool get isLocated => _addressDetails != null; - Tuple2 get latLng => isCatalogued ? Tuple2(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; + LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; - String get geoUri => hasGps ? 'geo:${_catalogMetadata.latitude},${_catalogMetadata.longitude}?q=${_catalogMetadata.latitude},${_catalogMetadata.longitude}' : null; + String get geoUri { + if (!hasGps) return null; + final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6); + final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6); + return 'geo:$latitude,$longitude?q=$latitude,$longitude'; + } List get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; @@ -359,7 +375,6 @@ class ImageEntry { final address = addresses.first; addressDetails = AddressDetails( contentId: contentId, - addressLine: address.addressLine, countryCode: address.countryCode, countryName: address.countryName, adminArea: address.adminArea, @@ -371,11 +386,29 @@ class ImageEntry { } } + Future findAddressLine() async { + final latitude = _catalogMetadata?.latitude; + final longitude = _catalogMetadata?.longitude; + if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null; + + final coordinates = Coordinates(latitude, longitude); + try { + final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); + if (addresses != null && addresses.isNotEmpty) { + final address = addresses.first; + return address.addressLine; + } + } catch (error, stackTrace) { + debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace'); + } + return null; + } + String get shortAddress { if (!isLocated) return ''; - // admin area examples: Seoul, Geneva, null - // locality examples: Mapo-gu, Geneva, Annecy + // `admin area` examples: Seoul, Geneva, null + // `locality` examples: Mapo-gu, Geneva, Annecy return { _addressDetails.countryName, _addressDetails.adminArea, @@ -383,12 +416,13 @@ class ImageEntry { }.where((part) => part != null && part.isNotEmpty).join(', '); } - bool search(String query) { - if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true; - if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true; - if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true; - return false; - } + bool search(String query) => { + bestTitle, + _catalogMetadata?.xmpSubjects, + _addressDetails?.countryName, + _addressDetails?.adminArea, + _addressDetails?.locality, + }.any((s) => s != null && s.toUpperCase().contains(query)); Future _applyNewFields(Map newFields) async { final uri = newFields['uri']; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 42ec4f5fd..9471d0ec0 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -48,7 +48,7 @@ class CatalogMetadata { this.xmpTitleDescription, double latitude, double longitude, - }) + }) // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; @@ -142,13 +142,12 @@ class OverlayMetadata { class AddressDetails { final int contentId; - final String addressLine, countryCode, countryName, adminArea, locality; + final String countryCode, countryName, adminArea, locality; String get place => locality != null && locality.isNotEmpty ? locality : adminArea; AddressDetails({ this.contentId, - this.addressLine, this.countryCode, this.countryName, this.adminArea, @@ -160,7 +159,6 @@ class AddressDetails { }) { return AddressDetails( contentId: contentId ?? this.contentId, - addressLine: addressLine, countryCode: countryCode, countryName: countryName, adminArea: adminArea, @@ -171,7 +169,6 @@ class AddressDetails { factory AddressDetails.fromMap(Map map) { return AddressDetails( contentId: map['contentId'], - addressLine: map['addressLine'] ?? '', countryCode: map['countryCode'] ?? '', countryName: map['countryName'] ?? '', adminArea: map['adminArea'] ?? '', @@ -181,7 +178,6 @@ class AddressDetails { Map toMap() => { 'contentId': contentId, - 'addressLine': addressLine, 'countryCode': countryCode, 'countryName': countryName, 'adminArea': adminArea, @@ -190,7 +186,7 @@ class AddressDetails { @override String toString() { - return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; + return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 721659cd1..681a38605 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -143,7 +143,7 @@ class MetadataDb { await init(); } - void removeIds(List contentIds) async { + void removeIds(Set contentIds, {@required bool updateFavourites}) async { if (contentIds == null || contentIds.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -157,7 +157,9 @@ class MetadataDb { batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(metadataTable, where: where, whereArgs: whereArgs); batch.delete(addressTable, where: where, whereArgs: whereArgs); - batch.delete(favouriteTable, where: where, whereArgs: whereArgs); + if (updateFavourites) { + batch.delete(favouriteTable, where: where, whereArgs: whereArgs); + } }); await batch.commit(noResult: true); debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries'); diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index be88e0ca1..6bc89007b 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -1,5 +1,5 @@ import 'package:aves/utils/geo_utils.dart'; -import 'package:tuple/tuple.dart'; +import 'package:latlong/latlong.dart'; enum CoordinateFormat { dms, decimal } @@ -15,12 +15,12 @@ extension ExtraCoordinateFormat on CoordinateFormat { } } - String format(Tuple2 latLng) { + String format(LatLng latLng) { switch (this) { case CoordinateFormat.dms: return toDMS(latLng).join(', '); case CoordinateFormat.decimal: - return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', '); + return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); default: return toString(); } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 160d42ae2..87f6e0380 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -29,11 +29,11 @@ class Settings extends ChangeNotifier { static const keepScreenOnKey = 'keep_screen_on'; static const homePageKey = 'home_page'; static const catalogTimeZoneKey = 'catalog_time_zone'; + static const tileExtentPrefixKey = 'tile_extent_'; // collection static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionSortFactorKey = 'collection_sort_factor'; - static const collectionTileExtentKey = 'collection_tile_extent'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailRawKey = 'show_thumbnail_raw'; static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; @@ -112,6 +112,12 @@ class Settings extends ChangeNotifier { set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue); + double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0; + + // do not notify, as tile extents are only used internally by `TileExtentManager` + // and should not trigger rebuilding by change notification + void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false); + // collection EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values); @@ -122,12 +128,6 @@ class Settings extends ChangeNotifier { set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString()); - double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0; - - // do not notify, as `collectionTileExtent` is only used internally by `TileExtentManager` - // and should not trigger rebuilding by change notification - set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue, notify: false); - bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); @@ -198,14 +198,6 @@ class Settings extends ChangeNotifier { set searchHistory(List newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList()); - // utils - - // `RoutePredicate` - RoutePredicate navRemoveRoutePredicate(String pushedRouteName) { - final home = homePage.routeName; - return (route) => pushedRouteName != home && route.settings?.name == home; - } - // convenience methods // ignore: avoid_positional_boolean_parameters diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 21b169c91..9b54aa18f 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -4,7 +4,6 @@ import 'dart:collection'; 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/model/source/tag.dart'; import 'package:aves/utils/change_notifier.dart'; @@ -19,7 +18,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel EntryGroupFactor groupFactor; EntrySortFactor sortFactor; final AChangeNotifier filterChangeNotifier = AChangeNotifier(); - final StreamController _highlightController = StreamController.broadcast(); List _filteredEntries; List _subscriptions = []; @@ -50,14 +48,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel super.dispose(); } - factory CollectionLens.empty() { - return CollectionLens( - source: CollectionSource(), - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - ); - } - CollectionLens derive(CollectionFilter filter) { return CollectionLens( source: source, @@ -79,10 +69,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel return _sortedEntries; } - Stream get highlightStream => _highlightController.stream; - - void highlight(ImageEntry entry) => _highlightController.add(entry); - bool get showHeaders { if (sortFactor == EntrySortFactor.size) return false; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 7c99427bd..d3e669e18 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -37,7 +37,7 @@ mixin SourceBase { void setProgress({@required int done, @required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total)); } -class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { +abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { @override List get sortedEntriesForFilterList => CollectionLens( source: this, @@ -45,7 +45,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { sortFactor: EntrySortFactor.date, ).sortedEntries; - ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); + ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); List _savedDates; @@ -109,7 +109,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { } void updateAfterMove({ - @required List selection, + @required Set selection, @required bool copy, @required String destinationAlbum, @required Iterable movedOps, @@ -163,6 +163,10 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { int count(CollectionFilter filter) { return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length); } + + Future refresh(); + + Future refreshMetadata(Set entries); } enum SourceState { loading, cataloguing, locating, ready } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 789a1e1eb..6312bffeb 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; @@ -30,15 +32,25 @@ mixin LocationMixin on SourceBase { final todo = byLocated[false] ?? []; if (todo.isEmpty) return; - // cache known locations to avoid querying the geocoder unless necessary - // measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates) - // does not clearly show whether it is an actual optimization, - // as results vary wildly (durations in "min:sec"): - // - with no cache: 06:17, 08:36, 08:34 - // - with cache: 08:28, 05:42, 08:03, 05:58 - // anyway, in theory it should help! - final knownLocations = , AddressDetails>{}; - byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails)); + // geocoder calls take between 150ms and 250ms + // approximation and caching can reduce geocoder usage + // for example, for a set of 2932 entries: + // - 2476 calls (84%) when approximating to 6 decimal places (~10cm - individual humans) + // - 2433 calls (83%) when approximating to 5 decimal places (~1m - individual trees, houses) + // - 2277 calls (78%) when approximating to 4 decimal places (~10m - individual street, large buildings) + // - 1521 calls (52%) when approximating to 3 decimal places (~100m - neighborhood, street) + // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village) + // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision + final latLngFactor = pow(10, 2); + Tuple2 approximateLatLng(ImageEntry entry) { + final lat = entry.catalogMetadata?.latitude; + final lng = entry.catalogMetadata?.longitude; + if (lat == null || lng == null) return null; + return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); + } + + final knownLocations = {}; + byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails)); var progressDone = 0; final progressTotal = todo.length; @@ -46,13 +58,14 @@ mixin LocationMixin on SourceBase { final newAddresses = []; await Future.forEach(todo, (entry) async { - if (knownLocations.containsKey(entry.latLng)) { - entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId); + final latLng = approximateLatLng(entry); + if (knownLocations.containsKey(latLng)) { + entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); } else { await entry.locate(background: true); // it is intended to insert `null` if the geocoder failed, // so that we skip geocoding of following entries with the same coordinates - knownLocations[entry.latLng] = entry.addressDetails; + knownLocations[latLng] = entry.addressDetails; } if (entry.isLocated) { newAddresses.add(entry.addressDetails); diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/model/source/media_store_source.dart similarity index 89% rename from lib/widgets/common/data_providers/media_store_collection_provider.dart rename to lib/model/source/media_store_source.dart index f10dd760b..c0afae1aa 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/model/source/media_store_source.dart @@ -6,6 +6,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_timezone/flutter_native_timezone.dart'; @@ -30,14 +31,16 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}'); } + @override Future refresh() async { debugPrint('$runtimeType refresh start'); final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; + clearEntries(); final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); - final obsoleteEntries = await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList()); + final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet(); oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId)); // show known entries @@ -47,7 +50,7 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); // clean up obsolete entries - metadataDb.removeIds(obsoleteEntries); + metadataDb.removeIds(obsoleteEntries, updateFavourites: true); // fetch new entries var refreshCount = 10; @@ -92,9 +95,10 @@ class MediaStoreSource extends CollectionSource { ); } - // e.g. x=12345, precision=3 should return 13000 - int ceilBy(num x, int precision) { - final factor = pow(10, precision); - return (x / factor).ceil() * factor; + @override + Future refreshMetadata(Set entries) { + final contentIds = entries.map((entry) => entry.contentId).toSet(); + metadataDb.removeIds(contentIds, updateFavourites: false); + return refresh(); } } diff --git a/lib/ref/brand_colors.dart b/lib/ref/brand_colors.dart new file mode 100644 index 000000000..1e5c99a91 --- /dev/null +++ b/lib/ref/brand_colors.dart @@ -0,0 +1,21 @@ +import 'package:flutter/painting.dart'; + +class BrandColors { + static const Color adobeIllustrator = Color(0xFFFF9B00); + static const Color adobePhotoshop = Color(0xFF2DAAFF); + static const Color android = Color(0xFF3DDC84); + static const Color flutter = Color(0xFF47D1FD); + + static Color get(String text) { + if (text != null) { + switch (text.toLowerCase()) { + case 'illustrator': + return adobeIllustrator; + case 'photoshop': + case 'lightroom': + return adobePhotoshop; + } + } + return null; + } +} diff --git a/lib/model/mime_types.dart b/lib/ref/mime_types.dart similarity index 78% rename from lib/model/mime_types.dart rename to lib/ref/mime_types.dart index 0af76645f..537d81121 100644 --- a/lib/model/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -41,17 +41,4 @@ class MimeTypes { // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; - static const List undecodable = [crw, psd]; // TODO TLAD make it dynamic if it depends on OS/lib versions - - static String displayType(String mime) { - final patterns = [ - RegExp('.*/'), // remove type, keep subtype - RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes - '+XML', // noisy suffix - RegExp('ADOBE\\\.'), // for PSD - ]; - mime = mime.toUpperCase(); - patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, '')); - return mime; - } } diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart new file mode 100644 index 000000000..55ddc7eda --- /dev/null +++ b/lib/ref/xmp.dart @@ -0,0 +1,30 @@ +class XMP { + static const namespaceSeparator = ':'; + static const structFieldSeparator = '/'; + + // cf https://exiftool.org/TagNames/XMP.html + static const Map namespaces = { + 'aux': 'Auxiliary Exif', + 'Camera': 'Camera', + 'crs': 'Camera Raw Settings', + 'dc': 'Dublin Core', + 'exif': 'Exif', + 'GIMP': 'GIMP', + 'illustrator': 'Illustrator', + 'Iptc4xmpCore': 'IPTC Core', + 'lr': 'Lightroom', + 'MicrosoftPhoto': 'Microsoft Photo', + 'panorama': 'Panorama', + 'pdf': 'PDF', + 'pdfx': 'PDF/X', + 'photomechanic': 'Photo Mechanic', + 'photoshop': 'Photoshop', + 'tiff': 'TIFF', + 'xmp': 'Basic', + 'xmpBJ': 'Basic Job Ticket', + 'xmpDM': 'Dynamic Media', + 'xmpMM': 'Media Management', + 'xmpRights': 'Rights Management', + 'xmpTPg': 'Paged-Text', + }; +} diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index 979bb1331..0a0bb2f12 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -18,6 +19,18 @@ class AndroidFileService { return []; } + static Future getFreeSpace(StorageVolume volume) async { + try { + final result = await platform.invokeMethod('getFreeSpace', { + 'path': volume.path, + }); + return result as int; + } on PlatformException catch (e) { + debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return 0; + } + static Future> getGrantedDirectories() async { try { final result = await platform.invokeMethod('getGrantedDirectories'); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 0eec5e1ff..c27d72d30 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -4,7 +4,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/utils/durations.dart b/lib/theme/durations.dart similarity index 90% rename from lib/utils/durations.dart rename to lib/theme/durations.dart index 68394e51b..0481bf563 100644 --- a/lib/utils/durations.dart +++ b/lib/theme/durations.dart @@ -12,8 +12,13 @@ class Durations { static const staggeredAnimation = Duration(milliseconds: 375); static const dialogFieldReachAnimation = Duration(milliseconds: 300); - // collection animations static const appBarTitleAnimation = Duration(milliseconds: 300); + static const appBarActionChangeAnimation = Duration(milliseconds: 200); + + // filter grids animations + static const chipDecorationAnimation = Duration(milliseconds: 200); + + // collection animations static const filterBarRemovalAnimation = Duration(milliseconds: 400); static const collectionOpOverlayAnimation = Duration(milliseconds: 300); static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200); @@ -40,4 +45,5 @@ class Durations { static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const softKeyboardDisplayDelay = Duration(milliseconds: 300); + static const searchDebounceDelay = Duration(milliseconds: 250); } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart new file mode 100644 index 000000000..6d33fce2b --- /dev/null +++ b/lib/theme/icons.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; + +class AIcons { + static const IconData allCollection = Icons.collections_outlined; + static const IconData image = Icons.photo_outlined; + static const IconData video = Icons.movie_outlined; + static const IconData audio = Icons.audiotrack_outlined; + static const IconData vector = Icons.code_outlined; + + static const IconData android = Icons.android; + static const IconData checked = Icons.done_outlined; + static const IconData date = Icons.calendar_today_outlined; + static const IconData disc = Icons.fiber_manual_record; + static const IconData error = Icons.error_outline; + static const IconData location = Icons.place_outlined; + static const IconData locationOff = Icons.location_off_outlined; + static const IconData raw = Icons.camera_outlined; + static const IconData shooting = Icons.camera_outlined; + static const IconData removableStorage = Icons.sd_storage_outlined; + static const IconData settings = Icons.settings_outlined; + static const IconData text = Icons.format_quote_outlined; + static const IconData tag = Icons.local_offer_outlined; + static const IconData tagOff = MdiIcons.tagOffOutline; + + // actions + static const IconData addShortcut = Icons.add_to_home_screen_outlined; + static const IconData clear = Icons.clear_outlined; + static const IconData collapse = Icons.expand_less_outlined; + static const IconData createAlbum = Icons.add_circle_outline; + static const IconData debug = Icons.whatshot_outlined; + static const IconData delete = Icons.delete_outlined; + static const IconData expand = Icons.expand_more_outlined; + static const IconData flip = Icons.flip_outlined; + static const IconData favourite = Icons.favorite_border; + static const IconData favouriteActive = Icons.favorite; + static const IconData goUp = Icons.arrow_upward_outlined; + static const IconData group = Icons.group_work_outlined; + static const IconData info = Icons.info_outlined; + static const IconData layers = Icons.layers_outlined; + static const IconData openInNew = Icons.open_in_new_outlined; + static const IconData pin = Icons.push_pin_outlined; + static const IconData print = Icons.print_outlined; + static const IconData refresh = Icons.refresh_outlined; + static const IconData rename = Icons.title_outlined; + static const IconData rotateLeft = Icons.rotate_left_outlined; + static const IconData rotateRight = Icons.rotate_right_outlined; + static const IconData search = Icons.search_outlined; + static const IconData select = Icons.select_all_outlined; + static const IconData share = Icons.share_outlined; + static const IconData sort = Icons.sort_outlined; + static const IconData stats = Icons.pie_chart_outlined; + static const IconData zoomIn = Icons.add_outlined; + static const IconData zoomOut = Icons.remove_outlined; + + // albums + static const IconData album = Icons.photo_album_outlined; + static const IconData cameraAlbum = Icons.photo_camera_outlined; + static const IconData downloadAlbum = Icons.file_download; + static const IconData screenshotAlbum = Icons.smartphone_outlined; + + // thumbnail overlay + static const IconData animated = Icons.slideshow; + static const IconData play = Icons.play_circle_outline; + static const IconData selected = Icons.check_circle_outline; + static const IconData unselected = Icons.radio_button_unchecked; +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index e7e88d044..59d567b65 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; -import 'package:tuple/tuple.dart'; +import 'package:latlong/latlong.dart'; class Constants { // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped @@ -21,11 +21,23 @@ class Constants { static const String overlayUnknown = '—'; // em dash static const String infoUnknown = 'unknown'; - static const pointNemo = Tuple2(-48.876667, -123.393333); + static final pointNemo = LatLng(-48.876667, -123.393333); static const int infoGroupMaxValueLength = 140; static const List androidDependencies = [ + Dependency( + name: 'AndroidX Core-KTX', + license: 'Apache 2.0', + licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt', + sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core/core-ktx', + ), + Dependency( + name: 'AndroidX Exifinterface', + license: 'Apache 2.0', + licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt', + sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/exifinterface/exifinterface', + ), Dependency( name: 'Android-TiffBitmapFactory', license: 'MIT', @@ -96,7 +108,7 @@ class Constants { sourceUrl: 'https://github.com/Skylled/expansion_tile_card', ), Dependency( - name: 'FlutterFire', + name: 'FlutterFire (Core, Analytics, Crashlytics)', license: 'BSD 3-Clause', licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE', sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', @@ -107,6 +119,12 @@ class Constants { licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE', sourceUrl: 'https://github.com/AndreHaueisen/flushbar', ), + Dependency( + name: 'Flutter Highlight', + license: 'MIT', + licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE', + sourceUrl: 'https://github.com/git-touch/highlight', + ), Dependency( name: 'Flutter ijkplayer', license: 'MIT', @@ -180,7 +198,7 @@ class Constants { sourceUrl: 'https://github.com/boyan01/overlay_support', ), Dependency( - name: 'Package info', + name: 'Package Info', license: 'BSD 3-Clause', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info', @@ -269,12 +287,6 @@ class Constants { licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', ), - Dependency( - name: 'UUID', - license: 'MIT', - licenseUrl: 'https://github.com/Daegalus/dart-uuid/blob/master/LICENSE', - sourceUrl: 'https://github.com/Daegalus/dart-uuid', - ), ]; } diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 000000000..b41cd90bc --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class Debouncer { + final Duration delay; + + Timer _timer; + + Debouncer({@required this.delay}); + + void call(Function action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } +} diff --git a/lib/utils/flutter_utils.dart b/lib/utils/flutter_utils.dart deleted file mode 100644 index 3f06767b8..000000000 --- a/lib/utils/flutter_utils.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter/widgets.dart'; - -extension ExtraContext on BuildContext { - String get currentRouteName => ModalRoute.of(this)?.settings?.name; -} diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 226eaa95b..62a942c2a 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -1,15 +1,12 @@ -import 'dart:math'; - +import 'package:aves/utils/math_utils.dart'; import 'package:intl/intl.dart'; -import 'package:tuple/tuple.dart'; +import 'package:latlong/latlong.dart'; String _decimal2sexagesimal(final double degDecimal) { - double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals); - List _split(final double value) { // NumberFormat is necessary to create digit after comma if the value // has no decimal point (only necessary for browser) - final tmp = NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.'); + final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); return [ int.parse(tmp[0]).abs(), int.parse(tmp[1]), @@ -21,14 +18,14 @@ String _decimal2sexagesimal(final double degDecimal) { final min = _split(minDecimal)[0]; final sec = (minDecimal - min) * 60; - return '$deg° $min′ ${_round(sec, decimals: 2).toStringAsFixed(2)}″'; + return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″'; } // return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] -List toDMS(Tuple2 latLng) { +List toDMS(LatLng latLng) { if (latLng == null) return []; - final lat = latLng.item1; - final lng = latLng.item2; + final lat = latLng.latitude; + final lng = latLng.longitude; return [ '${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}', diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index c83bde20b..541ebb5c5 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -1,11 +1,20 @@ import 'dart:math'; -const double _piOver180 = pi / 180.0; +import 'package:flutter/foundation.dart'; -final double log2 = log(2); +final double _log2 = log(2); +const double _piOver180 = pi / 180.0; double toDegrees(num radians) => radians / _piOver180; double toRadians(num degrees) => degrees * _piOver180; -int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / log2).floor()); +int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()); + +double roundToPrecision(final double value, {@required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals); + +// e.g. x=12345, precision=3 should return 13000 +int ceilBy(num x, int precision) { + final factor = pow(10, precision); + return (x / factor).ceil() * factor; +} diff --git a/lib/utils/mime_utils.dart b/lib/utils/mime_utils.dart new file mode 100644 index 000000000..52c223f29 --- /dev/null +++ b/lib/utils/mime_utils.dart @@ -0,0 +1,20 @@ +class MimeUtils { + static String displayType(String mime) { + switch (mime) { + case 'image/x-icon': + return 'ICO'; + case 'image/vnd.adobe.photoshop': + case 'image/x-photoshop': + return 'PSD'; + default: + final patterns = [ + RegExp('.*/'), // remove type, keep subtype + RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes + '+XML', // noisy suffix + ]; + mime = mime.toUpperCase(); + patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, '')); + return mime; + } + } +} diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 6e35112f6..8abc4a8db 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,9 +1,8 @@ import 'package:aves/flutter_version.dart'; import 'package:aves/widgets/about/licenses.dart'; -import 'package:aves/widgets/common/aves_logo.dart'; -import 'package:aves/widgets/common/link_chip.dart'; +import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:package_info/package_info.dart'; class AboutPage extends StatelessWidget { @@ -16,24 +15,58 @@ class AboutPage extends StatelessWidget { title: Text('About'), ), body: SafeArea( - child: AnimationLimiter( - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: EdgeInsets.only(top: 16), - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - AppReference(), - SizedBox(height: 16), - Divider(), - ], - ), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: 16), + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + AppReference(), + SizedBox(height: 16), + Divider(), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints(minHeight: 48), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + 'Credits', + style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'), + ), + ), + ), + RichText( + text: TextSpan( + children: [ + TextSpan(text: 'This app uses the font '), + WidgetSpan( + child: LinkChip( + text: 'Concourse', + url: 'https://mbtype.com/fonts/concourse/', + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' for titles and the media information page.'), + ], + ), + ), + SizedBox(height: 16), + ], + ), + ), + Divider(), + ], ), ), - Licenses(), - ], - ), + ), + Licenses(), + ], ), ), ); diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index e8241cc70..3138ee81c 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -1,11 +1,11 @@ +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/link_chip.dart'; -import 'package:aves/widgets/common/menu_row.dart'; +import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; class Licenses extends StatefulWidget { @override @@ -13,18 +13,20 @@ class Licenses extends StatefulWidget { } class _LicensesState extends State { + final ValueNotifier _expandedNotifier = ValueNotifier(null); LicenseSort _sort = LicenseSort.name; - List _packages; + List _platform, _flutter; @override void initState() { super.initState(); - _packages = [...Constants.androidDependencies, ...Constants.flutterPackages]; + _platform = List.from(Constants.androidDependencies); + _flutter = List.from(Constants.flutterPackages); _sortPackages(); } void _sortPackages() { - _packages.sort((a, b) { + int compare(Dependency a, Dependency b) { switch (_sort) { case LicenseSort.license: final c = compareAsciiUpperCase(a.license, b.license); @@ -33,7 +35,10 @@ class _LicensesState extends State { default: return compareAsciiUpperCase(a.name, b.name); } - }); + } + + _platform.sort(compare); + _flutter.sort(compare); } @override @@ -41,25 +46,40 @@ class _LicensesState extends State { return SliverPadding( padding: EdgeInsets.symmetric(horizontal: 8), sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index-- == 0) { - return _buildHeader(); - } - final child = LicenseRow(_packages[index]); - return AnimationConfiguration.staggeredList( - position: index, - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, + delegate: SliverChildListDelegate( + [ + _buildHeader(), + SizedBox(height: 16), + AvesExpansionTile( + title: 'Android Libraries', + color: BrandColors.android, + expandedNotifier: _expandedNotifier, + children: _platform.map((package) => LicenseRow(package)).toList(), + ), + AvesExpansionTile( + title: 'Flutter Packages', + color: BrandColors.flutter, + expandedNotifier: _expandedNotifier, + children: _flutter.map((package) => LicenseRow(package)).toList(), + ), + Center( + child: TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Theme( + data: Theme.of(context).copyWith( + // as of Flutter v1.22.4, `cardColor` is used as a background color by `LicensePage` + cardColor: Theme.of(context).scaffoldBackgroundColor, + ), + child: LicensePage(), + ), + ), ), + child: Text('Show All Licenses'.toUpperCase()), ), - ); - }, - childCount: _packages.length + 1, + ), + ], ), ), ); @@ -122,7 +142,7 @@ class LicenseRow extends StatelessWidget { final subColor = bodyTextStyle.color.withOpacity(.6); return Padding( - padding: EdgeInsets.only(top: 16), + padding: EdgeInsets.symmetric(vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 5e67c0bf3..ff4601a4c 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -1,23 +1,22 @@ import 'dart:async'; import 'package:aves/main.dart'; +import 'package:aves/model/actions/collection_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; 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/services/app_shortcut_service.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/collection/collection_actions.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; -import 'package:aves/widgets/common/action_delegates/add_shortcut_dialog.dart'; -import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.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/common/entry_actions.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/menu_row.dart'; +import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats.dart'; @@ -43,7 +42,7 @@ class CollectionAppBar extends StatefulWidget { class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { final TextEditingController _searchFieldController = TextEditingController(); - SelectionActionDelegate _actionDelegate; + EntrySetActionDelegate _actionDelegate; AnimationController _browseToSelectAnimation; Future _canAddShortcutsLoader; @@ -56,7 +55,7 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void initState() { super.initState(); - _actionDelegate = SelectionActionDelegate( + _actionDelegate = EntrySetActionDelegate( collection: collection, ); _browseToSelectAnimation = AnimationController( @@ -259,7 +258,10 @@ class _CollectionAppBarState extends State with SingleTickerPr ] ]; }, - onSelected: _onCollectionActionSelected, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action)); + }, ); }, ), @@ -279,9 +281,7 @@ class _CollectionAppBarState extends State with SingleTickerPr widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0); } - void _onCollectionActionSelected(CollectionAction action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); + Future _onCollectionActionSelected(CollectionAction action) async { switch (action) { case CollectionAction.copy: case CollectionAction.move: @@ -289,10 +289,7 @@ class _CollectionAppBarState extends State with SingleTickerPr _actionDelegate.onCollectionActionSelected(context, action); break; case CollectionAction.refresh: - if (source is MediaStoreSource) { - source.clearEntries(); - unawaited((source as MediaStoreSource).refresh()); - } + unawaited(source.refresh()); break; case CollectionAction.select: collection.select(); @@ -377,7 +374,8 @@ class _CollectionAppBarState extends State with SingleTickerPr MaterialPageRoute( settings: RouteSettings(name: StatsPage.routeName), builder: (context) => StatsPage( - collection: collection, + source: source, + parentCollection: collection, ), ), ); diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 35fdb5981..7a52c08f2 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/thumbnail_collection.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/double_back_pop.dart'; +import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart similarity index 53% rename from lib/widgets/common/action_delegates/selection_action_delegate.dart rename to lib/widgets/collection/entry_set_action_delegate.dart index 837776778..9ecf90e61 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,34 +1,30 @@ import 'dart:async'; -import 'package:aves/model/filters/album.dart'; -import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/actions/collection_actions.dart'; +import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/image_entry.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/services/android_app_service.dart'; import 'package:aves/services/image_file_service.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'; -import 'package:aves/widgets/common/action_delegates/feedback.dart'; -import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; -import 'package:aves/widgets/common/aves_dialog.dart'; -import 'package:aves/widgets/common/entry_actions.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { +class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionLens collection; - SelectionActionDelegate({ + CollectionSource get source => collection.source; + + Set get selection => collection.selection; + + EntrySetActionDelegate({ @required this.collection, }); @@ -38,7 +34,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { _showDeleteDialog(context); break; case EntryAction.share: - AndroidAppService.share(collection.selection); + AndroidAppService.share(selection).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); break; default: break; @@ -54,7 +52,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { _moveSelection(context, copy: false); break; case CollectionAction.refreshMetadata: - _refreshSelectionMetadata(); + source.refreshMetadata(selection); + collection.clearSelection(); + collection.browse(); break; default: break; @@ -62,60 +62,20 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { } Future _moveSelection(BuildContext context, {@required bool copy}) async { - final source = collection.source; - final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source); final destinationAlbum = await Navigator.push( context, MaterialPageRoute( - builder: (context) { - return Selector( - selector: (context, s) => s.albumSortFactor, - builder: (context, sortFactor, child) { - return FilterGridPage( - source: source, - appBar: SliverAppBar( - leading: BackButton(), - title: Text(copy ? 'Copy to Album' : 'Move to Album'), - actions: [ - IconButton( - icon: Icon(AIcons.createAlbum), - onPressed: () async { - final newAlbum = await showDialog( - context: context, - builder: (context) => CreateAlbumDialog(), - ); - if (newAlbum != null && newAlbum.isNotEmpty) { - Navigator.pop(context, newAlbum); - } - }, - tooltip: 'Create album', - ), - IconButton( - icon: Icon(AIcons.sort), - onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort), - ), - ], - floating: true, - ), - filterEntries: AlbumListPage.getAlbumEntries(source), - filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: 'No albums', - ), - onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), - ); - }, - ); - }, + settings: RouteSettings(name: AlbumPickPage.routeName), + builder: (context) => AlbumPickPage(source: source, copy: copy), ), ); if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; - final selection = collection.selection.toList(); if (!await checkStoragePermission(context, selection)) return; + if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return; + showOpReport( context: context, selection: selection, @@ -143,24 +103,14 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { ); } - Future _refreshSelectionMetadata() async { - collection.selection.forEach((entry) => entry.clearMetadata()); - final source = collection.source; - source.stateNotifier.value = SourceState.cataloguing; - await source.catalogEntries(); - source.stateNotifier.value = SourceState.locating; - await source.locateEntries(); - source.stateNotifier.value = SourceState.ready; - } - Future _showDeleteDialog(BuildContext context) async { - final selection = collection.selection.toList(); final count = selection.length; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( + context: context, content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'), actions: [ TextButton( @@ -192,7 +142,7 @@ 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)).toList()); + source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toList()); } collection.clearSelection(); collection.browse(); diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index e447f934a..712f21fa3 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; class FilterBar extends StatefulWidget implements PreferredSizeWidget { diff --git a/lib/widgets/collection/grid/header_album.dart b/lib/widgets/collection/grid/header_album.dart index 5afd82fa3..d5ef85b6a 100644 --- a/lib/widgets/collection/grid/header_album.dart +++ b/lib/widgets/collection/grid/header_album.dart @@ -1,6 +1,7 @@ +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/material.dart'; class AlbumSectionHeader extends StatelessWidget { diff --git a/lib/widgets/collection/grid/header_generic.dart b/lib/widgets/collection/grid/header_generic.dart index eabeb55d0..2396cf2c3 100644 --- a/lib/widgets/collection/grid/header_generic.dart +++ b/lib/widgets/collection/grid/header_generic.dart @@ -3,12 +3,12 @@ import 'dart:math'; 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/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/collection/grid/header_album.dart'; import 'package:aves/widgets/collection/grid/header_date.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart index fe88bfd51..540beee50 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart'; -import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; +import 'package:aves/widgets/collection/thumbnail_collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -22,7 +22,7 @@ class SectionedListLayoutProvider extends StatelessWidget { @required this.thumbnailBuilder, @required this.child, }) : assert(scrollableWidth != 0), - columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin); + columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin); @override Widget build(BuildContext context) { diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart index 3ed09aea1..91089845c 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -4,8 +4,9 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/widgets/collection/grid/list_known_extent.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/common/routes.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -70,7 +71,7 @@ class GridThumbnail extends StatelessWidget { } }, child: MetaData( - metaData: ThumbnailMetadata(entry), + metaData: ScalerMetadata(entry), child: DecoratedThumbnail( entry: entry, extent: tileExtent, @@ -94,10 +95,3 @@ class GridThumbnail extends StatelessWidget { ); } } - -// metadata to identify entry from RenderObject hit test during collection scaling -class ThumbnailMetadata { - final ImageEntry entry; - - const ThumbnailMetadata(this.entry); -} diff --git a/lib/widgets/collection/grid/tile_extent_manager.dart b/lib/widgets/collection/grid/tile_extent_manager.dart deleted file mode 100644 index 5b920bde9..000000000 --- a/lib/widgets/collection/grid/tile_extent_manager.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/settings/settings.dart'; -import 'package:flutter/widgets.dart'; - -class TileExtentManager { - static const int columnCountMin = 2; - static const int columnCountDefault = 4; - static const double tileExtentMin = 46.0; - static const screenDimensionMin = tileExtentMin * columnCountMin; - - static double applyTileExtent(Size mqSize, double mqHorizontalPadding, ValueNotifier extentNotifier, {double newExtent}) { - // sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size) - mqSize = Size(max(mqSize.width, screenDimensionMin), max(mqSize.height, screenDimensionMin)); - - final availableWidth = mqSize.width - mqHorizontalPadding; - var numColumns; - if ((newExtent ?? 0) == 0) { - newExtent = extentNotifier.value; - } - if ((newExtent ?? 0) == 0) { - newExtent = settings.collectionTileExtent; - } - if ((newExtent ?? 0) == 0) { - numColumns = columnCountDefault; - } else { - newExtent = newExtent.clamp(tileExtentMin, extentMaxForSize(mqSize)); - numColumns = max(columnCountMin, (availableWidth / newExtent).round()); - } - newExtent = availableWidth / numColumns; - if (extentNotifier.value != newExtent) { - settings.collectionTileExtent = newExtent; - extentNotifier.value = newExtent; - } - return newExtent; - } - - static double extentMaxForSize(Size mqSize) { - return mqSize.shortestSide / columnCountMin; - } -} diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 2f2ac1676..71dd0377c 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -10,7 +10,7 @@ class DecoratedThumbnail extends StatelessWidget { final double extent; final CollectionLens collection; final ValueNotifier isScrollingNotifier; - final bool showOverlay; + final bool selectable, highlightable; final Object heroTag; static final Color borderColor = Colors.grey.shade700; @@ -22,7 +22,8 @@ class DecoratedThumbnail extends StatelessWidget { @required this.extent, this.collection, this.isScrollingNotifier, - this.showOverlay = true, + this.selectable = true, + this.highlightable = true, }) : heroTag = collection?.heroTag(entry), super(key: key); @@ -40,29 +41,32 @@ class DecoratedThumbnail extends StatelessWidget { isScrollingNotifier: isScrollingNotifier, heroTag: heroTag, ); - if (showOverlay) { - child = Stack( - children: [ - child, - Positioned( - bottom: 0, - left: 0, - child: ThumbnailEntryOverlay( - entry: entry, - extent: extent, - ), + + child = Stack( + fit: StackFit.passthrough, + children: [ + child, + Positioned( + bottom: 0, + left: 0, + child: ThumbnailEntryOverlay( + entry: entry, + extent: extent, ), + ), + if (selectable) ThumbnailSelectionOverlay( entry: entry, extent: extent, ), + if (highlightable) ThumbnailHighlightOverlay( - highlightedStream: collection.highlightStream.map((highlighted) => highlighted == entry), + entry: entry, extent: extent, ), - ], - ); - } + ], + ); + return Container( foregroundDecoration: BoxDecoration( border: Border.all( diff --git a/lib/widgets/collection/thumbnail/error.dart b/lib/widgets/collection/thumbnail/error.dart index 3bfeb9b1c..f66274fde 100644 --- a/lib/widgets/collection/thumbnail/error.dart +++ b/lib/widgets/collection/thumbnail/error.dart @@ -1,5 +1,5 @@ import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; +import 'package:aves/utils/mime_utils.dart'; import 'package:flutter/material.dart'; class ErrorThumbnail extends StatelessWidget { @@ -15,12 +15,14 @@ class ErrorThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( + return Container( + alignment: Alignment.center, + color: Colors.black, child: Tooltip( message: tooltip, preferBelow: false, child: Text( - MimeTypes.displayType(entry.mimeType), + MimeUtils.displayType(entry.mimeType), style: TextStyle( color: Colors.blueGrey, fontSize: extent / 5, diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index bde0dd789..6e9179bd3 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -1,12 +1,14 @@ import 'dart:math'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -115,13 +117,13 @@ class ThumbnailSelectionOverlay extends StatelessWidget { } class ThumbnailHighlightOverlay extends StatefulWidget { + final ImageEntry entry; final double extent; - final Stream highlightedStream; const ThumbnailHighlightOverlay({ Key key, + @required this.entry, @required this.extent, - @required this.highlightedStream, }) : super(key: key); @override @@ -131,27 +133,25 @@ class ThumbnailHighlightOverlay extends StatefulWidget { class _ThumbnailHighlightOverlayState extends State { final ValueNotifier _highlightedNotifier = ValueNotifier(false); + ImageEntry get entry => widget.entry; + @override Widget build(BuildContext context) { - return StreamBuilder( - stream: widget.highlightedStream, - builder: (context, snapshot) { - _highlightedNotifier.value = snapshot.hasData && snapshot.data; - return Sweeper( - builder: (context) => Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).accentColor, - width: widget.extent * .1, - ), - ), + final highlightInfo = context.watch(); + _highlightedNotifier.value = highlightInfo.contains(entry); + return Sweeper( + builder: (context) => Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).accentColor, + width: widget.extent * .1, ), - toggledNotifier: _highlightedNotifier, - startAngle: pi * -3 / 4, - centerSweep: false, - onSweepEnd: () => _highlightedNotifier.value = false, - ); - }, + ), + ), + toggledNotifier: _highlightedNotifier, + startAngle: pi * -3 / 4, + centerSweep: false, + onSweepEnd: () => highlightInfo.remove(entry), ); } } diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 05d7ce36f..573caa1dd 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -1,11 +1,11 @@ import 'dart:math'; +import 'package:aves/image_providers/thumbnail_provider.dart'; +import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/error.dart'; -import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; -import 'package:aves/widgets/common/transition_image.dart'; +import 'package:aves/widgets/common/fx/transition_image.dart'; import 'package:flutter/material.dart'; class ThumbnailRasterImage extends StatefulWidget { @@ -98,16 +98,13 @@ class _ThumbnailRasterImageState extends State { @override Widget build(BuildContext context) { if (!entry.canDecode) { - return ErrorThumbnail( - entry: entry, - extent: extent, - tooltip: '${entry.mimeType} not supported', - ); + return _buildError(context, '${entry.mimeType} not supported', null); } final fastImage = Image( key: ValueKey('LQ'), image: _fastThumbnailProvider, + errorBuilder: _buildError, width: extent, height: extent, fit: BoxFit.cover, @@ -137,11 +134,7 @@ class _ThumbnailRasterImageState extends State { child: frame == null ? fastImage : child, ); }, - errorBuilder: (context, error, stackTrace) => ErrorThumbnail( - entry: entry, - extent: extent, - tooltip: error.toString(), - ), + errorBuilder: _buildError, width: extent, height: extent, fit: BoxFit.cover, @@ -173,6 +166,12 @@ class _ThumbnailRasterImageState extends State { ); } + Widget _buildError(BuildContext context, Object error, StackTrace stackTrace) => ErrorThumbnail( + entry: entry, + extent: extent, + tooltip: error.toString(), + ); + // when the entry image itself changed (e.g. after rotation) void _onImageChanged() async { // rebuild to refresh the thumbnails diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index e93ddc8bb..ed238e02e 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -1,6 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; +import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; @@ -32,10 +32,10 @@ class ThumbnailVectorImage extends StatelessWidget { UriPicture( uri: entry.uri, mimeType: entry.mimeType, + colorFilter: colorFilter, ), width: extent, height: extent, - colorFilter: colorFilter, ); }, ), diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index d880f4835..d1c1fa12d 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -2,24 +2,28 @@ import 'dart:async'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; -import 'package:aves/model/mime_types.dart'; +import 'package:aves/model/highlight.dart'; +import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/collection/grid/list_sliver.dart'; -import 'package:aves/widgets/collection/grid/scaling.dart'; -import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/scroll_thumb.dart'; -import 'package:aves/widgets/common/sloppy_scroll_physics.dart'; +import 'package:aves/widgets/collection/thumbnail/decorated.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; +import 'package:aves/widgets/common/identity/scroll_thumb.dart'; +import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; +import 'package:aves/widgets/common/scaling.dart'; +import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class ThumbnailCollection extends StatelessWidget { final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); @@ -27,67 +31,88 @@ class ThumbnailCollection extends StatelessWidget { final ValueNotifier _isScrollingNotifier = ValueNotifier(false); final GlobalKey _scrollableKey = GlobalKey(); + static const columnCountMin = 2; + static const columnCountDefault = 4; + static const extentMin = 46.0; + @override Widget build(BuildContext context) { - return SafeArea( - child: Selector>( - selector: (context, mq) => Tuple2(mq.size, mq.padding.horizontal), - builder: (context, mq, child) { - final mqSize = mq.item1; - final mqHorizontalPadding = mq.item2; + return HighlightInfoProvider( + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final viewportSize = constraints.biggest; + assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); + if (viewportSize.isEmpty) return SizedBox.shrink(); - if (mqSize.isEmpty) return SizedBox.shrink(); + final tileExtentManager = TileExtentManager( + settingsRouteKey: context.currentRouteName, + columnCountMin: columnCountMin, + columnCountDefault: columnCountDefault, + extentMin: extentMin, + extentNotifier: _tileExtentNotifier, + spacing: 0, + )..applyTileExtent(viewportSize: viewportSize); + final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; - TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier); - final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2; - - // do not replace by Provider.of - // so that view updates on collection filter changes - return Consumer( - builder: (context, collection, child) { - final scrollView = CollectionScrollView( - scrollableKey: _scrollableKey, - collection: collection, - appBar: CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, + // do not replace by Provider.of + // so that view updates on collection filter changes + return Consumer( + builder: (context, collection, child) { + final scrollView = CollectionScrollView( + scrollableKey: _scrollableKey, collection: collection, - ), - appBarHeightNotifier: _appBarHeightNotifier, - isScrollingNotifier: _isScrollingNotifier, - scrollController: PrimaryScrollController.of(context), - cacheExtent: cacheExtent, - ); - - final scaler = GridScaleGestureDetector( - scrollableKey: _scrollableKey, - appBarHeightNotifier: _appBarHeightNotifier, - extentNotifier: _tileExtentNotifier, - mqSize: mqSize, - mqHorizontalPadding: mqHorizontalPadding, - onScaled: collection.highlight, - child: scrollView, - ); - - final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) => SectionedListLayoutProvider( - collection: collection, - scrollableWidth: mqSize.width - mqHorizontalPadding, - tileExtent: tileExtent, - thumbnailBuilder: (entry) => GridThumbnail( - key: ValueKey(entry.contentId), + appBar: CollectionAppBar( + appBarHeightNotifier: _appBarHeightNotifier, collection: collection, - entry: entry, - tileExtent: tileExtent, - isScrollingNotifier: _isScrollingNotifier, ), - child: scaler, - ), - ); - return sectionedListLayoutProvider; - }, - ); - }, + appBarHeightNotifier: _appBarHeightNotifier, + isScrollingNotifier: _isScrollingNotifier, + scrollController: PrimaryScrollController.of(context), + cacheExtent: cacheExtent, + ); + + final scaler = GridScaleGestureDetector( + tileExtentManager: tileExtentManager, + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + viewportSize: viewportSize, + showScaledGrid: true, + scaledBuilder: (entry, extent) => DecoratedThumbnail( + entry: entry, + extent: extent, + selectable: false, + highlightable: false, + ), + getScaledItemTileRect: (context, entry) { + final sectionedListLayout = Provider.of(context, listen: false); + return sectionedListLayout.getTileRect(entry) ?? Rect.zero; + }, + onScaled: (entry) => Provider.of(context, listen: false).add(entry), + child: scrollView, + ); + + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: _tileExtentNotifier, + builder: (context, tileExtent, child) => SectionedListLayoutProvider( + collection: collection, + scrollableWidth: viewportSize.width, + tileExtent: tileExtent, + thumbnailBuilder: (entry) => GridThumbnail( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + tileExtent: tileExtent, + isScrollingNotifier: _isScrollingNotifier, + ), + child: scaler, + ), + ); + return sectionedListLayoutProvider; + }, + ); + }, + ), ), ); } diff --git a/lib/widgets/common/action_delegates/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart similarity index 97% rename from lib/widgets/common/action_delegates/feedback.dart rename to lib/widgets/common/action_mixins/feedback.dart index f103df3cd..eca65b6c9 100644 --- a/lib/widgets/common/action_delegates/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -1,6 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/services/image_file_service.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; import 'package:flushbar/flushbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -31,7 +31,7 @@ mixin FeedbackMixin { void showOpReport({ @required BuildContext context, - @required List selection, + @required Set selection, @required Stream opStream, @required void Function(Set processed) onDone, }) { diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart similarity index 91% rename from lib/widgets/common/action_delegates/permission_aware.dart rename to lib/widgets/common/action_mixins/permission_aware.dart index 18ba08630..58b896830 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -1,11 +1,10 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/services/android_file_service.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; -import '../aves_dialog.dart'; - mixin PermissionAwareMixin { - Future checkStoragePermission(BuildContext context, Iterable entries) { + Future checkStoragePermission(BuildContext context, Set entries) { return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet()); } @@ -25,6 +24,7 @@ mixin PermissionAwareMixin { context: context, builder: (context) { return AvesDialog( + context: context, title: 'Storage Volume Access', content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'), actions: [ diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart new file mode 100644 index 000000000..d3d7bd18c --- /dev/null +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/android_file_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +mixin SizeAwareMixin { + Future checkFreeSpaceForMove(BuildContext context, Set selection, String destinationAlbum, bool copy) async { + final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); + final free = await AndroidFileService.getFreeSpace(destinationVolume); + int needed; + int sumSize(sum, entry) => sum + entry.sizeBytes; + if (copy) { + needed = selection.fold(0, sumSize); + } else { + // when moving, we only need space for the entries that are not already on the destination volume + final byVolume = groupBy(selection, (entry) => androidFileUtils.getStorageVolume(entry.path)); + final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume); + final fromOtherVolumes = otherVolumes.fold(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize)); + // and we need at least as much space as the largest entry because individual entries are copied then deleted + final largestSingle = selection.fold(0, (largest, entry) => max(largest, entry.sizeBytes)); + needed = max(fromOtherVolumes, largestSingle); + } + + final hasEnoughSpace = needed < free; + if (!hasEnoughSpace) { + await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + title: 'Not Enough Space', + content: Text('This operation needs ${formatFilesize(needed)} of free space on “${destinationVolume.description}” to complete, but there is only ${formatFilesize(free)} left.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('OK'.toUpperCase()), + ), + ], + ); + }, + ); + } + return hasEnoughSpace; + } +} diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 497bf3d69..8b44a7520 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,5 @@ import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; import 'package:flutter/material.dart'; class SourceStateAwareAppBarTitle extends StatelessWidget { diff --git a/lib/widgets/common/aves_dialog.dart b/lib/widgets/common/aves_dialog.dart deleted file mode 100644 index aa3829d67..000000000 --- a/lib/widgets/common/aves_dialog.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -class AvesDialog extends AlertDialog { - static const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); - - AvesDialog({ - String title, - ScrollController scrollController, - List scrollableContent, - Widget content, - @required List actions, - }) : assert((scrollableContent != null) ^ (content != null)), - super( - title: title != null ? DialogTitle(title: title) : null, - titlePadding: EdgeInsets.zero, - // the `scrollable` flag of `AlertDialog` makes it - // scroll both the title and the content together, - // and overflow feedback ignores the dialog shape, - // so we restrict scrolling to the content instead - content: scrollableContent != null - ? Builder( - builder: (context) => Container( - // workaround because the dialog tries - // to size itself to the content intrinsic size, - // but the `ListView` viewport does not have one - width: 1, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context, width: 1), - ), - ), - child: ListView( - controller: scrollController ?? ScrollController(), - shrinkWrap: true, - children: scrollableContent, - ), - ), - ), - ) - : content, - contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 0), - actions: actions, - actionsPadding: EdgeInsets.symmetric(horizontal: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ); -} - -class DialogTitle extends StatelessWidget { - final String title; - - const DialogTitle({@required this.title}); - - @override - Widget build(BuildContext context) { - return Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context, width: 1), - ), - ), - child: Text( - title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'Concourse Caps', - ), - ), - ); - } -} diff --git a/lib/widgets/common/aves_highlight.dart b/lib/widgets/common/aves_highlight.dart new file mode 100644 index 000000000..13df2ac11 --- /dev/null +++ b/lib/widgets/common/aves_highlight.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6 + +/// Highlight Flutter Widget +class AvesHighlightView extends StatelessWidget { + /// The original code to be highlighted + final String source; + + /// Highlight language + /// + /// It is recommended to give it a value for performance + /// + /// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages) + final String language; + + /// Highlight theme + /// + /// [All available themes](https://github.com/pd4d10/highlight/blob/master/flutter_highlight/lib/themes) + final Map theme; + + /// Padding + final EdgeInsetsGeometry padding; + + /// Text styles + /// + /// Specify text styles such as font family and font size + final TextStyle textStyle; + + AvesHighlightView( + String input, { + this.language, + this.theme = const {}, + this.padding, + this.textStyle, + int tabSize = 8, // TODO: https://github.com/flutter/flutter/issues/50087 + }) : source = input.replaceAll('\t', ' ' * tabSize); + + List _convert(List nodes) { + final spans = []; + var currentSpans = spans; + final stack = >[]; + + void _traverse(Node node) { + if (node.value != null) { + currentSpans.add(node.className == null ? TextSpan(text: node.value) : TextSpan(text: node.value, style: theme[node.className])); + } else if (node.children != null) { + final tmp = []; + currentSpans.add(TextSpan(children: tmp, style: theme[node.className])); + stack.add(currentSpans); + currentSpans = tmp; + + node.children.forEach((n) { + _traverse(n); + if (n == node.children.last) { + currentSpans = stack.isEmpty ? spans : stack.removeLast(); + } + }); + } + } + + for (var node in nodes) { + _traverse(node); + } + + return spans; + } + + static const _rootKey = 'root'; + static const _defaultFontColor = Color(0xff000000); + static const _defaultBackgroundColor = Color(0xffffffff); + + // TODO: dart:io is not available at web platform currently + // See: https://github.com/flutter/flutter/issues/39998 + // So we just use monospace here for now + static const _defaultFontFamily = 'monospace'; + + @override + Widget build(BuildContext context) { + var _textStyle = TextStyle( + fontFamily: _defaultFontFamily, + color: theme[_rootKey]?.color ?? _defaultFontColor, + ); + if (textStyle != null) { + _textStyle = _textStyle.merge(textStyle); + } + + return Container( + color: theme[_rootKey]?.backgroundColor ?? _defaultBackgroundColor, + padding: padding, + child: SelectableText.rich( + TextSpan( + style: _textStyle, + children: _convert(highlight.parse(source, language: language).nodes), + ), + ), + ); + } +} diff --git a/lib/widgets/common/labeled_checkbox.dart b/lib/widgets/common/basic/labeled_checkbox.dart similarity index 100% rename from lib/widgets/common/labeled_checkbox.dart rename to lib/widgets/common/basic/labeled_checkbox.dart diff --git a/lib/widgets/common/link_chip.dart b/lib/widgets/common/basic/link_chip.dart similarity index 96% rename from lib/widgets/common/link_chip.dart rename to lib/widgets/common/basic/link_chip.dart index 8b7e801d8..c0f54a506 100644 --- a/lib/widgets/common/link_chip.dart +++ b/lib/widgets/common/basic/link_chip.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/lib/widgets/common/menu_row.dart b/lib/widgets/common/basic/menu_row.dart similarity index 93% rename from lib/widgets/common/menu_row.dart rename to lib/widgets/common/basic/menu_row.dart index 29cf302bb..c14161c84 100644 --- a/lib/widgets/common/menu_row.dart +++ b/lib/widgets/common/basic/menu_row.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:flutter/material.dart'; class MenuRow extends StatelessWidget { diff --git a/lib/widgets/common/fx/outlined_text.dart b/lib/widgets/common/basic/outlined_text.dart similarity index 100% rename from lib/widgets/common/fx/outlined_text.dart rename to lib/widgets/common/basic/outlined_text.dart diff --git a/lib/widgets/common/aves_radio_list_tile.dart b/lib/widgets/common/basic/reselectable_radio_list_tile.dart similarity index 96% rename from lib/widgets/common/aves_radio_list_tile.dart rename to lib/widgets/common/basic/reselectable_radio_list_tile.dart index 424a26b10..f90a4a159 100644 --- a/lib/widgets/common/aves_radio_list_tile.dart +++ b/lib/widgets/common/basic/reselectable_radio_list_tile.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; // `RadioListTile` that can trigger `onChanged` on tap when already selected, if `reselectable` is true -class AvesRadioListTile extends StatelessWidget { +class ReselectableRadioListTile extends StatelessWidget { final T value; final T groupValue; final ValueChanged onChanged; @@ -19,7 +19,7 @@ class AvesRadioListTile extends StatelessWidget { bool get checked => value == groupValue; - const AvesRadioListTile({ + const ReselectableRadioListTile({ Key key, @required this.value, @required this.groupValue, diff --git a/lib/widgets/common/double_back_pop.dart b/lib/widgets/common/behaviour/double_back_pop.dart similarity index 92% rename from lib/widgets/common/double_back_pop.dart rename to lib/widgets/common/behaviour/double_back_pop.dart index dda008461..4a844fdda 100644 --- a/lib/widgets/common/double_back_pop.dart +++ b/lib/widgets/common/behaviour/double_back_pop.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/action_delegates/feedback.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:overlay_support/overlay_support.dart'; diff --git a/lib/utils/route_tracker.dart b/lib/widgets/common/behaviour/route_tracker.dart similarity index 100% rename from lib/utils/route_tracker.dart rename to lib/widgets/common/behaviour/route_tracker.dart diff --git a/lib/widgets/common/routes.dart b/lib/widgets/common/behaviour/routes.dart similarity index 90% rename from lib/widgets/common/routes.dart rename to lib/widgets/common/behaviour/routes.dart index a5ac9d079..03e460480 100644 --- a/lib/widgets/common/routes.dart +++ b/lib/widgets/common/behaviour/routes.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +extension ExtraContext on BuildContext { + String get currentRouteName => ModalRoute.of(this)?.settings?.name; +} + class DirectMaterialPageRoute extends PageRouteBuilder { DirectMaterialPageRoute({ RouteSettings settings, diff --git a/lib/widgets/common/sloppy_scroll_physics.dart b/lib/widgets/common/behaviour/sloppy_scroll_physics.dart similarity index 100% rename from lib/widgets/common/sloppy_scroll_physics.dart rename to lib/widgets/common/behaviour/sloppy_scroll_physics.dart diff --git a/lib/widgets/common/borders.dart b/lib/widgets/common/fx/borders.dart similarity index 100% rename from lib/widgets/common/borders.dart rename to lib/widgets/common/fx/borders.dart diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index 92bf7bd42..9a44bc053 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; diff --git a/lib/widgets/common/transition_image.dart b/lib/widgets/common/fx/transition_image.dart similarity index 100% rename from lib/widgets/common/transition_image.dart rename to lib/widgets/common/fx/transition_image.dart diff --git a/lib/widgets/common/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart similarity index 90% rename from lib/widgets/common/aves_expansion_tile.dart rename to lib/widgets/common/identity/aves_expansion_tile.dart index 9155483ed..7ae1393c6 100644 --- a/lib/widgets/common/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -1,14 +1,16 @@ -import 'package:aves/widgets/common/highlight_title.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; class AvesExpansionTile extends StatelessWidget { final String title; + final Color color; final List children; final ValueNotifier expandedNotifier; const AvesExpansionTile({ @required this.title, + this.color, this.expandedNotifier, @required this.children, }); @@ -27,6 +29,7 @@ class AvesExpansionTile extends StatelessWidget { expandedNotifier: expandedNotifier, title: HighlightTitle( title, + color: color, fontSize: 18, enabled: enabled, ), diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart similarity index 88% rename from lib/widgets/common/aves_filter_chip.dart rename to lib/widgets/common/identity/aves_filter_chip.dart index 314a2a38b..50fd94e47 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; typedef FilterCallback = void Function(CollectionFilter filter); @@ -14,29 +14,33 @@ class AvesFilterChip extends StatefulWidget { final bool showGenericIcon; final Widget background; final Widget details; + final double padding; final HeroType heroType; final FilterCallback onTap; final OffsetFilterCallback onLongPress; + final BorderRadius borderRadius; - static final BorderRadius borderRadius = BorderRadius.circular(32); + static const double defaultRadius = 32; static const double outlineWidth = 2; static const double minChipHeight = kMinInteractiveDimension; static const double minChipWidth = 80; static const double maxChipWidth = 160; static const double iconSize = 20; - static const double padding = 6; const AvesFilterChip({ Key key, - this.filter, + @required this.filter, this.removable = false, this.showGenericIcon = true, this.background, this.details, + this.borderRadius = const BorderRadius.all(Radius.circular(defaultRadius)), + this.padding = 6.0, this.heroType = HeroType.onTap, - @required this.onTap, + this.onTap, this.onLongPress, - }) : super(key: key); + }) : assert(filter != null), + super(key: key); @override _AvesFilterChipState createState() => _AvesFilterChipState(); @@ -50,6 +54,10 @@ class _AvesFilterChipState extends State { CollectionFilter get filter => widget.filter; + BorderRadius get borderRadius => widget.borderRadius; + + double get padding => widget.padding; + @override void initState() { super.initState(); @@ -79,9 +87,11 @@ class _AvesFilterChipState extends State { @override Widget build(BuildContext context) { + const iconSize = AvesFilterChip.iconSize; + final hasBackground = widget.background != null; - final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground); - final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null; + final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground); + final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null; Widget content = Row( mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min, @@ -89,7 +99,7 @@ class _AvesFilterChipState extends State { children: [ if (leading != null) ...[ leading, - SizedBox(width: AvesFilterChip.padding), + SizedBox(width: padding), ], Flexible( child: Text( @@ -100,7 +110,7 @@ class _AvesFilterChipState extends State { ), ), if (trailing != null) ...[ - SizedBox(width: AvesFilterChip.padding), + SizedBox(width: padding), trailing, ], ], @@ -117,7 +127,7 @@ class _AvesFilterChipState extends State { } content = Padding( - padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2), + padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2), child: content, ); @@ -135,8 +145,6 @@ class _AvesFilterChipState extends State { ); } - final borderRadius = AvesFilterChip.borderRadius; - Widget chip = Container( constraints: BoxConstraints( minWidth: AvesFilterChip.minChipWidth, diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/identity/aves_icons.dart similarity index 52% rename from lib/widgets/common/icons.dart rename to lib/widgets/common/identity/aves_icons.dart index 13fd1fa36..c6ed8fb77 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -1,77 +1,12 @@ import 'dart:ui'; +import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; - -class AIcons { - static const IconData allCollection = Icons.collections_outlined; - static const IconData image = Icons.photo_outlined; - static const IconData video = Icons.movie_outlined; - static const IconData audio = Icons.audiotrack_outlined; - static const IconData vector = Icons.code_outlined; - - static const IconData android = Icons.android; - static const IconData checked = Icons.done_outlined; - static const IconData date = Icons.calendar_today_outlined; - static const IconData disc = Icons.fiber_manual_record; - static const IconData error = Icons.error_outline; - static const IconData location = Icons.place_outlined; - static const IconData locationOff = Icons.location_off_outlined; - static const IconData raw = Icons.camera_outlined; - static const IconData shooting = Icons.camera_outlined; - static const IconData removableStorage = Icons.sd_storage_outlined; - static const IconData settings = Icons.settings_outlined; - static const IconData text = Icons.format_quote_outlined; - static const IconData tag = Icons.local_offer_outlined; - static const IconData tagOff = MdiIcons.tagOffOutline; - - // actions - static const IconData addShortcut = Icons.add_to_home_screen_outlined; - static const IconData clear = Icons.clear_outlined; - static const IconData collapse = Icons.expand_less_outlined; - static const IconData createAlbum = Icons.add_circle_outline; - static const IconData debug = Icons.whatshot_outlined; - static const IconData delete = Icons.delete_outlined; - static const IconData expand = Icons.expand_more_outlined; - static const IconData flip = Icons.flip_outlined; - static const IconData favourite = Icons.favorite_border; - static const IconData favouriteActive = Icons.favorite; - static const IconData goUp = Icons.arrow_upward_outlined; - static const IconData group = Icons.group_work_outlined; - static const IconData info = Icons.info_outlined; - static const IconData layers = Icons.layers_outlined; - static const IconData openInNew = Icons.open_in_new_outlined; - static const IconData pin = Icons.push_pin_outlined; - static const IconData print = Icons.print_outlined; - static const IconData refresh = Icons.refresh_outlined; - static const IconData rename = Icons.title_outlined; - static const IconData rotateLeft = Icons.rotate_left_outlined; - static const IconData rotateRight = Icons.rotate_right_outlined; - static const IconData search = Icons.search_outlined; - static const IconData select = Icons.select_all_outlined; - static const IconData share = Icons.share_outlined; - static const IconData sort = Icons.sort_outlined; - static const IconData stats = Icons.pie_chart_outlined; - static const IconData zoomIn = Icons.add_outlined; - static const IconData zoomOut = Icons.remove_outlined; - - // albums - static const IconData album = Icons.photo_album_outlined; - static const IconData cameraAlbum = Icons.photo_camera_outlined; - static const IconData downloadAlbum = Icons.file_download; - static const IconData screenshotAlbum = Icons.smartphone_outlined; - - // thumbnail overlay - static const IconData animated = Icons.slideshow; - static const IconData play = Icons.play_circle_outline; - static const IconData selected = Icons.check_circle_outline; - static const IconData unselected = Icons.radio_button_unchecked; -} class VideoIcon extends StatelessWidget { final ImageEntry entry; diff --git a/lib/widgets/common/aves_logo.dart b/lib/widgets/common/identity/aves_logo.dart similarity index 100% rename from lib/widgets/common/aves_logo.dart rename to lib/widgets/common/identity/aves_logo.dart diff --git a/lib/widgets/common/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart similarity index 91% rename from lib/widgets/common/highlight_title.dart rename to lib/widgets/common/identity/highlight_title.dart index 11c8ba448..f34e69880 100644 --- a/lib/widgets/common/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; class HighlightTitle extends StatelessWidget { final String name; + final Color color; final double fontSize; final bool enabled; const HighlightTitle( this.name, { + this.color, this.fontSize = 20, this.enabled = true, }) : assert(name != null); @@ -21,7 +23,7 @@ class HighlightTitle extends StatelessWidget { alignment: AlignmentDirectional.centerStart, child: Container( decoration: HighlightDecoration( - color: enabled ? stringToColor(name) : disabledColor, + color: enabled ? color ?? stringToColor(name) : disabledColor, ), margin: EdgeInsets.symmetric(vertical: 4.0), child: Text( diff --git a/lib/widgets/common/scroll_thumb.dart b/lib/widgets/common/identity/scroll_thumb.dart similarity index 100% rename from lib/widgets/common/scroll_thumb.dart rename to lib/widgets/common/identity/scroll_thumb.dart diff --git a/lib/widgets/common/providers/highlight_info_provider.dart b/lib/widgets/common/providers/highlight_info_provider.dart new file mode 100644 index 000000000..8b09c1695 --- /dev/null +++ b/lib/widgets/common/providers/highlight_info_provider.dart @@ -0,0 +1,17 @@ +import 'package:aves/model/highlight.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class HighlightInfoProvider extends StatelessWidget { + final Widget child; + + const HighlightInfoProvider({@required this.child}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => HighlightInfo(), + child: child, + ); + } +} diff --git a/lib/widgets/common/data_providers/media_query_data_provider.dart b/lib/widgets/common/providers/media_query_data_provider.dart similarity index 100% rename from lib/widgets/common/data_providers/media_query_data_provider.dart rename to lib/widgets/common/providers/media_query_data_provider.dart diff --git a/lib/widgets/common/data_providers/settings_provider.dart b/lib/widgets/common/providers/settings_provider.dart similarity index 100% rename from lib/widgets/common/data_providers/settings_provider.dart rename to lib/widgets/common/providers/settings_provider.dart diff --git a/lib/widgets/collection/grid/scaling.dart b/lib/widgets/common/scaling.dart similarity index 65% rename from lib/widgets/collection/grid/scaling.dart rename to lib/widgets/common/scaling.dart index 6eaf09931..9f1cc5cb1 100644 --- a/lib/widgets/collection/grid/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -1,48 +1,57 @@ import 'dart:math'; import 'dart:ui' as ui; -import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/collection/grid/list_section_layout.dart'; -import 'package:aves/widgets/collection/grid/list_sliver.dart'; -import 'package:aves/widgets/collection/grid/tile_extent_manager.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:provider/provider.dart'; -class GridScaleGestureDetector extends StatefulWidget { +// metadata to identify entry from RenderObject hit test during collection scaling +class ScalerMetadata { + final T item; + + const ScalerMetadata(this.item); +} + +class GridScaleGestureDetector extends StatefulWidget { + final TileExtentManager tileExtentManager; final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; - final ValueNotifier extentNotifier; - final Size mqSize; - final double mqHorizontalPadding; - final void Function(ImageEntry entry) onScaled; + final Size viewportSize; + final bool showScaledGrid; + final Widget Function(T item, double extent) scaledBuilder; + final Rect Function(BuildContext context, T item) getScaledItemTileRect; + final void Function(T item) onScaled; final Widget child; const GridScaleGestureDetector({ - this.scrollableKey, + @required this.tileExtentManager, + @required this.scrollableKey, @required this.appBarHeightNotifier, - @required this.extentNotifier, - @required this.mqSize, - @required this.mqHorizontalPadding, - this.onScaled, + @required this.viewportSize, + @required this.showScaledGrid, + @required this.scaledBuilder, + @required this.getScaledItemTileRect, + @required this.onScaled, @required this.child, }); @override - _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); + _GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState(); } -class _GridScaleGestureDetectorState extends State { +class _GridScaleGestureDetectorState extends State> { double _startExtent, _extentMin, _extentMax; bool _applyingScale = false; ValueNotifier _scaledExtentNotifier; OverlayEntry _overlayEntry; - ThumbnailMetadata _metadata; + ScalerMetadata _metadata; - ValueNotifier get tileExtentNotifier => widget.extentNotifier; + TileExtentManager get tileExtentManager => widget.tileExtentManager; + + Size get viewportSize => widget.viewportSize; @override Widget build(BuildContext context) { @@ -62,29 +71,31 @@ class _GridScaleGestureDetectorState extends State { scrollableBox.hitTest(result, position: details.localFocalPoint); // find `RenderObject`s at the gesture focal point - T firstOf(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T; + U firstOf(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is U, orElse: () => null)?.target as U; final renderMetaData = firstOf(result); // abort if we cannot find an image to show on overlay if (renderMetaData == null) return; _metadata = renderMetaData.metaData; - _startExtent = tileExtentNotifier.value; + _startExtent = renderMetaData.size.width; _scaledExtentNotifier = ValueNotifier(_startExtent); // not the same as `MediaQuery.size.width`, because of screen insets/padding final gridWidth = scrollableBox.size.width; - _extentMin = gridWidth / (gridWidth / TileExtentManager.tileExtentMin).round(); - _extentMax = gridWidth / (gridWidth / TileExtentManager.extentMaxForSize(widget.mqSize)).round(); + + _extentMin = tileExtentManager.getEffectiveExtentMin(viewportSize); + _extentMax = tileExtentManager.getEffectiveExtentMax(viewportSize); + final halfExtent = _startExtent / 2; final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent)); _overlayEntry = OverlayEntry( - builder: (context) { - return ScaleOverlay( - imageEntry: _metadata.entry, - center: thumbnailCenter, - gridWidth: gridWidth, - scaledExtentNotifier: _scaledExtentNotifier, - ); - }, + builder: (context) => ScaleOverlay( + builder: (extent) => widget.scaledBuilder(_metadata.item, extent), + center: thumbnailCenter, + gridWidth: gridWidth, + spacing: tileExtentManager.spacing, + scaledExtentNotifier: _scaledExtentNotifier, + showScaledGrid: widget.showScaledGrid, + ), ); Overlay.of(scrollableContext).insert(_overlayEntry); }, @@ -101,13 +112,11 @@ class _GridScaleGestureDetectorState extends State { } _applyingScale = true; - final oldExtent = tileExtentNotifier.value; + final oldExtent = tileExtentManager.extentNotifier.value; // sanitize and update grid layout if necessary - final newExtent = TileExtentManager.applyTileExtent( - widget.mqSize, - widget.mqHorizontalPadding, - tileExtentNotifier, - newExtent: _scaledExtentNotifier.value, + final newExtent = tileExtentManager.applyTileExtent( + viewportSize: widget.viewportSize, + userPreferredExtent: _scaledExtentNotifier.value, ); _scaledExtentNotifier = null; if (newExtent == oldExtent) { @@ -115,8 +124,8 @@ class _GridScaleGestureDetectorState extends State { } else { // scroll to show the focal point thumbnail at its new position WidgetsBinding.instance.addPostFrameCallback((_) { - final entry = _metadata.entry; - _scrollToEntry(entry); + final entry = _metadata.item; + _scrollToItem(entry); // warning: posting `onScaled` in the next frame with `addPostFrameCallback` // would trigger only when the scrollable offset actually changes Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled?.call(entry)); @@ -132,11 +141,10 @@ class _GridScaleGestureDetectorState extends State { // `Scrollable.ensureVisible` only works on already rendered objects // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` // `RenderViewport.scrollOffsetOf` is a good alternative - void _scrollToEntry(ImageEntry entry) { + void _scrollToItem(T item) { final scrollableContext = widget.scrollableKey.currentContext; final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; - final sectionedListLayout = Provider.of(context, listen: false); - final tileRect = sectionedListLayout.getTileRect(entry) ?? Rect.zero; + final tileRect = widget.getScaledItemTileRect(context, item); // most of the time the app bar will be scrolled away after scaling, // so we compensate for it to center the focal point thumbnail final appBarHeight = widget.appBarHeightNotifier.value; @@ -147,16 +155,20 @@ class _GridScaleGestureDetectorState extends State { } class ScaleOverlay extends StatefulWidget { - final ImageEntry imageEntry; + final Widget Function(double extent) builder; final Offset center; final double gridWidth; + final double spacing; final ValueNotifier scaledExtentNotifier; + final bool showScaledGrid; const ScaleOverlay({ - @required this.imageEntry, + @required this.builder, @required this.center, @required this.gridWidth, + @required this.spacing, @required this.scaledExtentNotifier, + @required this.showScaledGrid, }); @override @@ -216,28 +228,30 @@ class _ScaleOverlayState extends State { } final clampedCenter = center.translate(dx, 0); - return CustomPaint( - painter: GridPainter( - center: clampedCenter, - extent: extent, - ), - child: Stack( - children: [ - Positioned( - left: clampedCenter.dx - extent / 2, - top: clampedCenter.dy - extent / 2, - child: DefaultTextStyle( - style: TextStyle(), - child: DecoratedThumbnail( - entry: widget.imageEntry, - extent: extent, - showOverlay: false, - ), - ), + var child = widget.builder(extent); + child = Stack( + children: [ + Positioned( + left: clampedCenter.dx - extent / 2, + top: clampedCenter.dy - extent / 2, + child: DefaultTextStyle( + style: TextStyle(), + child: child, ), - ], - ), + ), + ], ); + if (widget.showScaledGrid) { + child = CustomPaint( + painter: GridPainter( + center: clampedCenter, + extent: extent, + spacing: widget.spacing, + ), + child: child, + ); + } + return child; }, ), ), @@ -248,11 +262,12 @@ class _ScaleOverlayState extends State { class GridPainter extends CustomPainter { final Offset center; - final double extent; + final double extent, spacing; const GridPainter({ @required this.center, @required this.extent, + @required this.spacing, }); @override @@ -261,7 +276,7 @@ class GridPainter extends CustomPainter { ..strokeWidth = DecoratedThumbnail.borderWidth ..shader = ui.Gradient.radial( center, - size.width / 2, + size.width * .7, [ DecoratedThumbnail.borderColor, Colors.transparent, @@ -271,10 +286,18 @@ class GridPainter extends CustomPainter { 1, ], ); + void draw(Offset topLeft) { + for (var i = -2; i <= 3; i++) { + final ref = (extent + spacing) * i; + canvas.drawLine(Offset(0, topLeft.dy + ref), Offset(size.width, topLeft.dy + ref), paint); + canvas.drawLine(Offset(topLeft.dx + ref, 0), Offset(topLeft.dx + ref, size.height), paint); + } + } + final topLeft = center.translate(-extent / 2, -extent / 2); - for (var i = -1; i <= 2; i++) { - canvas.drawLine(Offset(0, topLeft.dy + extent * i), Offset(size.width, topLeft.dy + extent * i), paint); - canvas.drawLine(Offset(topLeft.dx + extent * i, 0), Offset(topLeft.dx + extent * i, size.height), paint); + draw(topLeft); + if (spacing > 0) { + draw(topLeft.translate(-spacing, -spacing)); } } diff --git a/lib/widgets/common/tile_extent_manager.dart b/lib/widgets/common/tile_extent_manager.dart new file mode 100644 index 000000000..5001f84a2 --- /dev/null +++ b/lib/widgets/common/tile_extent_manager.dart @@ -0,0 +1,70 @@ +import 'dart:math'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:flutter/widgets.dart'; + +class TileExtentManager { + final String settingsRouteKey; + final int columnCountMin, columnCountDefault; + final double spacing, extentMin; + final ValueNotifier extentNotifier; + + const TileExtentManager({ + @required this.settingsRouteKey, + @required this.columnCountMin, + @required this.columnCountDefault, + @required this.extentMin, + @required this.extentNotifier, + @required this.spacing, + }); + + double applyTileExtent({ + @required Size viewportSize, + double userPreferredExtent = 0, + }) { + // sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size) + final viewportSizeMin = Size.square(extentMin * columnCountMin); + viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height)); + + final oldUserPreferredExtent = settings.getTileExtent(settingsRouteKey); + final currentExtent = extentNotifier.value; + final targetExtent = userPreferredExtent > 0 + ? userPreferredExtent + : oldUserPreferredExtent > 0 + ? oldUserPreferredExtent + : currentExtent; + + final columnCount = getEffectiveColumnCountForExtent(viewportSize, targetExtent); + final newExtent = _extentForColumnCount(viewportSize, columnCount); + + if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) { + settings.setTileExtent(settingsRouteKey, newExtent); + } + if (extentNotifier.value != newExtent) { + extentNotifier.value = newExtent; + } + return newExtent; + } + + double _extentMax(Size viewportSize) => (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin; + + double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing); + + double _extentForColumnCount(Size viewportSize, int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount; + + int _effectiveColumnCountMin(Size viewportSize) => _columnCountForExtent(viewportSize, _extentMax(viewportSize)).ceil(); + + int _effectiveColumnCountMax(Size viewportSize) => _columnCountForExtent(viewportSize, extentMin).floor(); + + double getEffectiveExtentMin(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMax(viewportSize)); + + double getEffectiveExtentMax(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMin(viewportSize)); + + int getEffectiveColumnCountForExtent(Size viewportSize, double extent) { + if (extent > 0) { + final columnCount = _columnCountForExtent(viewportSize, extent); + return columnCount.clamp(_effectiveColumnCountMin(viewportSize), _effectiveColumnCountMax(viewportSize)).round(); + } + return columnCountDefault; + } +} diff --git a/lib/widgets/debug/android_env.dart b/lib/widgets/debug/android_env.dart index cb0fa1aa5..e590c9cfe 100644 --- a/lib/widgets/debug/android_env.dart +++ b/lib/widgets/debug/android_env.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 2ae163bf1..a3c609ab7 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; diff --git a/lib/widgets/debug/cache.dart b/lib/widgets/debug/cache.dart index eeb85e308..09b8e9aa3 100644 --- a/lib/widgets/debug/cache.dart +++ b/lib/widgets/debug/cache.dart @@ -1,6 +1,6 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/file_utils.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 5c8f9ff19..bcf80693b 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -3,7 +3,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/utils/file_utils.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:flutter/material.dart'; class DebugAppDatabaseSection extends StatefulWidget { diff --git a/lib/widgets/debug/firebase.dart b/lib/widgets/debug/firebase.dart index f98d14a91..9551cbfe1 100644 --- a/lib/widgets/debug/firebase.dart +++ b/lib/widgets/debug/firebase.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index 36e68c18f..d3a1a7ce4 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -1,5 +1,9 @@ import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/countries_page.dart'; +import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -27,7 +31,10 @@ class DebugSettingsSection extends StatelessWidget { Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup({ - 'collectionTileExtent': '${settings.collectionTileExtent}', + 'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}', + 'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}', + 'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}', + 'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}', 'infoMapZoom': '${settings.infoMapZoom}', 'pinnedFilters': toMultiline(settings.pinnedFilters), 'searchHistory': toMultiline(settings.searchHistory), diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index 804f2f1d1..2c22b29f9 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -1,32 +1,59 @@ +import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; -class DebugStorageSection extends StatelessWidget { +class DebugStorageSection extends StatefulWidget { + @override + _DebugStorageSectionState createState() => _DebugStorageSectionState(); +} + +class _DebugStorageSectionState extends State with AutomaticKeepAliveClientMixin { + final Map _freeSpaceByVolume = {}; + + @override + void initState() { + super.initState(); + androidFileUtils.storageVolumes.forEach((volume) async { + final byteCount = await AndroidFileService.getFreeSpace(volume); + setState(() => _freeSpaceByVolume[volume.path] = byteCount); + }); + } + @override Widget build(BuildContext context) { + super.build(context); + return AvesExpansionTile( title: 'Storage Volumes', children: [ - ...androidFileUtils.storageVolumes.expand((v) => [ - Padding( - padding: EdgeInsets.all(8), - child: Text(v.path), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: InfoRowGroup({ - 'description': '${v.description}', - 'isEmulated': '${v.isEmulated}', - 'isPrimary': '${v.isPrimary}', - 'isRemovable': '${v.isRemovable}', - 'state': '${v.state}', - }), - ), - Divider(), - ]) + ...androidFileUtils.storageVolumes.expand((v) { + final freeSpace = _freeSpaceByVolume[v.path]; + return [ + Padding( + padding: EdgeInsets.all(8), + child: Text(v.path), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: InfoRowGroup({ + 'description': '${v.description}', + 'isEmulated': '${v.isEmulated}', + 'isPrimary': '${v.isPrimary}', + 'isRemovable': '${v.isRemovable}', + 'state': '${v.state}', + if (freeSpace != null) 'freeSpace': formatFilesize(freeSpace), + }), + ), + Divider(), + ]; + }) ], ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/widgets/common/action_delegates/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart similarity index 97% rename from lib/widgets/common/action_delegates/add_shortcut_dialog.dart rename to lib/widgets/dialogs/add_shortcut_dialog.dart index f17316720..b321ec0c1 100644 --- a/lib/widgets/common/action_delegates/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:flutter/material.dart'; -import '../aves_dialog.dart'; +import 'aves_dialog.dart'; class AddShortcutDialog extends StatefulWidget { final Set filters; @@ -37,6 +37,7 @@ class _AddShortcutDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( + context: context, content: TextField( controller: _nameController, decoration: InputDecoration( diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart new file mode 100644 index 000000000..adf8898f8 --- /dev/null +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class AvesDialog extends AlertDialog { + static const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); + static const borderWidth = 1.0; + + AvesDialog({ + @required BuildContext context, + String title, + ScrollController scrollController, + List scrollableContent, + Widget content, + @required List actions, + }) : assert((scrollableContent != null) ^ (content != null)), + super( + title: title != null + ? Padding( + // padding to avoid transparent border overlapping + padding: EdgeInsets.symmetric(horizontal: borderWidth), + child: DialogTitle(title: title), + ) + : null, + titlePadding: EdgeInsets.zero, + // the `scrollable` flag of `AlertDialog` makes it + // scroll both the title and the content together, + // and overflow feedback ignores the dialog shape, + // so we restrict scrolling to the content instead + content: scrollableContent != null + ? Container( + // padding to avoid transparent border overlapping + padding: EdgeInsets.symmetric(horizontal: borderWidth), + // workaround because the dialog tries + // to size itself to the content intrinsic size, + // but the `ListView` viewport does not have one + width: 1, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: borderWidth), + ), + ), + child: ListView( + controller: scrollController ?? ScrollController(), + shrinkWrap: true, + children: scrollableContent, + ), + ), + ) + : content, + contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(24, 20, 24, 0), + actions: actions, + actionsPadding: EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + side: Divider.createBorderSide(context, width: borderWidth), + borderRadius: BorderRadius.circular(24), + ), + ); +} + +class DialogTitle extends StatelessWidget { + final String title; + + const DialogTitle({@required this.title}); + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth), + ), + ), + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Concourse Caps', + ), + ), + ); + } +} + +void showNoMatchingAppDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + title: 'No Matching App', + content: Text('There are no apps that can handle this.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('OK'.toUpperCase()), + ), + ], + ); + }, + ); +} diff --git a/lib/widgets/common/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart similarity index 92% rename from lib/widgets/common/aves_selection_dialog.dart rename to lib/widgets/dialogs/aves_selection_dialog.dart index 5c32ca2e6..c4e5c7e4b 100644 --- a/lib/widgets/common/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -1,4 +1,4 @@ -import 'package:aves/widgets/common/aves_radio_list_tile.dart'; +import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -35,6 +35,7 @@ class _AvesSelectionDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( + context: context, title: widget.title, scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(), actions: [ @@ -47,7 +48,7 @@ class _AvesSelectionDialogState extends State { } Widget _buildRadioListTile(T value, String title) { - return AvesRadioListTile( + return ReselectableRadioListTile( key: Key(value.toString()), value: value, groupValue: _selectedValue, diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart similarity index 98% rename from lib/widgets/common/action_delegates/create_album_dialog.dart rename to lib/widgets/dialogs/create_album_dialog.dart index 498bb4edf..0b746a5c3 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -1,12 +1,12 @@ import 'dart:io'; +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/durations.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:path/path.dart'; -import '../aves_dialog.dart'; +import 'aves_dialog.dart'; class CreateAlbumDialog extends StatefulWidget { @override @@ -41,6 +41,7 @@ class _CreateAlbumDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( + context: context, title: 'New Album', scrollController: _scrollController, scrollableContent: [ diff --git a/lib/widgets/common/action_delegates/rename_album_dialog.dart b/lib/widgets/dialogs/rename_album_dialog.dart similarity index 97% rename from lib/widgets/common/action_delegates/rename_album_dialog.dart rename to lib/widgets/dialogs/rename_album_dialog.dart index 7679fb620..f9e3f10f4 100644 --- a/lib/widgets/common/action_delegates/rename_album_dialog.dart +++ b/lib/widgets/dialogs/rename_album_dialog.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; -import '../aves_dialog.dart'; +import '../dialogs/aves_dialog.dart'; class RenameAlbumDialog extends StatefulWidget { final String album; @@ -39,6 +39,7 @@ class _RenameAlbumDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( + context: context, content: ValueListenableBuilder( valueListenable: _existsNotifier, builder: (context, exists, child) { diff --git a/lib/widgets/common/action_delegates/rename_entry_dialog.dart b/lib/widgets/dialogs/rename_entry_dialog.dart similarity index 97% rename from lib/widgets/common/action_delegates/rename_entry_dialog.dart rename to lib/widgets/dialogs/rename_entry_dialog.dart index 87d258fc5..b28e25730 100644 --- a/lib/widgets/common/action_delegates/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/rename_entry_dialog.dart @@ -4,7 +4,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; -import '../aves_dialog.dart'; +import 'aves_dialog.dart'; class RenameEntryDialog extends StatefulWidget { final ImageEntry entry; @@ -37,6 +37,7 @@ class _RenameEntryDialogState extends State { @override Widget build(BuildContext context) { return AvesDialog( + context: context, content: TextField( controller: _nameController, decoration: InputDecoration( diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index c09791276..1daea1536 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -3,15 +3,16 @@ import 'dart:ui'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; -import 'package:aves/model/mime_types.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/about/about_page.dart'; -import 'package:aves/widgets/common/aves_logo.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_icons.dart'; +import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; import 'package:aves/widgets/drawer/collection_tile.dart'; import 'package:aves/widgets/drawer/tile.dart'; @@ -38,37 +39,30 @@ class _AppDrawerState extends State { @override Widget build(BuildContext context) { final header = Container( - decoration: BoxDecoration( - border: Border( - bottom: Divider.createBorderSide(context), - ), - ), - child: Container( - padding: EdgeInsets.all(16), - color: Theme.of(context).accentColor, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Wrap( - spacing: 16, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - AvesLogo(size: 64), - Text( - 'Aves', - style: TextStyle( - fontSize: 44, - fontFamily: 'Concourse Caps', - ), + padding: EdgeInsets.all(16), + color: Theme.of(context).accentColor, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Wrap( + spacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AvesLogo(size: 64), + Text( + 'Aves', + style: TextStyle( + fontSize: 44, + fontFamily: 'Concourse Caps', ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/drawer/collection_tile.dart b/lib/widgets/drawer/collection_tile.dart index 9cff6347f..e255a8059 100644 --- a/lib/widgets/drawer/collection_tile.dart +++ b/lib/widgets/drawer/collection_tile.dart @@ -51,7 +51,7 @@ class CollectionNavTile extends StatelessWidget { sortFactor: settings.collectionSortFactor, )), ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), + (route) => false, ); } } diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index 6b044cd98..b2bbb7614 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -1,7 +1,6 @@ import 'dart:ui'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/utils/flutter_utils.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -52,7 +51,7 @@ class NavTile extends StatelessWidget { Navigator.pushAndRemoveUntil( context, route, - settings.navRemoveRoutePredicate(routeName), + (route) => false, ); } else { Navigator.push(context, route); diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart new file mode 100644 index 000000000..2828a8266 --- /dev/null +++ b/lib/widgets/filter_grids/album_pick.dart @@ -0,0 +1,200 @@ +import 'package:aves/model/actions/chip_actions.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/dialogs/create_album_dialog.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.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/widgets.dart'; +import 'package:provider/provider.dart'; + +class AlbumPickPage extends StatefulWidget { + static const routeName = '/album_pick'; + + final CollectionSource source; + final bool copy; + + const AlbumPickPage({ + @required this.source, + @required this.copy, + }); + + @override + _AlbumPickPageState createState() => _AlbumPickPageState(); +} + +class _AlbumPickPageState extends State { + final _queryNotifier = ValueNotifier(''); + + CollectionSource get source => widget.source; + + @override + Widget build(BuildContext context) { + Widget appBar = AlbumPickAppBar( + copy: widget.copy, + actionDelegate: AlbumChipSetActionDelegate(source: source), + queryNotifier: _queryNotifier, + ); + + return Selector( + selector: (context, s) => s.albumSortFactor, + builder: (context, sortFactor, child) { + return FilterGridPage( + source: source, + appBar: appBar, + filterEntries: AlbumListPage.getAlbumEntries(source), + applyQuery: (filters, query) { + if (query == null || query.isEmpty) return filters; + query = query.toUpperCase(); + return filters.where((filter) => filter.uniqueName.toUpperCase().contains(query)).toList(); + }, + queryNotifier: _queryNotifier, + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: 'No albums', + ), + settingsRouteKey: AlbumListPage.routeName, + appBarHeight: AlbumPickAppBar.preferredHeight, + onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), + ); + }, + ); + } +} + +class AlbumPickAppBar extends StatelessWidget { + final bool copy; + final AlbumChipSetActionDelegate actionDelegate; + final ValueNotifier queryNotifier; + + static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight; + + const AlbumPickAppBar({ + @required this.copy, + @required this.actionDelegate, + @required this.queryNotifier, + }); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + leading: BackButton(), + title: Text(copy ? 'Copy to Album' : 'Move to Album'), + bottom: AlbumFilterBar( + filterNotifier: queryNotifier, + ), + actions: [ + IconButton( + icon: Icon(AIcons.createAlbum), + onPressed: () async { + final newAlbum = await showDialog( + context: context, + builder: (context) => CreateAlbumDialog(), + ); + if (newAlbum != null && newAlbum.isNotEmpty) { + Navigator.pop(context, newAlbum); + } + }, + tooltip: 'Create album', + ), + IconButton( + icon: Icon(AIcons.sort), + onPressed: () => actionDelegate.onActionSelected(context, ChipSetAction.sort), + tooltip: 'Sort…', + ), + ], + floating: true, + ); + } +} + +class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget { + final ValueNotifier filterNotifier; + + static const preferredHeight = kToolbarHeight; + + const AlbumFilterBar({@required this.filterNotifier}); + + @override + Size get preferredSize => Size.fromHeight(preferredHeight); + + @override + _AlbumFilterBarState createState() => _AlbumFilterBarState(); +} + +class _AlbumFilterBarState extends State { + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + TextEditingController _controller; + + ValueNotifier get filterNotifier => widget.filterNotifier; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: filterNotifier.value); + } + + @override + Widget build(BuildContext context) { + final clearButton = IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + _controller.clear(); + filterNotifier.value = ''; + }, + tooltip: 'Clear', + ); + return Container( + height: AlbumFilterBar.preferredHeight, + alignment: Alignment.topCenter, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon(AIcons.search), + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + icon: Padding( + padding: EdgeInsetsDirectional.only(start: 16), + child: Icon(AIcons.search), + ), + // border: OutlineInputBorder(), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, + ), + textInputAction: TextInputAction.search, + onChanged: (s) => _debouncer(() => filterNotifier.value = s), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(minWidth: 16), + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) => AnimatedSwitcher( + duration: Durations.appBarActionChangeAnimation, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: child, + ), + ), + child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index b10093d0e..4c78eac83 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; @@ -5,13 +6,12 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -33,7 +33,7 @@ class AlbumListPage extends StatelessWidget { animation: androidFileUtils.appNameChangeNotifier, builder: (context, child) => StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( + builder: (context, snapshot) => FilterNavigationPage( source: source, title: 'Albums', chipSetActionDelegate: AlbumChipSetActionDelegate(source: source), @@ -44,7 +44,6 @@ class AlbumListPage extends StatelessWidget { ChipAction.delete, ], filterEntries: getAlbumEntries(source), - filterBuilder: (album) => AlbumFilter(album, source.getUniqueAlbumName(album)), emptyBuilder: () => EmptyContent( icon: AIcons.album, text: 'No albums', @@ -58,52 +57,53 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static Map getAlbumEntries(CollectionSource source) { - final pinned = settings.pinnedFilters.whereType().map((f) => f.album); + static Map getAlbumEntries(CollectionSource source) { + final pinned = settings.pinnedFilters.whereType(); final entriesByDate = source.sortedEntriesForFilterList; + AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album)); + // albums are initially sorted by name at the source level - var sortedAlbums = source.sortedAlbums; + var sortedFilters = source.sortedAlbums.map(_buildFilter); if (settings.albumSortFactor == ChipSortFactor.name) { - final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; - for (var album in sortedAlbums) { - if (pinned.contains(album)) { - pinnedAlbums.add(album); + final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; + for (var filter in sortedFilters) { + if (pinned.contains(filter)) { + pinnedAlbums.add(filter); } else { - switch (androidFileUtils.getAlbumType(album)) { + switch (androidFileUtils.getAlbumType(filter.album)) { case AlbumType.regular: - regularAlbums.add(album); + regularAlbums.add(filter); break; case AlbumType.app: - appAlbums.add(album); + appAlbums.add(filter); break; default: - specialAlbums.add(album); + specialAlbums.add(filter); break; } } } - return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) { + return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((filter) { return MapEntry( - album, - entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), + filter, + entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null), ); })); } if (settings.albumSortFactor == ChipSortFactor.count) { - CollectionFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album)); - var filtersWithCount = List.of(sortedAlbums.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedAlbums = filtersWithCount.map((kv) => kv.key).toList(); + sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); } - final allMapEntries = sortedAlbums.map((album) => MapEntry( - album, - entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), + final allMapEntries = sortedFilters.map((filter) => MapEntry( + filter, + entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null), )); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 135443064..15fec7a8c 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -1,25 +1,20 @@ +import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_file_service.dart'; -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:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart' as path; -import 'package:pedantic/pedantic.dart'; class ChipActionDelegate { - Future onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - + void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { switch (action) { case ChipAction.pin: settings.pinnedFilters = settings.pinnedFilters..add(filter); @@ -33,7 +28,7 @@ class ChipActionDelegate { } } -class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin { +class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionSource source; AlbumChipActionDelegate({ @@ -41,14 +36,14 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per }); @override - Future onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async { - await super.onActionSelected(context, filter, action); + void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { + super.onActionSelected(context, filter, action); switch (action) { case ChipAction.delete: - unawaited(_showDeleteDialog(context, filter as AlbumFilter)); + _showDeleteDialog(context, filter as AlbumFilter); break; case ChipAction.rename: - unawaited(_showRenameDialog(context, filter as AlbumFilter)); + _showRenameDialog(context, filter as AlbumFilter); break; default: break; @@ -56,13 +51,14 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } Future _showDeleteDialog(BuildContext context, AlbumFilter filter) async { - final selection = source.rawEntries.where(filter.filter).toList(); + final selection = source.rawEntries.where(filter.filter).toSet(); final count = selection.length; final confirmed = await showDialog( context: context, builder: (context) { return AvesDialog( + context: context, content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'), actions: [ TextButton( @@ -110,9 +106,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final selection = source.rawEntries.where(filter.filter).toList(); + final selection = source.rawEntries.where(filter.filter).toSet(); final destinationAlbum = path.join(path.dirname(album), newName); + if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, false)) return; + showOpReport( context: context, selection: selection, diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 29911ebd0..4e2db7de1 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -1,15 +1,10 @@ +import 'package:aves/model/actions/chip_actions.dart'; 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/dialogs/aves_selection_dialog.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; @@ -18,19 +13,13 @@ abstract class ChipSetActionDelegate { set sortFactor(ChipSortFactor factor); - Future onActionSelected(BuildContext context, ChipSetAction action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - + void onActionSelected(BuildContext context, ChipSetAction action) { switch (action) { case ChipSetAction.sort: - await _showSortDialog(context); + _showSortDialog(context); break; case ChipSetAction.refresh: - if (source is MediaStoreSource) { - source.clearEntries(); - unawaited((source as MediaStoreSource).refresh()); - } + source.refresh(); break; case ChipSetAction.stats: _goToStats(context); @@ -62,11 +51,7 @@ abstract class ChipSetActionDelegate { MaterialPageRoute( settings: RouteSettings(name: StatsPage.routeName), builder: (context) => StatsPage( - collection: CollectionLens( - source: source, - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - ), + source: source, ), ), ); diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index 9bfb5b714..385b4a37b 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -1,16 +1,19 @@ +import 'dart:math'; import 'dart:ui'; 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/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/collection/thumbnail/vector.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/common/overlay.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; @@ -18,7 +21,8 @@ class DecoratedFilterChip extends StatelessWidget { final CollectionSource source; final CollectionFilter filter; final ImageEntry entry; - final bool pinned; + final double extent; + final bool pinned, highlightable; final FilterCallback onTap; final OffsetFilterCallback onLongPress; @@ -27,8 +31,10 @@ class DecoratedFilterChip extends StatelessWidget { @required this.source, @required this.filter, @required this.entry, + @required this.extent, this.pinned = false, - @required this.onTap, + this.highlightable = true, + this.onTap, this.onLongPress, }) : super(key: key); @@ -39,51 +45,78 @@ class DecoratedFilterChip extends StatelessWidget { : entry.isSvg ? ThumbnailVectorImage( entry: entry, - extent: FilterGridPage.maxCrossAxisExtent, + extent: extent, ) : ThumbnailRasterImage( entry: entry, - extent: FilterGridPage.maxCrossAxisExtent, + extent: extent, ); - return AvesFilterChip( + final radius = min(AvesFilterChip.defaultRadius, extent / 4); + final titlePadding = min(6.0, extent / 16); + final borderRadius = BorderRadius.all(Radius.circular(radius)); + Widget child = AvesFilterChip( filter: filter, showGenericIcon: false, background: backgroundImage, details: _buildDetails(filter), + borderRadius: borderRadius, + padding: titlePadding, onTap: onTap, onLongPress: onLongPress, ); + + child = Stack( + fit: StackFit.passthrough, + children: [ + child, + if (highlightable) + ChipHighlightOverlay( + filter: filter, + extent: extent, + borderRadius: borderRadius, + ), + ], + ); + + return child; } Widget _buildDetails(CollectionFilter filter) { - final count = Text( - '${source.count(filter)}', - style: TextStyle(color: FilterGridPage.detailColor), - ); + final padding = min(8.0, extent / 16); + final iconSize = min(14.0, extent / 8); + final fontSize = min(14.0, extent / 6); return Row( mainAxisSize: MainAxisSize.min, children: [ if (pinned) - Padding( - padding: EdgeInsets.only(right: 8), + AnimatedPadding( + padding: EdgeInsets.only(right: padding), child: DecoratedIcon( AIcons.pin, color: FilterGridPage.detailColor, shadows: [Constants.embossShadow], - size: 16, + size: iconSize, ), + duration: Durations.chipDecorationAnimation, ), if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)) - Padding( - padding: EdgeInsets.only(right: 8), + AnimatedPadding( + padding: EdgeInsets.only(right: padding), + duration: Durations.chipDecorationAnimation, child: DecoratedIcon( AIcons.removableStorage, color: FilterGridPage.detailColor, shadows: [Constants.embossShadow], - size: 16, + size: iconSize, ), ), - count, + Text( + '${source.count(filter)}', + style: TextStyle( + color: FilterGridPage.detailColor, + fontSize: fontSize, + ), + ), ], ); } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 3ecf62bf7..c6a3a7862 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -1,204 +1,136 @@ import 'dart:ui'; -import 'package:aves/main.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/image_entry.dart'; 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/utils/durations.dart'; -import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/app_bar_subtitle.dart'; -import 'package:aves/widgets/common/app_bar_title.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/double_back_pop.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/menu_row.dart'; -import 'package:aves/widgets/common/scroll_thumb.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/scroll_thumb.dart'; +import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/scaling.dart'; +import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; -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:aves/widgets/search/search_button.dart'; -import 'package:aves/widgets/search/search_delegate.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'; import 'package:provider/provider.dart'; -class FilterNavigationPage extends StatelessWidget { - final CollectionSource source; - final String title; - final ChipSetActionDelegate chipSetActionDelegate; - final ChipActionDelegate chipActionDelegate; - final Map filterEntries; - final CollectionFilter Function(String key) filterBuilder; - final Widget Function() emptyBuilder; - final List Function(CollectionFilter filter) chipActionsBuilder; - - const FilterNavigationPage({ - @required this.source, - @required this.title, - @required this.chipSetActionDelegate, - @required this.chipActionDelegate, - @required this.chipActionsBuilder, - @required this.filterEntries, - @required this.filterBuilder, - @required this.emptyBuilder, - }); - - @override - Widget build(BuildContext context) { - return FilterGridPage( - source: source, - appBar: SliverAppBar( - title: TappableAppBarTitle( - onTap: () => _goToSearch(context), - child: SourceStateAwareAppBarTitle( - title: Text(title), - source: source, - ), - ), - actions: _buildActions(context), - titleSpacing: 0, - floating: true, - ), - filterEntries: filterEntries, - filterBuilder: filterBuilder, - emptyBuilder: () => ValueListenableBuilder( - valueListenable: source.stateNotifier, - builder: (context, sourceState, child) { - return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink(); - }, - ), - onTap: (filter) => Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - settings: RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage(CollectionLens( - source: source, - filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - )), - ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), - ), - onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null, - ); - } - - Future _showMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async { - final RenderBox overlay = Overlay.of(context).context.findRenderObject(); - final touchArea = Size(40, 40); - final selectedAction = await showMenu( - context: context, - position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), - items: chipActionsBuilder(filter) - .map((action) => PopupMenuItem( - value: action, - child: MenuRow(text: action.getText(), icon: action.getIcon()), - )) - .toList(), - ); - if (selectedAction != null) { - unawaited(chipActionDelegate.onActionSelected(context, filter, selectedAction)); - } - } - - List _buildActions(BuildContext context) { - return [ - SearchButton(source), - PopupMenuButton( - key: Key('appbar-menu-button'), - itemBuilder: (context) { - return [ - PopupMenuItem( - key: Key('menu-sort'), - value: ChipSetAction.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), - ), - ]; - }, - onSelected: (action) => chipSetActionDelegate.onActionSelected(context, action), - ), - ]; - } - - void _goToSearch(BuildContext context) { - Navigator.push( - context, - SearchPageRoute( - delegate: ImageSearchDelegate( - source: source, - ), - )); - } - - static int compareChipsByDate(MapEntry a, MapEntry b) { - final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; - return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); - } - - static int compareChipsByEntryCount(MapEntry a, MapEntry b) { - final c = b.value.compareTo(a.value) ?? -1; - return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); - } -} - -class FilterGridPage extends StatelessWidget { +class FilterGridPage extends StatelessWidget { final CollectionSource source; final Widget appBar; - final Map filterEntries; - final CollectionFilter Function(String key) filterBuilder; + final Map filterEntries; + final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; + final String settingsRouteKey; + final Iterable Function(Iterable filters, String query) applyQuery; final FilterCallback onTap; final OffsetFilterCallback onLongPress; - const FilterGridPage({ + final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); + final ValueNotifier _tileExtentNotifier = ValueNotifier(0); + final GlobalKey _scrollableKey = GlobalKey(); + + static const spacing = 8.0; + + FilterGridPage({ @required this.source, @required this.appBar, @required this.filterEntries, - @required this.filterBuilder, + @required this.queryNotifier, + this.applyQuery, @required this.emptyBuilder, + this.settingsRouteKey, + double appBarHeight = kToolbarHeight, @required this.onTap, this.onLongPress, - }); - - List get filterKeys => filterEntries.keys.toList(); + }) { + _appBarHeightNotifier.value = appBarHeight; + } static const Color detailColor = Color(0xFFE0E0E0); - static const double maxCrossAxisExtent = 180; @override Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( body: DoubleBackPopScope( - child: SafeArea( - child: Selector( - selector: (c, mq) => mq.size.width, - builder: (c, mqWidth, child) { - final columnCount = (mqWidth / maxCrossAxisExtent).ceil(); - final scrollView = _buildScrollView(context, columnCount); - return AnimationLimiter( - child: _buildDraggableScrollView(scrollView), - ); - }, + child: HighlightInfoProvider( + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final viewportSize = constraints.biggest; + assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); + if (viewportSize.isEmpty) return SizedBox.shrink(); + + final tileExtentManager = TileExtentManager( + settingsRouteKey: settingsRouteKey ?? context.currentRouteName, + columnCountMin: 2, + columnCountDefault: 2, + extentMin: 60, + extentNotifier: _tileExtentNotifier, + spacing: spacing, + )..applyTileExtent(viewportSize: viewportSize); + + return ValueListenableBuilder( + valueListenable: _tileExtentNotifier, + builder: (context, tileExtent, child) { + final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent); + + return ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, child) { + final allFilters = filterEntries.keys; + final visibleFilters = (applyQuery != null ? applyQuery(allFilters, query) : allFilters).toList(); + + final scrollView = AnimationLimiter( + child: _buildDraggableScrollView(_buildScrollView(context, columnCount, visibleFilters)), + ); + + return GridScaleGestureDetector( + tileExtentManager: tileExtentManager, + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + viewportSize: viewportSize, + showScaledGrid: true, + scaledBuilder: (item, extent) { + final filter = item.filter; + return SizedBox( + width: extent, + height: extent, + child: DecoratedFilterChip( + source: source, + filter: filter, + entry: item.entry, + extent: extent, + pinned: settings.pinnedFilters.contains(filter), + highlightable: false, + ), + ); + }, + getScaledItemTileRect: (context, item) { + final index = visibleFilters.indexOf(item.filter); + final column = index % columnCount; + final row = (index / columnCount).floor(); + final left = tileExtent * column + spacing * (column - 1); + final top = tileExtent * row + spacing * (row - 1); + return Rect.fromLTWH(left, top, tileExtent, tileExtent); + }, + onScaled: (item) => Provider.of(context, listen: false).add(item.filter), + child: scrollView, + ); + }, + ); + }, + ); + }, + ), ), ), ), @@ -223,7 +155,7 @@ class FilterGridPage extends StatelessWidget { controller: PrimaryScrollController.of(context), padding: EdgeInsets.only( // padding to keep scroll thumb between app bar above and nav bar below - top: kToolbarHeight, + top: _appBarHeightNotifier.value, bottom: mqViewInsetsBottom, ), child: scrollView, @@ -231,53 +163,63 @@ class FilterGridPage extends StatelessWidget { ); } - ScrollView _buildScrollView(BuildContext context, int columnCount) { + ScrollView _buildScrollView(BuildContext context, int columnCount, List visibleFilters) { final pinnedFilters = settings.pinnedFilters; return CustomScrollView( + key: _scrollableKey, controller: PrimaryScrollController.of(context), slivers: [ appBar, - filterKeys.isEmpty + visibleFilters.isEmpty ? SliverFillRemaining( - child: emptyBuilder(), + child: Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) { + return Padding( + padding: EdgeInsets.only(bottom: mqViewInsetsBottom), + child: emptyBuilder(), + ); + }, + ), hasScrollBody: false, ) - : SliverPadding( - padding: EdgeInsets.all(AvesFilterChip.outlineWidth), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, i) { - final key = filterKeys[i]; - final filter = filterBuilder(key); - final child = DecoratedFilterChip( - key: Key(key), + : SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, i) { + final filter = visibleFilters[i]; + final entry = filterEntries[filter]; + final child = MetaData( + metaData: ScalerMetadata(FilterGridItem(filter, entry)), + child: DecoratedFilterChip( + key: Key(filter.key), source: source, filter: filter, - entry: filterEntries[key], + entry: entry, + extent: _tileExtentNotifier.value, pinned: pinnedFilters.contains(filter), onTap: onTap, onLongPress: onLongPress, - ); - return AnimationConfiguration.staggeredGrid( - position: i, - columnCount: columnCount, - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), + ), + ); + return AnimationConfiguration.staggeredGrid( + position: i, + columnCount: columnCount, + duration: Durations.staggeredAnimation, + delay: Durations.staggeredAnimationDelay, + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, ), - ); - }, - childCount: filterKeys.length, - ), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: maxCrossAxisExtent, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), + ), + ); + }, + childCount: visibleFilters.length, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, ), ), SliverToBoxAdapter( @@ -292,3 +234,10 @@ class FilterGridPage extends StatelessWidget { ); } } + +class FilterGridItem { + final T filter; + final ImageEntry entry; + + const FilterGridItem(this.filter, this.entry); +} diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart new file mode 100644 index 000000000..d2a66d8bf --- /dev/null +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -0,0 +1,153 @@ +import 'dart:ui'; + +import 'package:aves/main.dart'; +import 'package:aves/model/actions/chip_actions.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_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/search/search_button.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class FilterNavigationPage extends StatelessWidget { + final CollectionSource source; + final String title; + final ChipSetActionDelegate chipSetActionDelegate; + final ChipActionDelegate chipActionDelegate; + final Map filterEntries; + final Widget Function() emptyBuilder; + final List Function(T filter) chipActionsBuilder; + + const FilterNavigationPage({ + @required this.source, + @required this.title, + @required this.chipSetActionDelegate, + @required this.chipActionDelegate, + @required this.chipActionsBuilder, + @required this.filterEntries, + @required this.emptyBuilder, + }); + + @override + Widget build(BuildContext context) { + return FilterGridPage( + source: source, + appBar: SliverAppBar( + title: TappableAppBarTitle( + onTap: () => _goToSearch(context), + child: SourceStateAwareAppBarTitle( + title: Text(title), + source: source, + ), + ), + actions: _buildActions(context), + titleSpacing: 0, + floating: true, + ), + filterEntries: filterEntries, + queryNotifier: ValueNotifier(''), + emptyBuilder: () => ValueListenableBuilder( + valueListenable: source.stateNotifier, + builder: (context, sourceState, child) { + return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink(); + }, + ), + onTap: (filter) => Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage(CollectionLens( + source: source, + filters: [filter], + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + )), + ), + ), + onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null, + ); + } + + Future _showMenu(BuildContext context, T filter, Offset tapPosition) async { + final RenderBox overlay = Overlay.of(context).context.findRenderObject(); + final touchArea = Size(40, 40); + final selectedAction = await showMenu( + context: context, + position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), + items: chipActionsBuilder(filter) + .map((action) => PopupMenuItem( + value: action, + child: MenuRow(text: action.getText(), icon: action.getIcon()), + )) + .toList(), + ); + if (selectedAction != null) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipActionDelegate.onActionSelected(context, filter, selectedAction)); + } + } + + List _buildActions(BuildContext context) { + return [ + SearchButton(source), + PopupMenuButton( + key: Key('appbar-menu-button'), + itemBuilder: (context) { + return [ + PopupMenuItem( + key: Key('menu-sort'), + value: ChipSetAction.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), + ), + ]; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action)); + }, + ), + ]; + } + + void _goToSearch(BuildContext context) { + Navigator.push( + context, + SearchPageRoute( + delegate: ImageSearchDelegate( + source: source, + ), + )); + } + + static int compareChipsByDate(MapEntry a, MapEntry b) { + final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; + return c != 0 ? c : a.key.compareTo(b.key); + } + + static int compareChipsByEntryCount(MapEntry a, MapEntry b) { + final c = b.value.compareTo(a.value) ?? -1; + return c != 0 ? c : a.key.compareTo(b.key); + } +} diff --git a/lib/widgets/filter_grids/common/overlay.dart b/lib/widgets/filter_grids/common/overlay.dart new file mode 100644 index 000000000..18148fac1 --- /dev/null +++ b/lib/widgets/filter_grids/common/overlay.dart @@ -0,0 +1,50 @@ +import 'dart:math'; + +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/highlight.dart'; +import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ChipHighlightOverlay extends StatefulWidget { + final CollectionFilter filter; + final double extent; + final BorderRadius borderRadius; + + const ChipHighlightOverlay({ + Key key, + @required this.filter, + @required this.extent, + @required this.borderRadius, + }) : super(key: key); + + @override + _ChipHighlightOverlayState createState() => _ChipHighlightOverlayState(); +} + +class _ChipHighlightOverlayState extends State { + final ValueNotifier _highlightedNotifier = ValueNotifier(false); + + CollectionFilter get filter => widget.filter; + + @override + Widget build(BuildContext context) { + final highlightInfo = context.watch(); + _highlightedNotifier.value = highlightInfo.contains(filter); + return Sweeper( + builder: (context) => Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).accentColor, + width: widget.extent * .1, + ), + borderRadius: widget.borderRadius, + ), + ), + toggledNotifier: _highlightedNotifier, + startAngle: pi * -3 / 4, + centerSweep: false, + onSweepEnd: () => highlightInfo.remove(filter), + ); + } +} diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 170c837f0..5f3dd6ce4 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; @@ -5,12 +6,11 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/location.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -39,7 +39,6 @@ class CountryListPage extends StatelessWidget { settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], filterEntries: _getCountryEntries(), - filterBuilder: _buildFilter, emptyBuilder: () => EmptyContent( icon: AIcons.location, text: 'No countries', @@ -50,31 +49,29 @@ class CountryListPage extends StatelessWidget { ); } - CollectionFilter _buildFilter(String location) => LocationFilter(LocationLevel.country, location); - - Map _getCountryEntries() { - final pinned = settings.pinnedFilters.whereType().map((f) => f.countryNameAndCode); + Map _getCountryEntries() { + final pinned = settings.pinnedFilters.whereType(); final entriesByDate = source.sortedEntriesForFilterList; // countries are initially sorted by name at the source level - var sortedCountries = source.sortedCountries; + var sortedFilters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)); if (settings.countrySortFactor == ChipSortFactor.count) { - var filtersWithCount = List.of(sortedCountries.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedCountries = filtersWithCount.map((kv) => kv.key).toList(); + sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); } final locatedEntries = entriesByDate.where((entry) => entry.isLocated); - final allMapEntries = sortedCountries.map((countryNameAndCode) { - final split = countryNameAndCode.split(LocationFilter.locationSeparator); + final allMapEntries = sortedFilters.map((filter) { + final split = filter.countryNameAndCode.split(LocationFilter.locationSeparator); ImageEntry entry; if (split.length > 1) { final countryCode = split[1]; entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null); } - return MapEntry(countryNameAndCode, entry); + return MapEntry(filter, entry); }); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 97ebc9468..7d29f6f7c 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/image_entry.dart'; @@ -5,12 +6,11 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -39,7 +39,6 @@ class TagListPage extends StatelessWidget { settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], filterEntries: _getTagEntries(), - filterBuilder: _buildFilter, emptyBuilder: () => EmptyContent( icon: AIcons.tag, text: 'No tags', @@ -50,25 +49,23 @@ class TagListPage extends StatelessWidget { ); } - CollectionFilter _buildFilter(String tag) => TagFilter(tag); - - Map _getTagEntries() { - final pinned = settings.pinnedFilters.whereType().map((f) => f.tag); + Map _getTagEntries() { + final pinned = settings.pinnedFilters.whereType(); final entriesByDate = source.sortedEntriesForFilterList; // tags are initially sorted by name at the source level - var sortedTags = source.sortedTags; + var sortedFilters = source.sortedTags.map((tag) => TagFilter(tag)); if (settings.tagSortFactor == ChipSortFactor.count) { - var filtersWithCount = List.of(sortedTags.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedTags = filtersWithCount.map((kv) => kv.key).toList(); + sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); } - final allMapEntries = sortedTags.map((tag) => MapEntry( - tag, - entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null), + final allMapEntries = sortedFilters.map((filter) => MapEntry( + filter, + entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(filter.tag), orElse: () => null), )); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); diff --git a/lib/widgets/fullscreen/debug/db.dart b/lib/widgets/fullscreen/debug/db.dart index ce2b89699..745368d1d 100644 --- a/lib/widgets/fullscreen/debug/db.dart +++ b/lib/widgets/fullscreen/debug/db.dart @@ -41,21 +41,6 @@ class _DbTabState extends State { return ListView( padding: EdgeInsets.all(16), children: [ - Row( - children: [ - Expanded( - child: Text('DB'), - ), - SizedBox(width: 8), - ElevatedButton( - onPressed: () async { - await metadataDb.removeIds([entry.contentId]); - _loadDatabase(); - }, - child: Text('Remove from DB'), - ), - ], - ), FutureBuilder( future: _dbDateLoader, builder: (context, snapshot) { @@ -143,7 +128,6 @@ class _DbTabState extends State { Text('DB address:${data == null ? ' no row' : ''}'), if (data != null) InfoRowGroup({ - 'addressLine': '${data.addressLine}', 'countryCode': '${data.countryCode}', 'countryName': '${data.countryName}', 'adminArea': '${data.adminArea}', diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/fullscreen/debug/metadata.dart index d4b03340f..dca1ec6ef 100644 --- a/lib/widgets/fullscreen/debug/metadata.dart +++ b/lib/widgets/fullscreen/debug/metadata.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart similarity index 75% rename from lib/widgets/common/action_delegates/entry_action_delegate.dart rename to lib/widgets/fullscreen/entry_action_delegate.dart index e62b4631e..e77491913 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -1,15 +1,17 @@ +import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.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_entry_dialog.dart'; -import 'package:aves/widgets/common/aves_dialog.dart'; -import 'package:aves/widgets/common/entry_actions.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:pdf/pdf.dart'; @@ -36,21 +38,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { case EntryAction.delete: _showDeleteDialog(context, entry); break; - case EntryAction.edit: - AndroidAppService.edit(entry.uri, entry.mimeType); - break; case EntryAction.info: showInfo(); break; case EntryAction.rename: _showRenameDialog(context, entry); break; - case EntryAction.open: - AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype); - break; - case EntryAction.openMap: - AndroidAppService.openMap(entry.geoUri); - break; case EntryAction.print: _print(entry); break; @@ -63,11 +56,33 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { case EntryAction.flip: _flip(context, entry); break; + case EntryAction.edit: + AndroidAppService.edit(entry.uri, entry.mimeType).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; + case EntryAction.open: + AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; + case EntryAction.openMap: + AndroidAppService.openMap(entry.geoUri).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; case EntryAction.setAs: - AndroidAppService.setAs(entry.uri, entry.mimeType); + AndroidAppService.setAs(entry.uri, entry.mimeType).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); break; case EntryAction.share: - AndroidAppService.share({entry}); + AndroidAppService.share({entry}).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; + case EntryAction.viewSource: + _goToSourceViewer(context, entry); break; case EntryAction.debug: _goToDebug(context, entry); @@ -119,14 +134,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { } Future _flip(BuildContext context, ImageEntry entry) async { - if (!await checkStoragePermission(context, [entry])) return; + if (!await checkStoragePermission(context, {entry})) return; final success = await entry.flip(); if (!success) showFeedback(context, 'Failed'); } Future _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async { - if (!await checkStoragePermission(context, [entry])) return; + if (!await checkStoragePermission(context, {entry})) return; final success = await entry.rotate(clockwise: clockwise); if (!success) showFeedback(context, 'Failed'); @@ -137,6 +152,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { context: context, builder: (context) { return AvesDialog( + context: context, content: Text('Are you sure?'), actions: [ TextButton( @@ -153,7 +169,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermission(context, [entry])) return; + if (!await checkStoragePermission(context, {entry})) return; if (!await entry.delete()) { showFeedback(context, 'Failed'); @@ -176,11 +192,21 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { ); if (newName == null || newName.isEmpty) return; - if (!await checkStoragePermission(context, [entry])) return; + if (!await checkStoragePermission(context, {entry})) return; showFeedback(context, await entry.rename(newName) ? 'Done!' : 'Failed'); } + void _goToSourceViewer(BuildContext context, ImageEntry entry) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage(entry: entry), + ), + ); + } + void _goToDebug(BuildContext context, ImageEntry entry) { Navigator.push( context, diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 11b25828f..3fce014bf 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -6,9 +6,9 @@ import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; +import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; @@ -296,7 +296,7 @@ class FullscreenBodyState extends State with SingleTickerProvide settings: RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage(collection.derive(filter)), ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), + (route) => false, ); } diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart index 2a79c4481..3c796a253 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -1,8 +1,8 @@ +import 'package:aves/image_providers/thumbnail_provider.dart'; +import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/main.dart'; import 'package:aves/model/image_entry.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/theme/icons.dart'; import 'package:aves/widgets/fullscreen/debug/db.dart'; import 'package:aves/widgets/fullscreen/debug/metadata.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; diff --git a/lib/widgets/fullscreen/fullscreen_page.dart b/lib/widgets/fullscreen/fullscreen_page.dart index 142bfd344..8691f2f3e 100644 --- a/lib/widgets/fullscreen/fullscreen_page.dart +++ b/lib/widgets/fullscreen/fullscreen_page.dart @@ -1,6 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/fullscreen/fullscreen_body.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index f79584088..c5ee6c31d 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,12 +1,12 @@ import 'dart:async'; +import 'package:aves/image_providers/thumbnail_provider.dart'; +import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.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_image_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/tiled_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:flutter/foundation.dart'; @@ -39,7 +39,8 @@ class ImageView extends StatefulWidget { class _ImageViewState extends State { final PhotoViewController _photoViewController = PhotoViewController(); - final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); + final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController(); + final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); StreamSubscription _subscription; Size _photoViewChildSize; @@ -71,7 +72,9 @@ class _ImageViewState extends State { Widget build(BuildContext context) { Widget child; if (entry.isVideo) { - child = _buildVideoView(); + if (entry.width > 0 && entry.height > 0) { + child = _buildVideoView(); + } } else if (entry.isSvg) { child = _buildSvgView(); } else if (entry.canDecode) { @@ -80,9 +83,8 @@ class _ImageViewState extends State { } else { child = _buildImageView(); } - } else { - child = _buildError(); } + child ??= _buildError(); // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`, // the route transition becomes visible if the final image is loaded before the hero animation is done. @@ -164,6 +166,15 @@ class _ImageViewState extends State { child: Selector( selector: (context, mq) => mq.size, builder: (context, mqSize, child) { + // When the scale state is cycled to be in its `initial` state (i.e. `contained`), and the device is rotated, + // `PhotoView` keeps the scale state as `contained`, but the controller does not update or notify the new scale value. + // We cannot use `scaleStateChangedCallback` as a workaround, because the scale state is updated before animating the scale change, + // so we keep receiving scale updates after the scale state update. + // Instead we check the scale state here when the constraints change, so we can reset the obsolete scale value. + if (_photoViewScaleStateController.scaleState == PhotoViewScaleState.initial) { + final value = PhotoViewControllerValue(position: Offset.zero, scale: 0, rotation: 0, rotationFocusPoint: null); + WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value)); + } return TiledImageView( entry: entry, viewportSize: mqSize, @@ -176,6 +187,7 @@ class _ImageViewState extends State { childSize: entry.displaySize, backgroundDecoration: backgroundDecoration, controller: _photoViewController, + scaleStateController: _photoViewScaleStateController, maxScale: maxScale, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, @@ -191,9 +203,9 @@ class _ImageViewState extends State { UriPicture( uri: entry.uri, mimeType: entry.mimeType, + colorFilter: colorFilter, ), placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider), - colorFilter: colorFilter, ), backgroundDecoration: backgroundDecoration, controller: _photoViewController, diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 6fa89b94c..7b07a39a9 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -4,11 +4,11 @@ import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/file_utils.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/fullscreen/info/common.dart index a91078d9d..d6d7acd4c 100644 --- a/lib/widgets/fullscreen/info/common.dart +++ b/lib/widgets/fullscreen/info/common.dart @@ -1,6 +1,9 @@ -import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'dart:math'; + +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; class SectionRow extends StatelessWidget { final IconData icon; @@ -54,36 +57,71 @@ class _InfoRowGroupState extends State { int get maxValueLength => widget.maxValueLength; + static const keyValuePadding = 16; + static final baseStyle = TextStyle(fontFamily: 'Concourse'); + static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7); + @override Widget build(BuildContext context) { if (keyValues.isEmpty) return SizedBox.shrink(); + + // compute the size of keys and space in order to align values + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: '$key', style: keyStyle), textScaleFactor)))); + final baseSpaceWidth = _getSpanWidth(TextSpan(text: '\u200A' * 100, style: baseStyle), textScaleFactor); + final lastKey = keyValues.keys.last; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText.rich( - TextSpan( - children: keyValues.entries.expand( - (kv) { - final key = kv.key; - var value = kv.value; - final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); - if (showPreviewOnly) { - value = '${value.substring(0, maxValueLength)}…'; - } - return [ - TextSpan(text: '$key ', style: TextStyle(color: Colors.white70, height: 1.7)), - TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), - ]; - }, - ).toList(), - ), - style: TextStyle(fontFamily: 'Concourse'), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + // find longest key below threshold + final maxBaseValueX = constraints.maxWidth / 3; + final baseValueX = keySizes.values.where((size) => size < maxBaseValueX).fold(0.0, max); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText.rich( + TextSpan( + children: keyValues.entries.expand( + (kv) { + final key = kv.key; + var value = kv.value; + // long values are clipped, and made expandable by tapping them + final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); + if (showPreviewOnly) { + value = '${value.substring(0, maxValueLength)}…'; + } + + // as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan` + // so we add padding using multiple hair spaces instead + final thisSpaceSize = max(0.0, (baseValueX - keySizes[key])) + keyValuePadding; + final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round(); + + return [ + TextSpan(text: '$key', style: keyStyle), + TextSpan(text: '\u200A' * spaceCount), + TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), + ]; + }, + ).toList(), + ), + style: baseStyle, + ), + ], + ); + }, ); } + double _getSpanWidth(TextSpan span, double textScaleFactor) { + final para = RenderParagraph( + span, + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + )..layout(BoxConstraints(), parentUsesSize: true); + return para.getMaxIntrinsicWidth(double.infinity); + } + GestureRecognizer _buildTapRecognizer(String key) { return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); } diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 9e9e4a6a1..bf9f10780 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -1,12 +1,12 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/fullscreen/info/basic_section.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; -import 'package:aves/widgets/fullscreen/info/metadata_section.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -43,7 +43,7 @@ class InfoPageState extends State { key: Key('back-button'), icon: Icon(AIcons.goUp), onPressed: _goToImage, - tooltip: 'Back to image', + tooltip: 'Back to viewer', ), title: Text('Info'), floating: true, diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index c1c98445a..e6d1475d7 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -3,15 +3,16 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/fullscreen/info/common.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'; +import 'package:tuple/tuple.dart'; class LocationSection extends StatefulWidget { final CollectionLens collection; @@ -79,11 +80,9 @@ class _LocationSectionState extends State with TickerProviderSt final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value); if (showMap) { _loadedUri = entry.uri; - var location = ''; final filters = []; if (entry.isLocated) { final address = entry.addressDetails; - location = address.addressLine; final country = address.countryName; if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); final place = address.place; @@ -114,7 +113,8 @@ class _LocationSectionState extends State with TickerProviderSt vsync: this, child: settings.infoMapStyle.isGoogleMaps ? EntryGoogleMap( - latLng: entry.latLng, + // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package + latLng: Tuple2(entry.latLng.latitude, entry.latLng.longitude), geoUri: entry.geoUri, initialZoom: settings.infoMapZoom, markerId: entry.uri ?? entry.path, @@ -130,11 +130,7 @@ class _LocationSectionState extends State with TickerProviderSt ), ), ), - if (entry.hasGps) - InfoRowGroup(Map.fromEntries([ - MapEntry('Coordinates', settings.coordinateFormat.format(entry.latLng)), - if (location.isNotEmpty) MapEntry('Address', location), - ])), + if (entry.hasGps) _AddressInfoGroup(entry: entry), if (filters.isNotEmpty) Padding( padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8), @@ -160,6 +156,41 @@ class _LocationSectionState extends State with TickerProviderSt void _handleChange() => setState(() {}); } +class _AddressInfoGroup extends StatefulWidget { + final ImageEntry entry; + + const _AddressInfoGroup({@required this.entry}); + + @override + _AddressInfoGroupState createState() => _AddressInfoGroupState(); +} + +class _AddressInfoGroupState extends State<_AddressInfoGroup> { + Future _addressLineLoader; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _addressLineLoader = entry.findAddressLine(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _addressLineLoader, + builder: (context, snapshot) { + final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : null; + return InfoRowGroup({ + 'Coordinates': settings.coordinateFormat.format(entry.latLng), + if (address?.isNotEmpty == true) 'Address': address, + }); + }, + ); + } +} + // browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } @@ -173,7 +204,7 @@ extension ExtraEntryMapStyle on EntryMapStyle { case EntryMapStyle.googleTerrain: return 'Google Maps (Terrain)'; case EntryMapStyle.osmHot: - return 'Humanitarian OpenStreetMap'; + return 'Humanitarian OSM'; case EntryMapStyle.stamenToner: return 'Stamen Toner'; case EntryMapStyle.stamenWatercolor: diff --git a/lib/widgets/fullscreen/info/maps/common.dart b/lib/widgets/fullscreen/info/maps/common.dart index de76c8c64..452d6ef00 100644 --- a/lib/widgets/fullscreen/info/maps/common.dart +++ b/lib/widgets/fullscreen/info/maps/common.dart @@ -1,10 +1,11 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_selection_dialog.dart'; -import 'package:aves/widgets/common/borders.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/material.dart'; @@ -63,7 +64,9 @@ class MapButtonPanel extends StatelessWidget { children: [ MapOverlayButton( icon: AIcons.openInNew, - onPressed: () => AndroidAppService.openMap(geoUri), + onPressed: () => AndroidAppService.openMap(geoUri).then((success) { + if (!success) showNoMatchingAppDialog(context); + }), tooltip: 'Show on map…', ), SizedBox(height: padding), diff --git a/lib/widgets/fullscreen/info/maps/leaflet_map.dart b/lib/widgets/fullscreen/info/maps/leaflet_map.dart index cc6fd5fcd..84c8bc203 100644 --- a/lib/widgets/fullscreen/info/maps/leaflet_map.dart +++ b/lib/widgets/fullscreen/info/maps/leaflet_map.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:latlong/latlong.dart'; -import 'package:tuple/tuple.dart'; import 'package:url_launcher/url_launcher.dart'; import '../location_section.dart'; @@ -18,16 +17,15 @@ class EntryLeafletMap extends StatefulWidget { final Size markerSize; final WidgetBuilder markerBuilder; - EntryLeafletMap({ + const EntryLeafletMap({ Key key, - Tuple2 latLng, + this.latLng, this.geoUri, this.initialZoom, this.style, this.markerBuilder, this.markerSize, - }) : latLng = LatLng(latLng.item1, latLng.item2), - super(key: key); + }) : super(key: key); @override State createState() => EntryLeafletMapState(); diff --git a/lib/widgets/fullscreen/info/maps/scale_layer.dart b/lib/widgets/fullscreen/info/maps/scale_layer.dart index 7d821ee83..0c9aa1360 100644 --- a/lib/widgets/fullscreen/info/maps/scale_layer.dart +++ b/lib/widgets/fullscreen/info/maps/scale_layer.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:aves/widgets/common/fx/outlined_text.dart'; +import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/plugin_api.dart'; diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart similarity index 69% rename from lib/widgets/fullscreen/info/metadata_section.dart rename to lib/widgets/fullscreen/info/metadata/metadata_section.dart index fccd18ed4..438fb3807 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -1,14 +1,16 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/highlight_title.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -28,7 +30,7 @@ class MetadataSectionSliver extends StatefulWidget { } class _MetadataSectionSliverState extends State with AutomaticKeepAliveClientMixin { - List<_MetadataDirectory> _metadata = []; + Map _metadata = {}; final ValueNotifier _loadedMetadataUri = ValueNotifier(null); final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); @@ -41,6 +43,10 @@ class _MetadataSectionSliverState extends State with Auto static const xmpDirectory = 'XMP'; // from metadata-extractor static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory + // directory names may contain the name of their parent directory + // if so, they are separated by this character + static const parentChildSeparator = '/'; + @override void initState() { super.initState(); @@ -87,8 +93,6 @@ class _MetadataSectionSliverState extends State with Auto if (_metadata.isEmpty) { content = SizedBox.shrink(); } else { - final directoriesWithoutTitle = _metadata.where((dir) => dir.name.isEmpty).toList(); - final directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty).toList(); content = Column( children: AnimationConfiguration.toStaggeredList( duration: Durations.staggeredAnimation, @@ -101,8 +105,7 @@ class _MetadataSectionSliverState extends State with Auto ), children: [ SectionRow(AIcons.info), - ...directoriesWithoutTitle.map(_buildDirTileWithoutTitle), - ...directoriesWithTitle.map(_buildDirTileWithTitle), + ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), ], ), ); @@ -118,17 +121,18 @@ class _MetadataSectionSliverState extends State with Auto ); } - Widget _buildDirTileWithoutTitle(_MetadataDirectory dir) { - return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength); - } - - Widget _buildDirTileWithTitle(_MetadataDirectory dir) { - if (dir.name == xmpDirectory) { - return _buildXmpDirTile(dir); + Widget _buildDirTile(String title, _MetadataDirectory dir) { + final dirName = dir.name; + if (dirName == xmpDirectory) { + return XmpDirTile( + entry: entry, + tags: dir.tags, + expandedNotifier: _expandedDirectoryNotifier, + ); } Widget thumbnail; final prefixChildren = []; - switch (dir.name) { + switch (dirName) { case exifThumbnailDirectory: thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); break; @@ -151,7 +155,8 @@ class _MetadataSectionSliverState extends State with Auto } return AvesExpansionTile( - title: dir.name, + title: title, + color: BrandColors.get(dirName) ?? stringToColor(dirName), expandedNotifier: _expandedDirectoryNotifier, children: [ if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), @@ -164,45 +169,9 @@ class _MetadataSectionSliverState extends State with Auto ); } - Widget _buildXmpDirTile(_MetadataDirectory dir) { - final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); - final byNamespace = SplayTreeMap.of( - groupBy, String>(dir.tags.entries, (kv) { - final fullKey = kv.key; - final i = fullKey.indexOf(':'); - if (i == -1) return ''; - return fullKey.substring(0, i); - }), - compareAsciiLowerCase, - ); - return AvesExpansionTile( - title: dir.name, - expandedNotifier: _expandedDirectoryNotifier, - children: [ - if (thumbnail != null) thumbnail, - Padding( - padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: byNamespace.entries.expand((kv) { - final ns = kv.key; - final hasNamespace = ns.isNotEmpty; - final i = hasNamespace ? ns.length + 1 : 0; - final tags = Map.fromEntries(kv.value.map((kv) => MapEntry(kv.key.substring(i), kv.value))); - return [ - if (hasNamespace) HighlightTitle(ns), - InfoRowGroup(tags, maxValueLength: Constants.infoGroupMaxValueLength), - ]; - }).toList(), - ), - ), - ], - ); - } - void _onMetadataChanged() { _loadedMetadataUri.value = null; - _metadata = []; + _metadata = {}; _getMetadata(); } @@ -211,21 +180,38 @@ class _MetadataSectionSliverState extends State with Auto if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { final rawMetadata = await MetadataService.getAllMetadata(entry) ?? {}; - _metadata = rawMetadata.entries.map((dirKV) { - final directoryName = dirKV.key as String ?? ''; + final directories = rawMetadata.entries.map((dirKV) { + var directoryName = dirKV.key as String ?? ''; + + String parent; + final parts = directoryName.split(parentChildSeparator); + if (parts.length > 1) { + parent = parts[0]; + directoryName = parts[1]; + } + final rawTags = dirKV.value as Map ?? {}; final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { - final value = tagKV.value as String ?? ''; + final value = (tagKV.value as String ?? '').trim(); if (value.isEmpty) return null; final tagName = tagKV.key as String ?? ''; return MapEntry(tagName, value); }).where((kv) => kv != null))); - return _MetadataDirectory(directoryName, tags); + return _MetadataDirectory(directoryName, parent, tags); + }).toList(); + + final titledDirectories = directories.map((dir) { + var title = dir.name; + if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { + title = '${dir.parent}/$title'; + } + return MapEntry(title, dir); }).toList() - ..sort((a, b) => compareAsciiUpperCase(a.name, b.name)); + ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); + _metadata = Map.fromEntries(titledDirectories); _loadedMetadataUri.value = entry.uri; } else { - _metadata = []; + _metadata = {}; _loadedMetadataUri.value = null; } _expandedDirectoryNotifier.value = null; @@ -237,7 +223,8 @@ class _MetadataSectionSliverState extends State with Auto class _MetadataDirectory { final String name; + final String parent; final SplayTreeMap tags; - const _MetadataDirectory(this.name, this.tags); + const _MetadataDirectory(this.name, this.parent, this.tags); } diff --git a/lib/widgets/fullscreen/info/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart similarity index 100% rename from lib/widgets/fullscreen/info/metadata_thumbnail.dart rename to lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart new file mode 100644 index 000000000..49d469aff --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -0,0 +1,78 @@ +import 'dart:collection'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/xmp.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class XmpDirTile extends StatelessWidget { + final ImageEntry entry; + final SplayTreeMap tags; + final ValueNotifier expandedNotifier; + + const XmpDirTile({ + @required this.entry, + @required this.tags, + @required this.expandedNotifier, + }); + + @override + Widget build(BuildContext context) { + final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); + final sections = SplayTreeMap.of( + groupBy, String>(tags.entries, (kv) { + final fullKey = kv.key; + final i = fullKey.indexOf(XMP.namespaceSeparator); + if (i == -1) return ''; + final namespace = fullKey.substring(0, i); + return XMP.namespaces[namespace] ?? namespace; + }), + compareAsciiUpperCase, + ); + return AvesExpansionTile( + title: 'XMP', + expandedNotifier: expandedNotifier, + children: [ + if (thumbnail != null) thumbnail, + Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sections.entries.expand((sectionEntry) { + final title = sectionEntry.key; + + final entries = sectionEntry.value.map((kv) { + final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { + // strip namespace + final key = s.split(XMP.namespaceSeparator).last; + // uppercase first letter + return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + }); + return MapEntry(key, kv.value); + }).toList() + ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); + return [ + if (title.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 8), + child: HighlightTitle( + title, + color: BrandColors.get(title), + ), + ), + InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength), + ]; + }).toList(), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index af5d068c2..b2d08980f 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -5,10 +5,10 @@ import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; -import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/overlay/common.dart b/lib/widgets/fullscreen/overlay/common.dart index c5830d2d5..9c7183bee 100644 --- a/lib/widgets/fullscreen/overlay/common.dart +++ b/lib/widgets/fullscreen/overlay/common.dart @@ -1,5 +1,5 @@ -import 'package:aves/widgets/common/borders.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; import 'package:flutter/material.dart'; const kOverlayBackgroundColor = Colors.black26; diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart index edceade7c..d8a33446f 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -56,7 +56,10 @@ class MinimapPainter extends CustomPainter { @required this.viewScale, this.minimapBorderColor = Colors.white, this.viewportBorderColor = Colors.white, - }); + }) : assert(viewportSize != null), + assert(entrySize != null), + assert(viewCenterOffset != null), + assert(viewScale != null); @override void paint(Canvas canvas, Size size) { diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index bfd209e36..5626c06f5 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -1,17 +1,19 @@ import 'dart:math'; +import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/entry_actions.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:aves/widgets/fullscreen/overlay/minimap.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -109,6 +111,8 @@ class FullscreenTopOverlay extends StatelessWidget { return entry.canPrint; case EntryAction.openMap: return entry.hasGps; + case EntryAction.viewSource: + return entry.isSvg; case EntryAction.share: case EntryAction.info: case EntryAction.open: @@ -166,7 +170,10 @@ class _TopOverlayRow extends StatelessWidget { _buildPopupMenuItem(EntryAction.debug), ] ], - onSelected: onActionSelected, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); + }, ), ), ], @@ -175,7 +182,7 @@ class _TopOverlayRow extends StatelessWidget { Widget _buildOverlayButton(EntryAction action) { Widget child; - void onPressed() => onActionSelected?.call(action); + void onPressed() => onActionSelected(action); switch (action) { case EntryAction.toggleFavourite: child = _FavouriteToggler( @@ -191,6 +198,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rotateCW: case EntryAction.flip: case EntryAction.print: + case EntryAction.viewSource: child = IconButton( icon: Icon(action.getIcon()), onPressed: onPressed, @@ -233,6 +241,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rotateCW: case EntryAction.flip: case EntryAction.print: + case EntryAction.viewSource: case EntryAction.debug: child = MenuRow(text: action.getText(), icon: action.getIcon()); break; diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index df66e30d9..47e7a69b8 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:aves/model/image_entry.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/utils/durations.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/time_utils.dart'; -import 'package:aves/widgets/common/borders.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; diff --git a/lib/widgets/fullscreen/source_viewer_page.dart b/lib/widgets/fullscreen/source_viewer_page.dart new file mode 100644 index 000000000..d6a5a0557 --- /dev/null +++ b/lib/widgets/fullscreen/source_viewer_page.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/widgets/common/aves_highlight.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_highlight/themes/darcula.dart'; + +class SourceViewerPage extends StatefulWidget { + static const routeName = '/fullscreen/source'; + + final ImageEntry entry; + + const SourceViewerPage({ + @required this.entry, + }); + + @override + _SourceViewerPageState createState() => _SourceViewerPageState(); +} + +class _SourceViewerPageState extends State { + Future _loader; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Source'), + ), + body: SafeArea( + child: FutureBuilder( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (snapshot.connectionState != ConnectionState.done) { + return SizedBox.shrink(); + } + + final source = snapshot.data; + final highlightView = AvesHighlightView( + source, + language: 'xml', + theme: darculaTheme, + padding: EdgeInsets.all(8), + textStyle: TextStyle( + fontSize: 12, + ), + tabSize: 4, + ); + return Container( + constraints: BoxConstraints.expand(), + child: Scrollbar( + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: highlightView, + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index 2afd375c3..ad123a960 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/math_utils.dart'; -import 'package:aves/widgets/common/image_providers/region_provider.dart'; +import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index d98aa2073..7007ebb22 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:ui'; +import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index a75c777d4..e848d0a0b 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -5,12 +5,12 @@ import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; -import 'package:aves/widgets/common/routes.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index decf75f9f..db02cadeb 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -1,8 +1,8 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; class ExpandableFilterRow extends StatelessWidget { @@ -45,6 +45,7 @@ class ExpandableFilterRow extends StatelessWidget { IconButton( icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), onPressed: () => expandedNotifier.value = isExpanded ? null : title, + tooltip: isExpanded ? 'Collapse' : 'Expand', ), ], ), diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart index 5e6a4b3cf..70fbefb06 100644 --- a/lib/widgets/search/search_button.dart +++ b/lib/widgets/search/search_button.dart @@ -1,6 +1,6 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -16,6 +16,7 @@ class SearchButton extends StatelessWidget { key: Key('search-button'), icon: Icon(AIcons.search), onPressed: () => _goToSearch(context), + tooltip: 'Search', ); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 1ba7d3503..754150d56 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -5,16 +5,16 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/mime_types.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/search/expandable_filter_row.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:flutter/material.dart'; @@ -22,8 +22,8 @@ import 'package:flutter/services.dart'; class ImageSearchDelegate { final CollectionSource source; - final ValueNotifier expandedSectionNotifier = ValueNotifier(null); final CollectionLens parentCollection; + final ValueNotifier expandedSectionNotifier = ValueNotifier(null); static const searchHistoryCount = 10; @@ -188,14 +188,12 @@ class ImageSearchDelegate { if (parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { - _goToCollectionPage(context, filter); + _jumpToCollectionPage(context, filter); } } void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { - if (filter != null) { - parentCollection.addFilter(filter); - } + parentCollection.addFilter(filter); // we post closing the search page after applying the filter selection // so that hero animation target is ready in the `FilterBar`, // even when the target is a child of an `AnimatedList` @@ -209,7 +207,7 @@ class ImageSearchDelegate { Navigator.pop(context); } - void _goToCollectionPage(BuildContext context, CollectionFilter filter) { + void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { _clean(); Navigator.pushAndRemoveUntil( context, @@ -222,7 +220,7 @@ class ImageSearchDelegate { sortFactor: settings.collectionSortFactor, )), ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), + (route) => false, ); } @@ -259,7 +257,7 @@ class ImageSearchDelegate { queryTextController.text = value; } - final ValueNotifier currentBodyNotifier = ValueNotifier(null); + final ValueNotifier currentBodyNotifier = ValueNotifier(null); SearchBody get currentBody => currentBodyNotifier.value; diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index 5919ffc7f..171f0a8b6 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -1,3 +1,5 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -18,7 +20,8 @@ class SearchPage extends StatefulWidget { } class _SearchPageState extends State { - FocusNode focusNode = FocusNode(); + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + final FocusNode _focusNode = FocusNode(); @override void initState() { @@ -26,8 +29,8 @@ class _SearchPageState extends State { widget.delegate.queryTextController.addListener(_onQueryChanged); widget.animation.addStatusListener(_onAnimationStatusChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); - focusNode.addListener(_onFocusChanged); - widget.delegate.focusNode = focusNode; + _focusNode.addListener(_onFocusChanged); + widget.delegate.focusNode = _focusNode; } @override @@ -37,7 +40,7 @@ class _SearchPageState extends State { widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); widget.delegate.focusNode = null; - focusNode.dispose(); + _focusNode.dispose(); } void _onAnimationStatusChanged(AnimationStatus status) { @@ -45,7 +48,7 @@ class _SearchPageState extends State { return; } widget.animation.removeStatusListener(_onAnimationStatusChanged); - focusNode.requestFocus(); + _focusNode.requestFocus(); } @override @@ -57,20 +60,20 @@ class _SearchPageState extends State { oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); oldWidget.delegate.focusNode = null; - widget.delegate.focusNode = focusNode; + widget.delegate.focusNode = _focusNode; } } void _onFocusChanged() { - if (focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { + if (_focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { widget.delegate.showSuggestions(context); } } void _onQueryChanged() { - setState(() { - // rebuild ourselves because query changed. - }); + _debouncer(() => setState(() { + // rebuild ourselves because query changed. + })); } void _onSearchBodyChanged() { @@ -106,7 +109,7 @@ class _SearchPageState extends State { leading: widget.delegate.buildLeading(context), title: TextField( controller: widget.delegate.queryTextController, - focusNode: focusNode, + focusNode: _focusNode, style: theme.textTheme.headline6, textInputAction: TextInputAction.search, onSubmitted: (_) => widget.delegate.showResults(context), diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 2000c2405..8661c13d5 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -2,12 +2,12 @@ import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_expansion_tile.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/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/svg_background.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/settings/svg_background.dart b/lib/widgets/settings/svg_background.dart index b022a2671..929b5a8ad 100644 --- a/lib/widgets/settings/svg_background.dart +++ b/lib/widgets/settings/svg_background.dart @@ -1,5 +1,5 @@ import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/borders.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; import 'package:flutter/material.dart'; class SvgBackgroundSelector extends StatefulWidget { diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 6c64666d5..97964940e 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -1,24 +1,23 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; class FilterTable extends StatelessWidget { - final CollectionLens collection; + final int totalEntryCount; final Map entryCountMap; final CollectionFilter Function(String key) filterBuilder; + final FilterCallback onFilterSelection; const FilterTable({ - @required this.collection, + @required this.totalEntryCount, @required this.entryCountMap, @required this.filterBuilder, + @required this.onFilterSelection, }); static const chipWidth = AvesFilterChip.maxChipWidth; @@ -27,7 +26,6 @@ class FilterTable extends StatelessWidget { @override Widget build(BuildContext context) { - final maxCount = collection.entryCount; final sortedEntries = entryCountMap.entries.toList() ..sort((kv1, kv2) { final c = kv2.value.compareTo(kv1.value); @@ -47,7 +45,7 @@ class FilterTable extends StatelessWidget { final filter = filterBuilder(kv.key); final label = filter.label; final count = kv.value; - final percent = count / maxCount; + final percent = count / totalEntryCount; return TableRow( children: [ Container( @@ -58,7 +56,7 @@ class FilterTable extends StatelessWidget { alignment: AlignmentDirectional.centerStart, child: AvesFilterChip( filter: filter, - onTap: (filter) => _goToCollection(context, filter), + onTap: onFilterSelection, ), ), if (showPercentIndicator) @@ -92,16 +90,4 @@ class FilterTable extends StatelessWidget { ), ); } - - void _goToCollection(BuildContext context, CollectionFilter filter) { - if (collection == null) return; - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - settings: RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage(collection.derive(filter)), - ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), - ); - } } diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 93183f133..96abd7807 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -5,15 +5,16 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/mime_types.dart'; 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/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/stats/filter_table.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; @@ -25,14 +26,18 @@ import 'package:percent_indicator/linear_percent_indicator.dart'; class StatsPage extends StatelessWidget { static const routeName = '/collection/stats'; - final CollectionLens collection; + final CollectionSource source; + final CollectionLens parentCollection; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; - List get entries => collection.sortedEntries; + List get entries => parentCollection?.sortedEntries ?? source.rawEntries; static const mimeDonutMinWidth = 124.0; - StatsPage({this.collection}) { + StatsPage({ + @required this.source, + this.parentCollection, + }) : assert(source != null) { entries.forEach((entry) { if (entry.isLocated) { final address = entry.addressDetails; @@ -55,7 +60,7 @@ class StatsPage extends StatelessWidget { @override Widget build(BuildContext context) { Widget child; - if (collection.isEmpty) { + if (entries.isEmpty) { child = EmptyContent( icon: AIcons.image, text: 'No images', @@ -75,7 +80,7 @@ class StatsPage extends StatelessWidget { final catalogued = entries.where((entry) => entry.isCatalogued); final withGps = catalogued.where((entry) => entry.hasGps); - final withGpsPercent = withGps.length / collection.entryCount; + final withGpsPercent = withGps.length / entries.length; final textScaleFactor = MediaQuery.textScaleFactorOf(context); final lineHeight = 16 * textScaleFactor; final locationIndicator = Padding( @@ -105,9 +110,9 @@ class StatsPage extends StatelessWidget { children: [ mimeDonuts, locationIndicator, - ..._buildTopFilters('Top Countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), - ..._buildTopFilters('Top Places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), - ..._buildTopFilters('Top Tags', entryCountPerTag, (s) => TagFilter(s)), + ..._buildTopFilters(context, 'Top Countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), + ..._buildTopFilters(context, 'Top Places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), + ..._buildTopFilters(context, 'Top Tags', entryCountPerTag, (s) => TagFilter(s)), ], ); } @@ -178,7 +183,7 @@ class StatsPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: seriesData .map((d) => GestureDetector( - onTap: () => _goToCollection(context, MimeFilter(d.mimeType)), + onTap: () => _onFilterSelection(context, MimeFilter(d.mimeType)), child: Text.rich( TextSpan( children: [ @@ -218,6 +223,7 @@ class StatsPage extends StatelessWidget { } List _buildTopFilters( + BuildContext context, String title, Map entryCountMap, CollectionFilter Function(String key) filterBuilder, @@ -233,22 +239,45 @@ class StatsPage extends StatelessWidget { ), ), FilterTable( - collection: collection, + totalEntryCount: entries.length, entryCountMap: entryCountMap, filterBuilder: filterBuilder, + onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ]; } - void _goToCollection(BuildContext context, CollectionFilter filter) { - if (collection == null) return; + void _onFilterSelection(BuildContext context, CollectionFilter filter) { + if (parentCollection != null) { + _applyToParentCollectionPage(context, filter); + } else { + _jumpToCollectionPage(context, filter); + } + } + + void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { + parentCollection.addFilter(filter); + // we post closing the search page after applying the filter selection + // so that hero animation target is ready in the `FilterBar`, + // even when the target is a child of an `AnimatedList` + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pop(context); + }); + } + + void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute( settings: RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage(collection.derive(filter)), + builder: (context) => CollectionPage(CollectionLens( + source: source, + filters: [filter], + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + )), ), - settings.navRemoveRoutePredicate(CollectionPage.routeName), + (route) => false, ); } } @@ -261,7 +290,7 @@ class EntryByMimeDatum { EntryByMimeDatum({ @required this.mimeType, @required this.entryCount, - }) : displayText = MimeTypes.displayType(mimeType); + }) : displayText = MimeUtils.displayType(mimeType); Color get color => stringToColor(displayText); diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index fb8b1f64b..e56ea9eaf 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/settings/settings.dart'; -import 'package:aves/utils/durations.dart'; -import 'package:aves/widgets/common/aves_logo.dart'; -import 'package:aves/widgets/common/labeled_checkbox.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/basic/labeled_checkbox.dart'; +import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/pubspec.lock b/pubspec.lock index cc04bfa10..b83655a87 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,7 +21,7 @@ packages: name: ansicolor url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.1.1" archive: dependency: transitive description: @@ -288,6 +288,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" flutter_ijkplayer: dependency: "direct main" description: @@ -317,7 +324,7 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.5.0" + version: "0.5.1" flutter_native_timezone: dependency: "direct main" description: @@ -388,7 +395,14 @@ packages: name: google_maps_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "1.0.5" + highlight: + dependency: transitive + description: + name: highlight + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" http: dependency: transitive description: @@ -521,7 +535,7 @@ packages: name: node_interop url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.1" node_io: dependency: transitive description: @@ -633,7 +647,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.11.2" + version: "1.12.0" pedantic: dependency: "direct main" description: @@ -1034,7 +1048,7 @@ packages: source: hosted version: "0.9.0+5" uuid: - dependency: "direct main" + dependency: transitive description: name: uuid url: "https://pub.dartlang.org" @@ -1102,7 +1116,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "1.7.3" + version: "1.7.4" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1eef71a3a..4dffbafbc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.2.7+33 +version: 1.2.8+34 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) @@ -59,6 +59,7 @@ dependencies: firebase_analytics: firebase_crashlytics: flushbar: + flutter_highlight: flutter_ijkplayer: # path: ../flutter_ijkplayer git: @@ -92,7 +93,6 @@ dependencies: streams_channel: tuple: url_launcher: - uuid: dev_dependencies: flutter_test: diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index 28bf84b9f..35d4f5076 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -5,7 +5,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/mime_types.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 0057dfb10..c728d71c2 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -1,12 +1,13 @@ import 'package:aves/utils/geo_utils.dart'; +import 'package:latlong/latlong.dart'; import 'package:test/test.dart'; -import 'package:tuple/tuple.dart'; void main() { test('Decimal degrees to DMS (sexagesimal)', () { - expect(toDMS(Tuple2(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam - expect(toDMS(Tuple2(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund - expect(toDMS(Tuple2(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo - expect(toDMS(Tuple2(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam + expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund + expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo + expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); }); } diff --git a/test/utils/math_utils_test.dart b/test/utils/math_utils_test.dart new file mode 100644 index 000000000..a0440626f --- /dev/null +++ b/test/utils/math_utils_test.dart @@ -0,0 +1,36 @@ +import 'dart:math'; + +import 'package:aves/utils/math_utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('convert angles in radians to degrees', () { + expect(toDegrees(pi), 180); + expect(toDegrees(-pi / 2), -90); + }); + + test('convert angles in degrees to radians', () { + expect(toRadians(180), pi); + expect(toRadians(-270), pi * -3 / 2); + }); + + test('highest power of 2 that is smaller than or equal to the number', () { + expect(highestPowerOf2(1024), 1024); + expect(highestPowerOf2(42), 32); + expect(highestPowerOf2(0), 0); + expect(highestPowerOf2(-42), 0); + }); + + test('rounding to a given precision after the decimal', () { + expect(roundToPrecision(1.2345678, decimals: 3), 1.235); + expect(roundToPrecision(0, decimals: 3), 0); + }); + + test('rounding up to a given precision before the decimal', () { + expect(ceilBy(12345.678, 3), 13000); + expect(ceilBy(42, 3), 1000); + expect(ceilBy(0, 3), 0); + expect(ceilBy(-42, 3), 0); + expect(ceilBy(-12345.678, 3), -12000); + }); +} diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index a4f88aadb..d4ed49813 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -v1.2.7: -- subsampling and tiling of large images -- support for TIFF images (single page only) -- optional minimap in viewer overlay +v1.2.8: +- pinch to scale albums, countries & tags +- SVG source viewer +- improved detailed metadata layout Full changelog available on Github \ No newline at end of file