Merge branch 'develop'
This commit is contained in:
commit
3a3f336eb5
164 changed files with 2704 additions and 1431 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v1.2.7] - 2020-11-15
|
||||||
### Added
|
### Added
|
||||||
- Support for TIFF images (single page)
|
- Support for TIFF images (single page)
|
||||||
|
|
|
@ -98,7 +98,7 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
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 'androidx.exifinterface:exifinterface:1.3.1'
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||||
|
@ -109,6 +109,9 @@ dependencies {
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
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 'androidx.annotation:annotation:1.1.0'
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"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_id": "100907092477-ml1c4hr4l24ekg7l7nqid06n03kek6c8.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
@ -51,6 +59,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"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_id": "100907092477-u7sm8gp5t2sotn42oq20ufhtn3craodu.apps.googleusercontent.com",
|
||||||
"client_type": 3
|
"client_type": 3
|
||||||
|
@ -80,6 +96,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"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_id": "100907092477-u7sm8gp5t2sotn42oq20ufhtn3craodu.apps.googleusercontent.com",
|
||||||
"client_type": 3
|
"client_type": 3
|
||||||
|
|
|
@ -162,6 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
uri ?: return false
|
uri ?: return false
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.setDataAndType(uri, mimeType)
|
.setDataAndType(uri, mimeType)
|
||||||
return safeStartActivityChooser(title, intent)
|
return safeStartActivityChooser(title, intent)
|
||||||
}
|
}
|
||||||
|
@ -177,12 +178,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
uri ?: return false
|
uri ?: return false
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_ATTACH_DATA)
|
val intent = Intent(Intent.ACTION_ATTACH_DATA)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.setDataAndType(uri, mimeType)
|
.setDataAndType(uri, mimeType)
|
||||||
return safeStartActivityChooser(title, intent)
|
return safeStartActivityChooser(title, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean {
|
private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean {
|
||||||
val intent = Intent(Intent.ACTION_SEND)
|
val intent = Intent(Intent.ACTION_SEND)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.setType(mimeType)
|
.setType(mimeType)
|
||||||
when (uri.scheme?.toLowerCase(Locale.ROOT)) {
|
when (uri.scheme?.toLowerCase(Locale.ROOT)) {
|
||||||
ContentResolver.SCHEME_FILE -> {
|
ContentResolver.SCHEME_FILE -> {
|
||||||
|
@ -190,7 +193,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
val applicationId = context.applicationContext.packageName
|
val applicationId = context.applicationContext.packageName
|
||||||
val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
|
val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, apkUri)
|
intent.putExtra(Intent.EXTRA_STREAM, apkUri)
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
}
|
||||||
else -> intent.putExtra(Intent.EXTRA_STREAM, uri)
|
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)
|
val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
|
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
|
||||||
.setType(mimeType)
|
.setType(mimeType)
|
||||||
return safeStartActivityChooser(title, intent)
|
return safeStartActivityChooser(title, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun safeStartActivity(intent: Intent): Boolean {
|
private fun safeStartActivity(intent: Intent): Boolean {
|
||||||
val canResolve = intent.resolveActivity(context.packageManager) != null
|
if (intent.resolveActivity(context.packageManager) == null) return false
|
||||||
if (canResolve) {
|
try {
|
||||||
context.startActivity(intent)
|
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 {
|
private fun safeStartActivityChooser(title: String?, intent: Intent): Boolean {
|
||||||
val canResolve = intent.resolveActivity(context.packageManager) != null
|
if (intent.resolveActivity(context.packageManager) == null) return false
|
||||||
if (canResolve) {
|
try {
|
||||||
context.startActivity(Intent.createChooser(intent, title))
|
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 {
|
companion object {
|
||||||
|
|
|
@ -103,7 +103,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) {
|
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) {
|
||||||
// directory name
|
// 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())
|
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
|
||||||
metadataMap[dirName] = dirMap
|
metadataMap[dirName] = dirMap
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
result.success(volumes)
|
result.success(volumes)
|
||||||
}
|
}
|
||||||
|
"getFreeSpace" -> getFreeSpace(call, result)
|
||||||
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||||
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
||||||
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
|
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
|
||||||
|
@ -62,6 +63,35 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
return volumes
|
return volumes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val path = call.argument<String>("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) {
|
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val dirPaths = call.argument<List<String>>("dirPaths")
|
val dirPaths = call.argument<List<String>>("dirPaths")
|
||||||
if (dirPaths == null) {
|
if (dirPaths == null) {
|
||||||
|
|
|
@ -217,8 +217,11 @@ object ExifInterfaceHelper {
|
||||||
// so that we can rely on metadata-extractor descriptions
|
// so that we can rely on metadata-extractor descriptions
|
||||||
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
|
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
|
||||||
|
|
||||||
|
// exclude Exif directory when it only includes image size
|
||||||
|
val isUselessExif: (Map<String, String>) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") }
|
||||||
|
|
||||||
return HashMap<String, Map<String, String>>().apply {
|
return HashMap<String, Map<String, String>>().apply {
|
||||||
put("Exif", describeDir(exif, dirs, baseTags))
|
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
|
||||||
put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags))
|
put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags))
|
||||||
put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags))
|
put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags))
|
||||||
put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags))
|
put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags))
|
||||||
|
|
|
@ -122,17 +122,15 @@ class SourceImageEntry {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fillVideoByMediaMetadataRetriever(context)
|
fillVideoByMediaMetadataRetriever(context)
|
||||||
if (isSized && hasDuration) return this
|
if (isSized && hasDuration) return this
|
||||||
}
|
fillByMetadataExtractor(context)
|
||||||
// skip metadata-extractor for raw images because it reports the decoded dimensions instead of the raw dimensions
|
} else {
|
||||||
if (!MimeTypes.isRaw(sourceMimeType) && MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
|
|
||||||
fillByMetadataExtractor(context)
|
fillByMetadataExtractor(context)
|
||||||
if (isSized && foundExif) return this
|
if (isSized && foundExif) return this
|
||||||
}
|
|
||||||
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
|
|
||||||
fillByExifInterface(context)
|
fillByExifInterface(context)
|
||||||
if (isSized) return this
|
|
||||||
}
|
}
|
||||||
fillByBitmapDecode(context)
|
if (!isSized) {
|
||||||
|
fillByBitmapDecode(context)
|
||||||
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,6 +154,9 @@ class SourceImageEntry {
|
||||||
|
|
||||||
// finds: width, height, orientation, date, duration
|
// finds: width, height, orientation, date, duration
|
||||||
private fun fillByMetadataExtractor(context: Context) {
|
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 {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
@ -206,6 +207,8 @@ class SourceImageEntry {
|
||||||
|
|
||||||
// finds: width, height, orientation, date
|
// finds: width, height, orientation, date
|
||||||
private fun fillByExifInterface(context: Context) {
|
private fun fillByExifInterface(context: Context) {
|
||||||
|
if (!ExifInterface.isSupportedMimeType(sourceMimeType)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
|
|
|
@ -182,7 +182,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newFields.isEmpty()) {
|
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 {
|
} else {
|
||||||
cont.resume(newFields)
|
cont.resume(newFields)
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,10 +47,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
|
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
|
||||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
|
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
|
// without an equivalent image/video if it is shared from a file browser
|
||||||
// but the file is not publicly visible
|
// 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"))
|
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
|
||||||
}
|
}
|
||||||
|
@ -87,6 +87,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
handleNewEntry: NewEntryHandler,
|
handleNewEntry: NewEntryHandler,
|
||||||
contentUri: Uri,
|
contentUri: Uri,
|
||||||
projection: Array<String>,
|
projection: Array<String>,
|
||||||
|
fileMimeType: String? = null,
|
||||||
): Int {
|
): Int {
|
||||||
var newEntryCount = 0
|
var newEntryCount = 0
|
||||||
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||||
|
@ -123,45 +124,51 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// for multiple items, `contentUri` is the root without ID,
|
// for multiple items, `contentUri` is the root without ID,
|
||||||
// but for single items, `contentUri` already contains the ID
|
// but for single items, `contentUri` already contains the ID
|
||||||
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, contentId.toLong())
|
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 width = cursor.getInt(widthColumn)
|
||||||
val height = cursor.getInt(heightColumn)
|
val height = cursor.getInt(heightColumn)
|
||||||
val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L
|
val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L
|
||||||
|
|
||||||
var entryMap: FieldMap = hashMapOf(
|
if (mimeType == null) {
|
||||||
"uri" to itemUri.toString(),
|
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||||
"path" to cursor.getString(pathColumn),
|
} else {
|
||||||
"sourceMimeType" to mimeType,
|
var entryMap: FieldMap = hashMapOf(
|
||||||
"width" to width,
|
"uri" to itemUri.toString(),
|
||||||
"height" to height,
|
"path" to cursor.getString(pathColumn),
|
||||||
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
"sourceMimeType" to mimeType,
|
||||||
"sizeBytes" to cursor.getLong(sizeColumn),
|
"width" to width,
|
||||||
"title" to cursor.getString(titleColumn),
|
"height" to height,
|
||||||
"dateModifiedSecs" to dateModifiedSecs,
|
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||||
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
"sizeBytes" to cursor.getLong(sizeColumn),
|
||||||
"durationMillis" to durationMillis,
|
"title" to cursor.getString(titleColumn),
|
||||||
// only for map export
|
"dateModifiedSecs" to dateModifiedSecs,
|
||||||
"contentId" to contentId,
|
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||||
)
|
"durationMillis" to durationMillis,
|
||||||
|
// only for map export
|
||||||
|
"contentId" to contentId,
|
||||||
|
)
|
||||||
|
|
||||||
if (MimeTypes.isRaw(mimeType)
|
if (MimeTypes.isRaw(mimeType)
|
||||||
|| (width <= 0 || height <= 0) && needSize(mimeType)
|
|| (width <= 0 || height <= 0) && needSize(mimeType)
|
||||||
|| durationMillis == 0L && needDuration
|
|| durationMillis == 0L && needDuration
|
||||||
) {
|
) {
|
||||||
// Some images are incorrectly registered in the Media Store,
|
// Some images are incorrectly registered in the Media Store,
|
||||||
// missing some attributes such as width, height, orientation.
|
// missing some attributes such as width, height, orientation.
|
||||||
// Also, the reported size of raw images is inconsistent across devices
|
// Also, the reported size of raw images is inconsistent across devices
|
||||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||||
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
|
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
|
||||||
entryMap = entry.toMap()
|
entryMap = entry.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewEntry(entryMap)
|
handleNewEntry(entryMap)
|
||||||
// TODO TLAD is this necessary?
|
// TODO TLAD is this necessary?
|
||||||
if (newEntryCount % 30 == 0) {
|
if (newEntryCount % 30 == 0) {
|
||||||
delay(10)
|
delay(10)
|
||||||
|
}
|
||||||
|
newEntryCount++
|
||||||
}
|
}
|
||||||
newEntryCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
@ -314,7 +321,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
MediaStore.MediaColumns._ID,
|
MediaStore.MediaColumns._ID,
|
||||||
MediaColumns.PATH,
|
MediaColumns.PATH,
|
||||||
MediaStore.MediaColumns.MIME_TYPE,
|
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.TITLE,
|
||||||
MediaStore.MediaColumns.WIDTH,
|
MediaStore.MediaColumns.WIDTH,
|
||||||
MediaStore.MediaColumns.HEIGHT,
|
MediaStore.MediaColumns.HEIGHT,
|
||||||
|
|
|
@ -44,7 +44,7 @@ object MimeTypes {
|
||||||
else -> isVideo(mimeType)
|
else -> isVideo(mimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isRaw(mimeType: String?): Boolean {
|
fun isRaw(mimeType: String): Boolean {
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
|
ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
|
||||||
else -> false
|
else -> false
|
||||||
|
|
|
@ -6,11 +6,11 @@ buildscript {
|
||||||
jcenter()
|
jcenter()
|
||||||
}
|
}
|
||||||
dependencies {
|
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 'com.android.tools.build:gradle:3.6.4'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.4'
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,10 +8,13 @@ class UriPicture extends PictureProvider<UriPicture> {
|
||||||
const UriPicture({
|
const UriPicture({
|
||||||
@required this.uri,
|
@required this.uri,
|
||||||
@required this.mimeType,
|
@required this.mimeType,
|
||||||
|
this.colorFilter,
|
||||||
}) : assert(uri != null);
|
}) : assert(uri != null);
|
||||||
|
|
||||||
final String uri, mimeType;
|
final String uri, mimeType;
|
||||||
|
|
||||||
|
final ColorFilter colorFilter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<UriPicture> obtainKey(PictureConfiguration configuration) {
|
Future<UriPicture> obtainKey(PictureConfiguration configuration) {
|
||||||
return SynchronousFuture<UriPicture>(this);
|
return SynchronousFuture<UriPicture>(this);
|
||||||
|
@ -34,22 +37,22 @@ class UriPicture extends PictureProvider<UriPicture> {
|
||||||
|
|
||||||
final decoder = SvgPicture.svgByteDecoder;
|
final decoder = SvgPicture.svgByteDecoder;
|
||||||
if (onError != null) {
|
if (onError != null) {
|
||||||
final future = decoder(data, null, key.toString());
|
final future = decoder(data, colorFilter, key.toString());
|
||||||
unawaited(future.catchError(onError));
|
unawaited(future.catchError(onError));
|
||||||
return future;
|
return future;
|
||||||
}
|
}
|
||||||
return decoder(data, null, key.toString());
|
return decoder(data, colorFilter, key.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is UriPicture && other.uri == uri;
|
return other is UriPicture && other.uri == uri && other.colorFilter == colorFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => uri.hashCode;
|
int get hashCode => hashValues(uri, colorFilter);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType)';
|
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)';
|
||||||
}
|
}
|
|
@ -2,10 +2,10 @@ import 'dart:isolate';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/utils/route_tracker.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/data_providers/settings_provider.dart';
|
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/routes.dart';
|
import 'package:aves/widgets/common/providers/settings_provider.dart';
|
||||||
import 'package:aves/widgets/home_page.dart';
|
import 'package:aves/widgets/home_page.dart';
|
||||||
import 'package:aves/widgets/welcome_page.dart';
|
import 'package:aves/widgets/welcome_page.dart';
|
||||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
@ -57,6 +57,7 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
accentColor: accentColor,
|
accentColor: accentColor,
|
||||||
scaffoldBackgroundColor: Colors.grey[900],
|
scaffoldBackgroundColor: Colors.grey[900],
|
||||||
buttonColor: accentColor,
|
buttonColor: accentColor,
|
||||||
|
dialogBackgroundColor: Colors.grey[850],
|
||||||
toggleableActiveColor: accentColor,
|
toggleableActiveColor: accentColor,
|
||||||
tooltipTheme: TooltipThemeData(
|
tooltipTheme: TooltipThemeData(
|
||||||
verticalOffset: 32,
|
verticalOffset: 32,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
enum ChipSetAction {
|
enum ChipSetAction {
|
|
@ -1,13 +1,14 @@
|
||||||
enum CollectionAction {
|
enum CollectionAction {
|
||||||
addShortcut,
|
addShortcut,
|
||||||
copy,
|
sort,
|
||||||
group,
|
group,
|
||||||
move,
|
|
||||||
refresh,
|
refresh,
|
||||||
refreshMetadata,
|
|
||||||
select,
|
select,
|
||||||
selectAll,
|
selectAll,
|
||||||
selectNone,
|
selectNone,
|
||||||
sort,
|
|
||||||
stats,
|
stats,
|
||||||
|
// apply to entry set
|
||||||
|
copy,
|
||||||
|
move,
|
||||||
|
refreshMetadata,
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
enum EntryAction {
|
enum EntryAction {
|
||||||
delete,
|
delete,
|
||||||
|
@ -15,6 +15,7 @@ enum EntryAction {
|
||||||
setAs,
|
setAs,
|
||||||
share,
|
share,
|
||||||
toggleFavourite,
|
toggleFavourite,
|
||||||
|
viewSource,
|
||||||
debug,
|
debug,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +32,7 @@ class EntryActions {
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
|
EntryAction.viewSource,
|
||||||
];
|
];
|
||||||
|
|
||||||
static const externalApp = [
|
static const externalApp = [
|
||||||
|
@ -64,6 +66,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return 'Print';
|
return 'Print';
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
return 'Share';
|
return 'Share';
|
||||||
|
case EntryAction.viewSource:
|
||||||
|
return 'View source';
|
||||||
// external app actions
|
// external app actions
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
return 'Edit with…';
|
return 'Edit with…';
|
||||||
|
@ -101,6 +105,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return AIcons.print;
|
return AIcons.print;
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
return AIcons.share;
|
return AIcons.share;
|
||||||
|
case EntryAction.viewSource:
|
||||||
|
return AIcons.vector;
|
||||||
// external app actions
|
// external app actions
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
case EntryAction.open:
|
case EntryAction.open:
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||||
|
|
||||||
class EntryCache {
|
class EntryCache {
|
||||||
static Future<void> evict(
|
static Future<void> evict(
|
||||||
|
|
|
@ -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/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/mime_types.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/utils/mime_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ class MimeFilter extends CollectionFilter {
|
||||||
_label ??= lowMime.split('/')[0].toUpperCase();
|
_label ??= lowMime.split('/')[0].toUpperCase();
|
||||||
} else {
|
} else {
|
||||||
_filter = (entry) => entry.mimeType == lowMime;
|
_filter = (entry) => entry.mimeType == lowMime;
|
||||||
_label = MimeTypes.displayType(lowMime);
|
_label = MimeUtils.displayType(lowMime);
|
||||||
}
|
}
|
||||||
_icon ??= AIcons.vector;
|
_icon ??= AIcons.vector;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
|
24
lib/model/highlight.dart
Normal file
24
lib/model/highlight.dart
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class HighlightInfo extends ChangeNotifier {
|
||||||
|
final Queue<Object> _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);
|
||||||
|
}
|
|
@ -8,21 +8,24 @@ import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
import 'package:aves/utils/time_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geocoder/geocoder.dart';
|
import 'package:geocoder/geocoder.dart';
|
||||||
|
import 'package:latlong/latlong.dart';
|
||||||
import 'package:path/path.dart' as ppath;
|
import 'package:path/path.dart' as ppath;
|
||||||
import 'package:tuple/tuple.dart';
|
|
||||||
|
|
||||||
import 'mime_types.dart';
|
import '../ref/mime_types.dart';
|
||||||
|
|
||||||
class ImageEntry {
|
class ImageEntry {
|
||||||
String uri;
|
String uri;
|
||||||
String _path, _directory, _filename, _extension;
|
String _path, _directory, _filename, _extension;
|
||||||
int contentId;
|
int contentId;
|
||||||
final String sourceMimeType;
|
final String sourceMimeType;
|
||||||
|
|
||||||
|
// TODO TLAD use SVG viewport as width/height
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int sourceRotationDegrees;
|
int sourceRotationDegrees;
|
||||||
|
@ -37,6 +40,9 @@ class ImageEntry {
|
||||||
|
|
||||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||||
|
|
||||||
|
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||||
|
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
|
||||||
|
|
||||||
ImageEntry({
|
ImageEntry({
|
||||||
this.uri,
|
this.uri,
|
||||||
String path,
|
String path,
|
||||||
|
@ -56,7 +62,7 @@ class ImageEntry {
|
||||||
this.dateModifiedSecs = dateModifiedSecs;
|
this.dateModifiedSecs = dateModifiedSecs;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get canDecode => !MimeTypes.undecodable.contains(mimeType);
|
bool get canDecode => !undecodable.contains(mimeType);
|
||||||
|
|
||||||
ImageEntry copyWith({
|
ImageEntry copyWith({
|
||||||
@required String uri,
|
@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 {
|
String get resolutionText {
|
||||||
final w = width ?? '?';
|
final w = width ?? '?';
|
||||||
|
@ -288,9 +299,14 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get isLocated => _addressDetails != null;
|
bool get isLocated => _addressDetails != null;
|
||||||
|
|
||||||
Tuple2<double, double> 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<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
||||||
|
|
||||||
|
@ -359,7 +375,6 @@ class ImageEntry {
|
||||||
final address = addresses.first;
|
final address = addresses.first;
|
||||||
addressDetails = AddressDetails(
|
addressDetails = AddressDetails(
|
||||||
contentId: contentId,
|
contentId: contentId,
|
||||||
addressLine: address.addressLine,
|
|
||||||
countryCode: address.countryCode,
|
countryCode: address.countryCode,
|
||||||
countryName: address.countryName,
|
countryName: address.countryName,
|
||||||
adminArea: address.adminArea,
|
adminArea: address.adminArea,
|
||||||
|
@ -371,11 +386,29 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> 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 {
|
String get shortAddress {
|
||||||
if (!isLocated) return '';
|
if (!isLocated) return '';
|
||||||
|
|
||||||
// admin area examples: Seoul, Geneva, null
|
// `admin area` examples: Seoul, Geneva, null
|
||||||
// locality examples: Mapo-gu, Geneva, Annecy
|
// `locality` examples: Mapo-gu, Geneva, Annecy
|
||||||
return {
|
return {
|
||||||
_addressDetails.countryName,
|
_addressDetails.countryName,
|
||||||
_addressDetails.adminArea,
|
_addressDetails.adminArea,
|
||||||
|
@ -383,12 +416,13 @@ class ImageEntry {
|
||||||
}.where((part) => part != null && part.isNotEmpty).join(', ');
|
}.where((part) => part != null && part.isNotEmpty).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
bool search(String query) {
|
bool search(String query) => {
|
||||||
if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true;
|
bestTitle,
|
||||||
if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true;
|
_catalogMetadata?.xmpSubjects,
|
||||||
if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true;
|
_addressDetails?.countryName,
|
||||||
return false;
|
_addressDetails?.adminArea,
|
||||||
}
|
_addressDetails?.locality,
|
||||||
|
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||||||
|
|
||||||
Future<void> _applyNewFields(Map newFields) async {
|
Future<void> _applyNewFields(Map newFields) async {
|
||||||
final uri = newFields['uri'];
|
final uri = newFields['uri'];
|
||||||
|
|
|
@ -142,13 +142,12 @@ class OverlayMetadata {
|
||||||
|
|
||||||
class AddressDetails {
|
class AddressDetails {
|
||||||
final int contentId;
|
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;
|
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
|
||||||
|
|
||||||
AddressDetails({
|
AddressDetails({
|
||||||
this.contentId,
|
this.contentId,
|
||||||
this.addressLine,
|
|
||||||
this.countryCode,
|
this.countryCode,
|
||||||
this.countryName,
|
this.countryName,
|
||||||
this.adminArea,
|
this.adminArea,
|
||||||
|
@ -160,7 +159,6 @@ class AddressDetails {
|
||||||
}) {
|
}) {
|
||||||
return AddressDetails(
|
return AddressDetails(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
||||||
addressLine: addressLine,
|
|
||||||
countryCode: countryCode,
|
countryCode: countryCode,
|
||||||
countryName: countryName,
|
countryName: countryName,
|
||||||
adminArea: adminArea,
|
adminArea: adminArea,
|
||||||
|
@ -171,7 +169,6 @@ class AddressDetails {
|
||||||
factory AddressDetails.fromMap(Map map) {
|
factory AddressDetails.fromMap(Map map) {
|
||||||
return AddressDetails(
|
return AddressDetails(
|
||||||
contentId: map['contentId'],
|
contentId: map['contentId'],
|
||||||
addressLine: map['addressLine'] ?? '',
|
|
||||||
countryCode: map['countryCode'] ?? '',
|
countryCode: map['countryCode'] ?? '',
|
||||||
countryName: map['countryName'] ?? '',
|
countryName: map['countryName'] ?? '',
|
||||||
adminArea: map['adminArea'] ?? '',
|
adminArea: map['adminArea'] ?? '',
|
||||||
|
@ -181,7 +178,6 @@ class AddressDetails {
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'contentId': contentId,
|
'contentId': contentId,
|
||||||
'addressLine': addressLine,
|
|
||||||
'countryCode': countryCode,
|
'countryCode': countryCode,
|
||||||
'countryName': countryName,
|
'countryName': countryName,
|
||||||
'adminArea': adminArea,
|
'adminArea': adminArea,
|
||||||
|
@ -190,7 +186,7 @@ class AddressDetails {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -143,7 +143,7 @@ class MetadataDb {
|
||||||
await init();
|
await init();
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeIds(List<int> contentIds) async {
|
void removeIds(Set<int> contentIds, {@required bool updateFavourites}) async {
|
||||||
if (contentIds == null || contentIds.isEmpty) return;
|
if (contentIds == null || contentIds.isEmpty) return;
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
@ -157,7 +157,9 @@ class MetadataDb {
|
||||||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(addressTable, 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);
|
await batch.commit(noResult: true);
|
||||||
debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/utils/geo_utils.dart';
|
import 'package:aves/utils/geo_utils.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:latlong/latlong.dart';
|
||||||
|
|
||||||
enum CoordinateFormat { dms, decimal }
|
enum CoordinateFormat { dms, decimal }
|
||||||
|
|
||||||
|
@ -15,12 +15,12 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String format(Tuple2<double, double> latLng) {
|
String format(LatLng latLng) {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case CoordinateFormat.dms:
|
case CoordinateFormat.dms:
|
||||||
return toDMS(latLng).join(', ');
|
return toDMS(latLng).join(', ');
|
||||||
case CoordinateFormat.decimal:
|
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:
|
default:
|
||||||
return toString();
|
return toString();
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,11 +29,11 @@ class Settings extends ChangeNotifier {
|
||||||
static const keepScreenOnKey = 'keep_screen_on';
|
static const keepScreenOnKey = 'keep_screen_on';
|
||||||
static const homePageKey = 'home_page';
|
static const homePageKey = 'home_page';
|
||||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||||
|
static const tileExtentPrefixKey = 'tile_extent_';
|
||||||
|
|
||||||
// collection
|
// collection
|
||||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||||
static const collectionTileExtentKey = 'collection_tile_extent';
|
|
||||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||||
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
||||||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||||
|
@ -112,6 +112,12 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
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
|
// collection
|
||||||
|
|
||||||
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
|
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());
|
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);
|
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
|
||||||
|
|
||||||
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
||||||
|
@ -198,14 +198,6 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
|
set searchHistory(List<CollectionFilter> 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
|
// convenience methods
|
||||||
|
|
||||||
// ignore: avoid_positional_boolean_parameters
|
// ignore: avoid_positional_boolean_parameters
|
||||||
|
|
|
@ -4,7 +4,6 @@ import 'dart:collection';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.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/collection_source.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
|
@ -19,7 +18,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
EntryGroupFactor groupFactor;
|
EntryGroupFactor groupFactor;
|
||||||
EntrySortFactor sortFactor;
|
EntrySortFactor sortFactor;
|
||||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
||||||
final StreamController<ImageEntry> _highlightController = StreamController.broadcast();
|
|
||||||
|
|
||||||
List<ImageEntry> _filteredEntries;
|
List<ImageEntry> _filteredEntries;
|
||||||
List<StreamSubscription> _subscriptions = [];
|
List<StreamSubscription> _subscriptions = [];
|
||||||
|
@ -50,14 +48,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
factory CollectionLens.empty() {
|
|
||||||
return CollectionLens(
|
|
||||||
source: CollectionSource(),
|
|
||||||
groupFactor: settings.collectionGroupFactor,
|
|
||||||
sortFactor: settings.collectionSortFactor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CollectionLens derive(CollectionFilter filter) {
|
CollectionLens derive(CollectionFilter filter) {
|
||||||
return CollectionLens(
|
return CollectionLens(
|
||||||
source: source,
|
source: source,
|
||||||
|
@ -79,10 +69,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
return _sortedEntries;
|
return _sortedEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageEntry> get highlightStream => _highlightController.stream;
|
|
||||||
|
|
||||||
void highlight(ImageEntry entry) => _highlightController.add(entry);
|
|
||||||
|
|
||||||
bool get showHeaders {
|
bool get showHeaders {
|
||||||
if (sortFactor == EntrySortFactor.size) return false;
|
if (sortFactor == EntrySortFactor.size) return false;
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ mixin SourceBase {
|
||||||
void setProgress({@required int done, @required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
|
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
|
@override
|
||||||
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens(
|
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens(
|
||||||
source: this,
|
source: this,
|
||||||
|
@ -45,7 +45,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||||
sortFactor: EntrySortFactor.date,
|
sortFactor: EntrySortFactor.date,
|
||||||
).sortedEntries;
|
).sortedEntries;
|
||||||
|
|
||||||
ValueNotifier<SourceState> stateNotifier = ValueNotifier<SourceState>(SourceState.ready);
|
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
|
||||||
|
|
||||||
List<DateMetadata> _savedDates;
|
List<DateMetadata> _savedDates;
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateAfterMove({
|
void updateAfterMove({
|
||||||
@required List<ImageEntry> selection,
|
@required Set<ImageEntry> selection,
|
||||||
@required bool copy,
|
@required bool copy,
|
||||||
@required String destinationAlbum,
|
@required String destinationAlbum,
|
||||||
@required Iterable<MoveOpEvent> movedOps,
|
@required Iterable<MoveOpEvent> movedOps,
|
||||||
|
@ -163,6 +163,10 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||||
int count(CollectionFilter filter) {
|
int count(CollectionFilter filter) {
|
||||||
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refresh();
|
||||||
|
|
||||||
|
Future<void> refreshMetadata(Set<ImageEntry> entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SourceState { loading, cataloguing, locating, ready }
|
enum SourceState { loading, cataloguing, locating, ready }
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
|
@ -30,15 +32,25 @@ mixin LocationMixin on SourceBase {
|
||||||
final todo = byLocated[false] ?? [];
|
final todo = byLocated[false] ?? [];
|
||||||
if (todo.isEmpty) return;
|
if (todo.isEmpty) return;
|
||||||
|
|
||||||
// cache known locations to avoid querying the geocoder unless necessary
|
// geocoder calls take between 150ms and 250ms
|
||||||
// measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates)
|
// approximation and caching can reduce geocoder usage
|
||||||
// does not clearly show whether it is an actual optimization,
|
// for example, for a set of 2932 entries:
|
||||||
// as results vary wildly (durations in "min:sec"):
|
// - 2476 calls (84%) when approximating to 6 decimal places (~10cm - individual humans)
|
||||||
// - with no cache: 06:17, 08:36, 08:34
|
// - 2433 calls (83%) when approximating to 5 decimal places (~1m - individual trees, houses)
|
||||||
// - with cache: 08:28, 05:42, 08:03, 05:58
|
// - 2277 calls (78%) when approximating to 4 decimal places (~10m - individual street, large buildings)
|
||||||
// anyway, in theory it should help!
|
// - 1521 calls (52%) when approximating to 3 decimal places (~100m - neighborhood, street)
|
||||||
final knownLocations = <Tuple2<double, double>, AddressDetails>{};
|
// - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
|
||||||
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails));
|
// 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 = <Tuple2, AddressDetails>{};
|
||||||
|
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
|
||||||
|
|
||||||
var progressDone = 0;
|
var progressDone = 0;
|
||||||
final progressTotal = todo.length;
|
final progressTotal = todo.length;
|
||||||
|
@ -46,13 +58,14 @@ mixin LocationMixin on SourceBase {
|
||||||
|
|
||||||
final newAddresses = <AddressDetails>[];
|
final newAddresses = <AddressDetails>[];
|
||||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
await Future.forEach<ImageEntry>(todo, (entry) async {
|
||||||
if (knownLocations.containsKey(entry.latLng)) {
|
final latLng = approximateLatLng(entry);
|
||||||
entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId);
|
if (knownLocations.containsKey(latLng)) {
|
||||||
|
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||||
} else {
|
} else {
|
||||||
await entry.locate(background: true);
|
await entry.locate(background: true);
|
||||||
// it is intended to insert `null` if the geocoder failed,
|
// it is intended to insert `null` if the geocoder failed,
|
||||||
// so that we skip geocoding of following entries with the same coordinates
|
// so that we skip geocoding of following entries with the same coordinates
|
||||||
knownLocations[entry.latLng] = entry.addressDetails;
|
knownLocations[latLng] = entry.addressDetails;
|
||||||
}
|
}
|
||||||
if (entry.isLocated) {
|
if (entry.isLocated) {
|
||||||
newAddresses.add(entry.addressDetails);
|
newAddresses.add(entry.addressDetails);
|
||||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/image_file_service.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:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_native_timezone/flutter_native_timezone.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}');
|
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
debugPrint('$runtimeType refresh start');
|
debugPrint('$runtimeType refresh start');
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
|
clearEntries();
|
||||||
|
|
||||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||||
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
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));
|
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
|
@ -47,7 +50,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
metadataDb.removeIds(obsoleteEntries);
|
metadataDb.removeIds(obsoleteEntries, updateFavourites: true);
|
||||||
|
|
||||||
// fetch new entries
|
// fetch new entries
|
||||||
var refreshCount = 10;
|
var refreshCount = 10;
|
||||||
|
@ -92,9 +95,10 @@ class MediaStoreSource extends CollectionSource {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// e.g. x=12345, precision=3 should return 13000
|
@override
|
||||||
int ceilBy(num x, int precision) {
|
Future<void> refreshMetadata(Set<ImageEntry> entries) {
|
||||||
final factor = pow(10, precision);
|
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||||
return (x / factor).ceil() * factor;
|
metadataDb.removeIds(contentIds, updateFavourites: false);
|
||||||
|
return refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
21
lib/ref/brand_colors.dart
Normal file
21
lib/ref/brand_colors.dart
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,17 +41,4 @@ class MimeTypes {
|
||||||
|
|
||||||
// groups
|
// groups
|
||||||
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
|
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
|
||||||
static const List<String> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
30
lib/ref/xmp.dart
Normal file
30
lib/ref/xmp.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
class XMP {
|
||||||
|
static const namespaceSeparator = ':';
|
||||||
|
static const structFieldSeparator = '/';
|
||||||
|
|
||||||
|
// cf https://exiftool.org/TagNames/XMP.html
|
||||||
|
static const Map<String, String> 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',
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:streams_channel/streams_channel.dart';
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
@ -18,6 +19,18 @@ class AndroidFileService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<int> getFreeSpace(StorageVolume volume) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
|
||||||
|
'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<List<String>> getGrantedDirectories() async {
|
static Future<List<String>> getGrantedDirectories() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getGrantedDirectories');
|
final result = await platform.invokeMethod('getGrantedDirectories');
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
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:aves/services/service_policy.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -12,8 +12,13 @@ class Durations {
|
||||||
static const staggeredAnimation = Duration(milliseconds: 375);
|
static const staggeredAnimation = Duration(milliseconds: 375);
|
||||||
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
|
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
|
||||||
|
|
||||||
// collection animations
|
|
||||||
static const appBarTitleAnimation = Duration(milliseconds: 300);
|
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 filterBarRemovalAnimation = Duration(milliseconds: 400);
|
||||||
static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
|
static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
|
||||||
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
|
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
|
||||||
|
@ -40,4 +45,5 @@ class Durations {
|
||||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||||
|
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||||
}
|
}
|
67
lib/theme/icons.dart
Normal file
67
lib/theme/icons.dart
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:latlong/latlong.dart';
|
||||||
|
|
||||||
class Constants {
|
class Constants {
|
||||||
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
|
// 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 overlayUnknown = '—'; // em dash
|
||||||
static const String infoUnknown = 'unknown';
|
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 int infoGroupMaxValueLength = 140;
|
||||||
|
|
||||||
static const List<Dependency> androidDependencies = [
|
static const List<Dependency> 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(
|
Dependency(
|
||||||
name: 'Android-TiffBitmapFactory',
|
name: 'Android-TiffBitmapFactory',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
@ -96,7 +108,7 @@ class Constants {
|
||||||
sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
|
sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'FlutterFire',
|
name: 'FlutterFire (Core, Analytics, Crashlytics)',
|
||||||
license: 'BSD 3-Clause',
|
license: 'BSD 3-Clause',
|
||||||
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||||
|
@ -107,6 +119,12 @@ class Constants {
|
||||||
licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/AndreHaueisen/flushbar',
|
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(
|
Dependency(
|
||||||
name: 'Flutter ijkplayer',
|
name: 'Flutter ijkplayer',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
@ -180,7 +198,7 @@ class Constants {
|
||||||
sourceUrl: 'https://github.com/boyan01/overlay_support',
|
sourceUrl: 'https://github.com/boyan01/overlay_support',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Package info',
|
name: 'Package Info',
|
||||||
license: 'BSD 3-Clause',
|
license: 'BSD 3-Clause',
|
||||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE',
|
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE',
|
||||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info',
|
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',
|
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',
|
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',
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
16
lib/utils/debouncer.dart
Normal file
16
lib/utils/debouncer.dart
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
extension ExtraContext on BuildContext {
|
|
||||||
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
|
|
||||||
}
|
|
|
@ -1,15 +1,12 @@
|
||||||
import 'dart:math';
|
import 'package:aves/utils/math_utils.dart';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:latlong/latlong.dart';
|
||||||
|
|
||||||
String _decimal2sexagesimal(final double degDecimal) {
|
String _decimal2sexagesimal(final double degDecimal) {
|
||||||
double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals);
|
|
||||||
|
|
||||||
List<int> _split(final double value) {
|
List<int> _split(final double value) {
|
||||||
// NumberFormat is necessary to create digit after comma if the value
|
// NumberFormat is necessary to create digit after comma if the value
|
||||||
// has no decimal point (only necessary for browser)
|
// 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>[
|
return <int>[
|
||||||
int.parse(tmp[0]).abs(),
|
int.parse(tmp[0]).abs(),
|
||||||
int.parse(tmp[1]),
|
int.parse(tmp[1]),
|
||||||
|
@ -21,14 +18,14 @@ String _decimal2sexagesimal(final double degDecimal) {
|
||||||
final min = _split(minDecimal)[0];
|
final min = _split(minDecimal)[0];
|
||||||
final sec = (minDecimal - min) * 60;
|
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']
|
// return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||||
List<String> toDMS(Tuple2<double, double> latLng) {
|
List<String> toDMS(LatLng latLng) {
|
||||||
if (latLng == null) return [];
|
if (latLng == null) return [];
|
||||||
final lat = latLng.item1;
|
final lat = latLng.latitude;
|
||||||
final lng = latLng.item2;
|
final lng = latLng.longitude;
|
||||||
return [
|
return [
|
||||||
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
|
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
|
||||||
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',
|
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
import 'dart:math';
|
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 toDegrees(num radians) => radians / _piOver180;
|
||||||
|
|
||||||
double toRadians(num degrees) => degrees * _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;
|
||||||
|
}
|
||||||
|
|
20
lib/utils/mime_utils.dart
Normal file
20
lib/utils/mime_utils.dart
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,8 @@
|
||||||
import 'package:aves/flutter_version.dart';
|
import 'package:aves/flutter_version.dart';
|
||||||
import 'package:aves/widgets/about/licenses.dart';
|
import 'package:aves/widgets/about/licenses.dart';
|
||||||
import 'package:aves/widgets/common/aves_logo.dart';
|
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||||
import 'package:aves/widgets/common/link_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_logo.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
|
||||||
import 'package:package_info/package_info.dart';
|
import 'package:package_info/package_info.dart';
|
||||||
|
|
||||||
class AboutPage extends StatelessWidget {
|
class AboutPage extends StatelessWidget {
|
||||||
|
@ -16,24 +15,58 @@ class AboutPage extends StatelessWidget {
|
||||||
title: Text('About'),
|
title: Text('About'),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: AnimationLimiter(
|
child: CustomScrollView(
|
||||||
child: CustomScrollView(
|
slivers: [
|
||||||
slivers: [
|
SliverPadding(
|
||||||
SliverPadding(
|
padding: EdgeInsets.only(top: 16),
|
||||||
padding: EdgeInsets.only(top: 16),
|
sliver: SliverList(
|
||||||
sliver: SliverList(
|
delegate: SliverChildListDelegate(
|
||||||
delegate: SliverChildListDelegate(
|
[
|
||||||
[
|
AppReference(),
|
||||||
AppReference(),
|
SizedBox(height: 16),
|
||||||
SizedBox(height: 16),
|
Divider(),
|
||||||
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(),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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/constants.dart';
|
||||||
import 'package:aves/utils/durations.dart';
|
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||||
import 'package:aves/widgets/common/link_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/common/menu_row.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
|
||||||
|
|
||||||
class Licenses extends StatefulWidget {
|
class Licenses extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
@ -13,18 +13,20 @@ class Licenses extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LicensesState extends State<Licenses> {
|
class _LicensesState extends State<Licenses> {
|
||||||
|
final ValueNotifier<String> _expandedNotifier = ValueNotifier(null);
|
||||||
LicenseSort _sort = LicenseSort.name;
|
LicenseSort _sort = LicenseSort.name;
|
||||||
List<Dependency> _packages;
|
List<Dependency> _platform, _flutter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_packages = [...Constants.androidDependencies, ...Constants.flutterPackages];
|
_platform = List.from(Constants.androidDependencies);
|
||||||
|
_flutter = List.from(Constants.flutterPackages);
|
||||||
_sortPackages();
|
_sortPackages();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sortPackages() {
|
void _sortPackages() {
|
||||||
_packages.sort((a, b) {
|
int compare(Dependency a, Dependency b) {
|
||||||
switch (_sort) {
|
switch (_sort) {
|
||||||
case LicenseSort.license:
|
case LicenseSort.license:
|
||||||
final c = compareAsciiUpperCase(a.license, b.license);
|
final c = compareAsciiUpperCase(a.license, b.license);
|
||||||
|
@ -33,7 +35,10 @@ class _LicensesState extends State<Licenses> {
|
||||||
default:
|
default:
|
||||||
return compareAsciiUpperCase(a.name, b.name);
|
return compareAsciiUpperCase(a.name, b.name);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
_platform.sort(compare);
|
||||||
|
_flutter.sort(compare);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -41,25 +46,40 @@ class _LicensesState extends State<Licenses> {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildListDelegate(
|
||||||
(context, index) {
|
[
|
||||||
if (index-- == 0) {
|
_buildHeader(),
|
||||||
return _buildHeader();
|
SizedBox(height: 16),
|
||||||
}
|
AvesExpansionTile(
|
||||||
final child = LicenseRow(_packages[index]);
|
title: 'Android Libraries',
|
||||||
return AnimationConfiguration.staggeredList(
|
color: BrandColors.android,
|
||||||
position: index,
|
expandedNotifier: _expandedNotifier,
|
||||||
duration: Durations.staggeredAnimation,
|
children: _platform.map((package) => LicenseRow(package)).toList(),
|
||||||
delay: Durations.staggeredAnimationDelay,
|
),
|
||||||
child: SlideAnimation(
|
AvesExpansionTile(
|
||||||
verticalOffset: 50.0,
|
title: 'Flutter Packages',
|
||||||
child: FadeInAnimation(
|
color: BrandColors.flutter,
|
||||||
child: child,
|
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);
|
final subColor = bodyTextStyle.color.withOpacity(.6);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(top: 16),
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
|
@ -1,23 +1,22 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/main.dart';
|
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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/app_shortcut_service.dart';
|
import 'package:aves/services/app_shortcut_service.dart';
|
||||||
import 'package:aves/utils/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/collection/collection_actions.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/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_subtitle.dart';
|
||||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||||
import 'package:aves/widgets/common/aves_selection_dialog.dart';
|
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||||
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
|
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||||
import 'package:aves/widgets/common/entry_actions.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
|
||||||
import 'package:aves/widgets/common/menu_row.dart';
|
|
||||||
import 'package:aves/widgets/search/search_button.dart';
|
import 'package:aves/widgets/search/search_button.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:aves/widgets/stats/stats.dart';
|
import 'package:aves/widgets/stats/stats.dart';
|
||||||
|
@ -43,7 +42,7 @@ class CollectionAppBar extends StatefulWidget {
|
||||||
|
|
||||||
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||||
final TextEditingController _searchFieldController = TextEditingController();
|
final TextEditingController _searchFieldController = TextEditingController();
|
||||||
SelectionActionDelegate _actionDelegate;
|
EntrySetActionDelegate _actionDelegate;
|
||||||
AnimationController _browseToSelectAnimation;
|
AnimationController _browseToSelectAnimation;
|
||||||
Future<bool> _canAddShortcutsLoader;
|
Future<bool> _canAddShortcutsLoader;
|
||||||
|
|
||||||
|
@ -56,7 +55,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_actionDelegate = SelectionActionDelegate(
|
_actionDelegate = EntrySetActionDelegate(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
);
|
);
|
||||||
_browseToSelectAnimation = AnimationController(
|
_browseToSelectAnimation = AnimationController(
|
||||||
|
@ -259,7 +258,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
|
||||||
widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0);
|
widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCollectionActionSelected(CollectionAction action) async {
|
Future<void> _onCollectionActionSelected(CollectionAction action) async {
|
||||||
// wait for the popup menu to hide before proceeding with the action
|
|
||||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CollectionAction.copy:
|
case CollectionAction.copy:
|
||||||
case CollectionAction.move:
|
case CollectionAction.move:
|
||||||
|
@ -289,10 +289,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
_actionDelegate.onCollectionActionSelected(context, action);
|
_actionDelegate.onCollectionActionSelected(context, action);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.refresh:
|
case CollectionAction.refresh:
|
||||||
if (source is MediaStoreSource) {
|
unawaited(source.refresh());
|
||||||
source.clearEntries();
|
|
||||||
unawaited((source as MediaStoreSource).refresh());
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case CollectionAction.select:
|
case CollectionAction.select:
|
||||||
collection.select();
|
collection.select();
|
||||||
|
@ -377,7 +374,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: RouteSettings(name: StatsPage.routeName),
|
settings: RouteSettings(name: StatsPage.routeName),
|
||||||
builder: (context) => StatsPage(
|
builder: (context) => StatsPage(
|
||||||
collection: collection,
|
source: source,
|
||||||
|
parentCollection: collection,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail_collection.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/behaviour/double_back_pop.dart';
|
||||||
import 'package:aves/widgets/common/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:aves/widgets/drawer/app_drawer.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -1,34 +1,30 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/actions/collection_actions.dart';
|
||||||
import 'package:aves/model/settings/settings.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_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.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/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/widgets/collection/collection_actions.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/collection/empty.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart';
|
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||||
import 'package:aves/widgets/common/action_delegates/feedback.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
|
import 'package:aves/widgets/filter_grids/album_pick.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:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:intl/intl.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;
|
final CollectionLens collection;
|
||||||
|
|
||||||
SelectionActionDelegate({
|
CollectionSource get source => collection.source;
|
||||||
|
|
||||||
|
Set<ImageEntry> get selection => collection.selection;
|
||||||
|
|
||||||
|
EntrySetActionDelegate({
|
||||||
@required this.collection,
|
@required this.collection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -38,7 +34,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
_showDeleteDialog(context);
|
_showDeleteDialog(context);
|
||||||
break;
|
break;
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
AndroidAppService.share(collection.selection);
|
AndroidAppService.share(selection).then((success) {
|
||||||
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
@ -54,7 +52,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
_moveSelection(context, copy: false);
|
_moveSelection(context, copy: false);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.refreshMetadata:
|
case CollectionAction.refreshMetadata:
|
||||||
_refreshSelectionMetadata();
|
source.refreshMetadata(selection);
|
||||||
|
collection.clearSelection();
|
||||||
|
collection.browse();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
@ -62,60 +62,20 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||||
final source = collection.source;
|
|
||||||
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
|
|
||||||
final destinationAlbum = await Navigator.push(
|
final destinationAlbum = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String>(
|
MaterialPageRoute<String>(
|
||||||
builder: (context) {
|
settings: RouteSettings(name: AlbumPickPage.routeName),
|
||||||
return Selector<Settings, ChipSortFactor>(
|
builder: (context) => AlbumPickPage(source: source, copy: copy),
|
||||||
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<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => CreateAlbumDialog(),
|
|
||||||
);
|
|
||||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
|
||||||
Navigator.pop<String>(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<String>(context, (filter as AlbumFilter)?.album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
||||||
|
|
||||||
final selection = collection.selection.toList();
|
|
||||||
if (!await checkStoragePermission(context, selection)) return;
|
if (!await checkStoragePermission(context, selection)) return;
|
||||||
|
|
||||||
|
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return;
|
||||||
|
|
||||||
showOpReport<MoveOpEvent>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
selection: selection,
|
selection: selection,
|
||||||
|
@ -143,24 +103,14 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _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<void> _showDeleteDialog(BuildContext context) async {
|
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||||
final selection = collection.selection.toList();
|
|
||||||
final count = selection.length;
|
final count = selection.length;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
|
context: context,
|
||||||
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
|
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
@ -192,7 +142,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||||
}
|
}
|
||||||
if (deletedCount > 0) {
|
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.clearSelection();
|
||||||
collection.browse();
|
collection.browse();
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/utils/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/collection/grid/header_generic.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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AlbumSectionHeader extends StatelessWidget {
|
class AlbumSectionHeader extends StatelessWidget {
|
||||||
|
|
|
@ -3,12 +3,12 @@ import 'dart:math';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.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/android_file_utils.dart';
|
||||||
import 'package:aves/utils/constants.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_album.dart';
|
||||||
import 'package:aves/widgets/collection/grid/header_date.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/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/grid/header_generic.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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
||||||
@required this.thumbnailBuilder,
|
@required this.thumbnailBuilder,
|
||||||
@required this.child,
|
@required this.child,
|
||||||
}) : assert(scrollableWidth != 0),
|
}) : assert(scrollableWidth != 0),
|
||||||
columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin);
|
columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -4,8 +4,9 @@ import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/viewer_service.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_known_extent.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_section_layout.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/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:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -70,7 +71,7 @@ class GridThumbnail extends StatelessWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: MetaData(
|
child: MetaData(
|
||||||
metaData: ThumbnailMetadata(entry),
|
metaData: ScalerMetadata(entry),
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: tileExtent,
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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<double> 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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,7 +10,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
final double extent;
|
final double extent;
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final ValueNotifier<bool> isScrollingNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
final bool showOverlay;
|
final bool selectable, highlightable;
|
||||||
final Object heroTag;
|
final Object heroTag;
|
||||||
|
|
||||||
static final Color borderColor = Colors.grey.shade700;
|
static final Color borderColor = Colors.grey.shade700;
|
||||||
|
@ -22,7 +22,8 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
@required this.extent,
|
@required this.extent,
|
||||||
this.collection,
|
this.collection,
|
||||||
this.isScrollingNotifier,
|
this.isScrollingNotifier,
|
||||||
this.showOverlay = true,
|
this.selectable = true,
|
||||||
|
this.highlightable = true,
|
||||||
}) : heroTag = collection?.heroTag(entry),
|
}) : heroTag = collection?.heroTag(entry),
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
|
@ -40,29 +41,32 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
isScrollingNotifier: isScrollingNotifier,
|
isScrollingNotifier: isScrollingNotifier,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
);
|
);
|
||||||
if (showOverlay) {
|
|
||||||
child = Stack(
|
child = Stack(
|
||||||
children: [
|
fit: StackFit.passthrough,
|
||||||
child,
|
children: [
|
||||||
Positioned(
|
child,
|
||||||
bottom: 0,
|
Positioned(
|
||||||
left: 0,
|
bottom: 0,
|
||||||
child: ThumbnailEntryOverlay(
|
left: 0,
|
||||||
entry: entry,
|
child: ThumbnailEntryOverlay(
|
||||||
extent: extent,
|
entry: entry,
|
||||||
),
|
extent: extent,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (selectable)
|
||||||
ThumbnailSelectionOverlay(
|
ThumbnailSelectionOverlay(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
),
|
),
|
||||||
|
if (highlightable)
|
||||||
ThumbnailHighlightOverlay(
|
ThumbnailHighlightOverlay(
|
||||||
highlightedStream: collection.highlightStream.map((highlighted) => highlighted == entry),
|
entry: entry,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return Container(
|
return Container(
|
||||||
foregroundDecoration: BoxDecoration(
|
foregroundDecoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ErrorThumbnail extends StatelessWidget {
|
class ErrorThumbnail extends StatelessWidget {
|
||||||
|
@ -15,12 +15,14 @@ class ErrorThumbnail extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: Colors.black,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
preferBelow: false,
|
preferBelow: false,
|
||||||
child: Text(
|
child: Text(
|
||||||
MimeTypes.displayType(entry.mimeType),
|
MimeUtils.displayType(entry.mimeType),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.blueGrey,
|
color: Colors.blueGrey,
|
||||||
fontSize: extent / 5,
|
fontSize: extent / 5,
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/enums.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/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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
@ -115,13 +117,13 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailHighlightOverlay extends StatefulWidget {
|
class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||||
|
final ImageEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final Stream<bool> highlightedStream;
|
|
||||||
|
|
||||||
const ThumbnailHighlightOverlay({
|
const ThumbnailHighlightOverlay({
|
||||||
Key key,
|
Key key,
|
||||||
|
@required this.entry,
|
||||||
@required this.extent,
|
@required this.extent,
|
||||||
@required this.highlightedStream,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -131,27 +133,25 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||||
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
||||||
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
|
ImageEntry get entry => widget.entry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<bool>(
|
final highlightInfo = context.watch<HighlightInfo>();
|
||||||
stream: widget.highlightedStream,
|
_highlightedNotifier.value = highlightInfo.contains(entry);
|
||||||
builder: (context, snapshot) {
|
return Sweeper(
|
||||||
_highlightedNotifier.value = snapshot.hasData && snapshot.data;
|
builder: (context) => Container(
|
||||||
return Sweeper(
|
decoration: BoxDecoration(
|
||||||
builder: (context) => Container(
|
border: Border.all(
|
||||||
decoration: BoxDecoration(
|
color: Theme.of(context).accentColor,
|
||||||
border: Border.all(
|
width: widget.extent * .1,
|
||||||
color: Theme.of(context).accentColor,
|
|
||||||
width: widget.extent * .1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
toggledNotifier: _highlightedNotifier,
|
),
|
||||||
startAngle: pi * -3 / 4,
|
),
|
||||||
centerSweep: false,
|
toggledNotifier: _highlightedNotifier,
|
||||||
onSweepEnd: () => _highlightedNotifier.value = false,
|
startAngle: pi * -3 / 4,
|
||||||
);
|
centerSweep: false,
|
||||||
},
|
onSweepEnd: () => highlightInfo.remove(entry),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import 'dart:math';
|
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/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/collection/thumbnail/error.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
|
||||||
import 'package:aves/widgets/common/transition_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ThumbnailRasterImage extends StatefulWidget {
|
class ThumbnailRasterImage extends StatefulWidget {
|
||||||
|
@ -98,16 +98,13 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!entry.canDecode) {
|
if (!entry.canDecode) {
|
||||||
return ErrorThumbnail(
|
return _buildError(context, '${entry.mimeType} not supported', null);
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
tooltip: '${entry.mimeType} not supported',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final fastImage = Image(
|
final fastImage = Image(
|
||||||
key: ValueKey('LQ'),
|
key: ValueKey('LQ'),
|
||||||
image: _fastThumbnailProvider,
|
image: _fastThumbnailProvider,
|
||||||
|
errorBuilder: _buildError,
|
||||||
width: extent,
|
width: extent,
|
||||||
height: extent,
|
height: extent,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
@ -137,11 +134,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||||
child: frame == null ? fastImage : child,
|
child: frame == null ? fastImage : child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
|
errorBuilder: _buildError,
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
tooltip: error.toString(),
|
|
||||||
),
|
|
||||||
width: extent,
|
width: extent,
|
||||||
height: extent,
|
height: extent,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
@ -173,6 +166,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
// when the entry image itself changed (e.g. after rotation)
|
||||||
void _onImageChanged() async {
|
void _onImageChanged() async {
|
||||||
// rebuild to refresh the thumbnails
|
// rebuild to refresh the thumbnails
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/settings/settings.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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -32,10 +32,10 @@ class ThumbnailVectorImage extends StatelessWidget {
|
||||||
UriPicture(
|
UriPicture(
|
||||||
uri: entry.uri,
|
uri: entry.uri,
|
||||||
mimeType: entry.mimeType,
|
mimeType: entry.mimeType,
|
||||||
|
colorFilter: colorFilter,
|
||||||
),
|
),
|
||||||
width: extent,
|
width: extent,
|
||||||
height: extent,
|
height: extent,
|
||||||
colorFilter: colorFilter,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,24 +2,28 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.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_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.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/app_bar.dart';
|
||||||
import 'package:aves/widgets/collection/empty.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_section_layout.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
||||||
import 'package:aves/widgets/collection/grid/scaling.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/collection/grid/tile_extent_manager.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||||
import 'package:aves/widgets/common/scroll_thumb.dart';
|
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||||
import 'package:aves/widgets/common/sloppy_scroll_physics.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:draggable_scrollbar/draggable_scrollbar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
|
||||||
|
|
||||||
class ThumbnailCollection extends StatelessWidget {
|
class ThumbnailCollection extends StatelessWidget {
|
||||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||||
|
@ -27,67 +31,88 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||||
final GlobalKey _scrollableKey = GlobalKey();
|
final GlobalKey _scrollableKey = GlobalKey();
|
||||||
|
|
||||||
|
static const columnCountMin = 2;
|
||||||
|
static const columnCountDefault = 4;
|
||||||
|
static const extentMin = 46.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return HighlightInfoProvider(
|
||||||
child: Selector<MediaQueryData, Tuple2<Size, double>>(
|
child: SafeArea(
|
||||||
selector: (context, mq) => Tuple2(mq.size, mq.padding.horizontal),
|
child: LayoutBuilder(
|
||||||
builder: (context, mq, child) {
|
builder: (context, constraints) {
|
||||||
final mqSize = mq.item1;
|
final viewportSize = constraints.biggest;
|
||||||
final mqHorizontalPadding = mq.item2;
|
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);
|
// do not replace by Provider.of<CollectionLens>
|
||||||
final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2;
|
// so that view updates on collection filter changes
|
||||||
|
return Consumer<CollectionLens>(
|
||||||
// do not replace by Provider.of<CollectionLens>
|
builder: (context, collection, child) {
|
||||||
// so that view updates on collection filter changes
|
final scrollView = CollectionScrollView(
|
||||||
return Consumer<CollectionLens>(
|
scrollableKey: _scrollableKey,
|
||||||
builder: (context, collection, child) {
|
|
||||||
final scrollView = CollectionScrollView(
|
|
||||||
scrollableKey: _scrollableKey,
|
|
||||||
collection: collection,
|
|
||||||
appBar: CollectionAppBar(
|
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
|
||||||
collection: collection,
|
collection: collection,
|
||||||
),
|
appBar: CollectionAppBar(
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
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<double>(
|
|
||||||
valueListenable: _tileExtentNotifier,
|
|
||||||
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
|
||||||
collection: collection,
|
|
||||||
scrollableWidth: mqSize.width - mqHorizontalPadding,
|
|
||||||
tileExtent: tileExtent,
|
|
||||||
thumbnailBuilder: (entry) => GridThumbnail(
|
|
||||||
key: ValueKey(entry.contentId),
|
|
||||||
collection: collection,
|
collection: collection,
|
||||||
entry: entry,
|
|
||||||
tileExtent: tileExtent,
|
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
|
||||||
),
|
),
|
||||||
child: scaler,
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
),
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
);
|
scrollController: PrimaryScrollController.of(context),
|
||||||
return sectionedListLayoutProvider;
|
cacheExtent: cacheExtent,
|
||||||
},
|
);
|
||||||
);
|
|
||||||
},
|
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||||
|
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<SectionedListLayout>(context, listen: false);
|
||||||
|
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||||
|
},
|
||||||
|
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
||||||
|
child: scrollView,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/services/image_file_service.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:flushbar/flushbar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
@ -31,7 +31,7 @@ mixin FeedbackMixin {
|
||||||
|
|
||||||
void showOpReport<T extends ImageOpEvent>({
|
void showOpReport<T extends ImageOpEvent>({
|
||||||
@required BuildContext context,
|
@required BuildContext context,
|
||||||
@required List<ImageEntry> selection,
|
@required Set<ImageEntry> selection,
|
||||||
@required Stream<T> opStream,
|
@required Stream<T> opStream,
|
||||||
@required void Function(Set<T> processed) onDone,
|
@required void Function(Set<T> processed) onDone,
|
||||||
}) {
|
}) {
|
|
@ -1,11 +1,10 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/services/android_file_service.dart';
|
import 'package:aves/services/android_file_service.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../aves_dialog.dart';
|
|
||||||
|
|
||||||
mixin PermissionAwareMixin {
|
mixin PermissionAwareMixin {
|
||||||
Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) {
|
Future<bool> checkStoragePermission(BuildContext context, Set<ImageEntry> entries) {
|
||||||
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +24,7 @@ mixin PermissionAwareMixin {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
|
context: context,
|
||||||
title: 'Storage Volume Access',
|
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.'),
|
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: [
|
actions: [
|
52
lib/widgets/common/action_mixins/size_aware.dart
Normal file
52
lib/widgets/common/action_mixins/size_aware.dart
Normal file
|
@ -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<bool> checkFreeSpaceForMove(BuildContext context, Set<ImageEntry> 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<ImageEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||||
|
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||||
|
final fromOtherVolumes = otherVolumes.fold<int>(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<int>(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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SourceStateAwareAppBarTitle extends StatelessWidget {
|
class SourceStateAwareAppBarTitle extends StatelessWidget {
|
||||||
|
|
|
@ -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<Widget> scrollableContent,
|
|
||||||
Widget content,
|
|
||||||
@required List<Widget> 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',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
101
lib/widgets/common/aves_highlight.dart
Normal file
101
lib/widgets/common/aves_highlight.dart
Normal file
|
@ -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<String, TextStyle> 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<TextSpan> _convert(List<Node> nodes) {
|
||||||
|
final spans = <TextSpan>[];
|
||||||
|
var currentSpans = spans;
|
||||||
|
final stack = <List<TextSpan>>[];
|
||||||
|
|
||||||
|
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 = <TextSpan>[];
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.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:flutter/material.dart';
|
||||||
|
|
||||||
class MenuRow extends StatelessWidget {
|
class MenuRow extends StatelessWidget {
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// `RadioListTile` that can trigger `onChanged` on tap when already selected, if `reselectable` is true
|
// `RadioListTile` that can trigger `onChanged` on tap when already selected, if `reselectable` is true
|
||||||
class AvesRadioListTile<T> extends StatelessWidget {
|
class ReselectableRadioListTile<T> extends StatelessWidget {
|
||||||
final T value;
|
final T value;
|
||||||
final T groupValue;
|
final T groupValue;
|
||||||
final ValueChanged<T> onChanged;
|
final ValueChanged<T> onChanged;
|
||||||
|
@ -19,7 +19,7 @@ class AvesRadioListTile<T> extends StatelessWidget {
|
||||||
|
|
||||||
bool get checked => value == groupValue;
|
bool get checked => value == groupValue;
|
||||||
|
|
||||||
const AvesRadioListTile({
|
const ReselectableRadioListTile({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.value,
|
@required this.value,
|
||||||
@required this.groupValue,
|
@required this.groupValue,
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/utils/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/action_delegates/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:overlay_support/overlay_support.dart';
|
import 'package:overlay_support/overlay_support.dart';
|
|
@ -1,5 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
extension ExtraContext on BuildContext {
|
||||||
|
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
|
||||||
|
}
|
||||||
|
|
||||||
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
|
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
DirectMaterialPageRoute({
|
DirectMaterialPageRoute({
|
||||||
RouteSettings settings,
|
RouteSettings settings,
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/utils/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.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:expansion_tile_card/expansion_tile_card.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AvesExpansionTile extends StatelessWidget {
|
class AvesExpansionTile extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
|
final Color color;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
final ValueNotifier<String> expandedNotifier;
|
final ValueNotifier<String> expandedNotifier;
|
||||||
|
|
||||||
const AvesExpansionTile({
|
const AvesExpansionTile({
|
||||||
@required this.title,
|
@required this.title,
|
||||||
|
this.color,
|
||||||
this.expandedNotifier,
|
this.expandedNotifier,
|
||||||
@required this.children,
|
@required this.children,
|
||||||
});
|
});
|
||||||
|
@ -27,6 +29,7 @@ class AvesExpansionTile extends StatelessWidget {
|
||||||
expandedNotifier: expandedNotifier,
|
expandedNotifier: expandedNotifier,
|
||||||
title: HighlightTitle(
|
title: HighlightTitle(
|
||||||
title,
|
title,
|
||||||
|
color: color,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
),
|
),
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
typedef FilterCallback = void Function(CollectionFilter filter);
|
typedef FilterCallback = void Function(CollectionFilter filter);
|
||||||
|
@ -14,29 +14,33 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
final bool showGenericIcon;
|
final bool showGenericIcon;
|
||||||
final Widget background;
|
final Widget background;
|
||||||
final Widget details;
|
final Widget details;
|
||||||
|
final double padding;
|
||||||
final HeroType heroType;
|
final HeroType heroType;
|
||||||
final FilterCallback onTap;
|
final FilterCallback onTap;
|
||||||
final OffsetFilterCallback onLongPress;
|
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 outlineWidth = 2;
|
||||||
static const double minChipHeight = kMinInteractiveDimension;
|
static const double minChipHeight = kMinInteractiveDimension;
|
||||||
static const double minChipWidth = 80;
|
static const double minChipWidth = 80;
|
||||||
static const double maxChipWidth = 160;
|
static const double maxChipWidth = 160;
|
||||||
static const double iconSize = 20;
|
static const double iconSize = 20;
|
||||||
static const double padding = 6;
|
|
||||||
|
|
||||||
const AvesFilterChip({
|
const AvesFilterChip({
|
||||||
Key key,
|
Key key,
|
||||||
this.filter,
|
@required this.filter,
|
||||||
this.removable = false,
|
this.removable = false,
|
||||||
this.showGenericIcon = true,
|
this.showGenericIcon = true,
|
||||||
this.background,
|
this.background,
|
||||||
this.details,
|
this.details,
|
||||||
|
this.borderRadius = const BorderRadius.all(Radius.circular(defaultRadius)),
|
||||||
|
this.padding = 6.0,
|
||||||
this.heroType = HeroType.onTap,
|
this.heroType = HeroType.onTap,
|
||||||
@required this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
}) : super(key: key);
|
}) : assert(filter != null),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_AvesFilterChipState createState() => _AvesFilterChipState();
|
_AvesFilterChipState createState() => _AvesFilterChipState();
|
||||||
|
@ -50,6 +54,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
|
|
||||||
CollectionFilter get filter => widget.filter;
|
CollectionFilter get filter => widget.filter;
|
||||||
|
|
||||||
|
BorderRadius get borderRadius => widget.borderRadius;
|
||||||
|
|
||||||
|
double get padding => widget.padding;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -79,9 +87,11 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
const iconSize = AvesFilterChip.iconSize;
|
||||||
|
|
||||||
final hasBackground = widget.background != null;
|
final hasBackground = widget.background != null;
|
||||||
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
||||||
final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null;
|
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
||||||
|
|
||||||
Widget content = Row(
|
Widget content = Row(
|
||||||
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
|
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
|
||||||
|
@ -89,7 +99,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
children: [
|
children: [
|
||||||
if (leading != null) ...[
|
if (leading != null) ...[
|
||||||
leading,
|
leading,
|
||||||
SizedBox(width: AvesFilterChip.padding),
|
SizedBox(width: padding),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
@ -100,7 +110,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailing != null) ...[
|
if (trailing != null) ...[
|
||||||
SizedBox(width: AvesFilterChip.padding),
|
SizedBox(width: padding),
|
||||||
trailing,
|
trailing,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
@ -117,7 +127,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
}
|
}
|
||||||
|
|
||||||
content = Padding(
|
content = Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2),
|
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2),
|
||||||
child: content,
|
child: content,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -135,8 +145,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final borderRadius = AvesFilterChip.borderRadius;
|
|
||||||
|
|
||||||
Widget chip = Container(
|
Widget chip = Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minWidth: AvesFilterChip.minChipWidth,
|
minWidth: AvesFilterChip.minChipWidth,
|
|
@ -1,77 +1,12 @@
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||||
import 'package:aves/model/image_entry.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/android_file_utils.dart';
|
||||||
import 'package:aves/utils/constants.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:decorated_icon/decorated_icon.dart';
|
||||||
import 'package:flutter/material.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 {
|
class VideoIcon extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
|
@ -4,11 +4,13 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class HighlightTitle extends StatelessWidget {
|
class HighlightTitle extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
|
final Color color;
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
|
||||||
const HighlightTitle(
|
const HighlightTitle(
|
||||||
this.name, {
|
this.name, {
|
||||||
|
this.color,
|
||||||
this.fontSize = 20,
|
this.fontSize = 20,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
}) : assert(name != null);
|
}) : assert(name != null);
|
||||||
|
@ -21,7 +23,7 @@ class HighlightTitle extends StatelessWidget {
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: HighlightDecoration(
|
decoration: HighlightDecoration(
|
||||||
color: enabled ? stringToColor(name) : disabledColor,
|
color: enabled ? color ?? stringToColor(name) : disabledColor,
|
||||||
),
|
),
|
||||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||||
child: Text(
|
child: Text(
|
17
lib/widgets/common/providers/highlight_info_provider.dart
Normal file
17
lib/widgets/common/providers/highlight_info_provider.dart
Normal file
|
@ -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<HighlightInfo>(
|
||||||
|
create: (context) => HighlightInfo(),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,48 +1,57 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/theme/durations.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/widgets/collection/thumbnail/decorated.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/material.dart';
|
||||||
import 'package:flutter/rendering.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<T> {
|
||||||
|
final T item;
|
||||||
|
|
||||||
|
const ScalerMetadata(this.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GridScaleGestureDetector<T> extends StatefulWidget {
|
||||||
|
final TileExtentManager tileExtentManager;
|
||||||
final GlobalKey scrollableKey;
|
final GlobalKey scrollableKey;
|
||||||
final ValueNotifier<double> appBarHeightNotifier;
|
final ValueNotifier<double> appBarHeightNotifier;
|
||||||
final ValueNotifier<double> extentNotifier;
|
final Size viewportSize;
|
||||||
final Size mqSize;
|
final bool showScaledGrid;
|
||||||
final double mqHorizontalPadding;
|
final Widget Function(T item, double extent) scaledBuilder;
|
||||||
final void Function(ImageEntry entry) onScaled;
|
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
|
||||||
|
final void Function(T item) onScaled;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const GridScaleGestureDetector({
|
const GridScaleGestureDetector({
|
||||||
this.scrollableKey,
|
@required this.tileExtentManager,
|
||||||
|
@required this.scrollableKey,
|
||||||
@required this.appBarHeightNotifier,
|
@required this.appBarHeightNotifier,
|
||||||
@required this.extentNotifier,
|
@required this.viewportSize,
|
||||||
@required this.mqSize,
|
@required this.showScaledGrid,
|
||||||
@required this.mqHorizontalPadding,
|
@required this.scaledBuilder,
|
||||||
this.onScaled,
|
@required this.getScaledItemTileRect,
|
||||||
|
@required this.onScaled,
|
||||||
@required this.child,
|
@required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState();
|
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T>> {
|
||||||
double _startExtent, _extentMin, _extentMax;
|
double _startExtent, _extentMin, _extentMax;
|
||||||
bool _applyingScale = false;
|
bool _applyingScale = false;
|
||||||
ValueNotifier<double> _scaledExtentNotifier;
|
ValueNotifier<double> _scaledExtentNotifier;
|
||||||
OverlayEntry _overlayEntry;
|
OverlayEntry _overlayEntry;
|
||||||
ThumbnailMetadata _metadata;
|
ScalerMetadata<T> _metadata;
|
||||||
|
|
||||||
ValueNotifier<double> get tileExtentNotifier => widget.extentNotifier;
|
TileExtentManager get tileExtentManager => widget.tileExtentManager;
|
||||||
|
|
||||||
|
Size get viewportSize => widget.viewportSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -62,29 +71,31 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
||||||
scrollableBox.hitTest(result, position: details.localFocalPoint);
|
scrollableBox.hitTest(result, position: details.localFocalPoint);
|
||||||
|
|
||||||
// find `RenderObject`s at the gesture focal point
|
// find `RenderObject`s at the gesture focal point
|
||||||
T firstOf<T>(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T;
|
U firstOf<U>(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is U, orElse: () => null)?.target as U;
|
||||||
final renderMetaData = firstOf<RenderMetaData>(result);
|
final renderMetaData = firstOf<RenderMetaData>(result);
|
||||||
// abort if we cannot find an image to show on overlay
|
// abort if we cannot find an image to show on overlay
|
||||||
if (renderMetaData == null) return;
|
if (renderMetaData == null) return;
|
||||||
_metadata = renderMetaData.metaData;
|
_metadata = renderMetaData.metaData;
|
||||||
_startExtent = tileExtentNotifier.value;
|
_startExtent = renderMetaData.size.width;
|
||||||
_scaledExtentNotifier = ValueNotifier(_startExtent);
|
_scaledExtentNotifier = ValueNotifier(_startExtent);
|
||||||
|
|
||||||
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
||||||
final gridWidth = scrollableBox.size.width;
|
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 halfExtent = _startExtent / 2;
|
||||||
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
|
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
|
||||||
_overlayEntry = OverlayEntry(
|
_overlayEntry = OverlayEntry(
|
||||||
builder: (context) {
|
builder: (context) => ScaleOverlay(
|
||||||
return ScaleOverlay(
|
builder: (extent) => widget.scaledBuilder(_metadata.item, extent),
|
||||||
imageEntry: _metadata.entry,
|
center: thumbnailCenter,
|
||||||
center: thumbnailCenter,
|
gridWidth: gridWidth,
|
||||||
gridWidth: gridWidth,
|
spacing: tileExtentManager.spacing,
|
||||||
scaledExtentNotifier: _scaledExtentNotifier,
|
scaledExtentNotifier: _scaledExtentNotifier,
|
||||||
);
|
showScaledGrid: widget.showScaledGrid,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
Overlay.of(scrollableContext).insert(_overlayEntry);
|
Overlay.of(scrollableContext).insert(_overlayEntry);
|
||||||
},
|
},
|
||||||
|
@ -101,13 +112,11 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyingScale = true;
|
_applyingScale = true;
|
||||||
final oldExtent = tileExtentNotifier.value;
|
final oldExtent = tileExtentManager.extentNotifier.value;
|
||||||
// sanitize and update grid layout if necessary
|
// sanitize and update grid layout if necessary
|
||||||
final newExtent = TileExtentManager.applyTileExtent(
|
final newExtent = tileExtentManager.applyTileExtent(
|
||||||
widget.mqSize,
|
viewportSize: widget.viewportSize,
|
||||||
widget.mqHorizontalPadding,
|
userPreferredExtent: _scaledExtentNotifier.value,
|
||||||
tileExtentNotifier,
|
|
||||||
newExtent: _scaledExtentNotifier.value,
|
|
||||||
);
|
);
|
||||||
_scaledExtentNotifier = null;
|
_scaledExtentNotifier = null;
|
||||||
if (newExtent == oldExtent) {
|
if (newExtent == oldExtent) {
|
||||||
|
@ -115,8 +124,8 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
||||||
} else {
|
} else {
|
||||||
// scroll to show the focal point thumbnail at its new position
|
// scroll to show the focal point thumbnail at its new position
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final entry = _metadata.entry;
|
final entry = _metadata.item;
|
||||||
_scrollToEntry(entry);
|
_scrollToItem(entry);
|
||||||
// warning: posting `onScaled` in the next frame with `addPostFrameCallback`
|
// warning: posting `onScaled` in the next frame with `addPostFrameCallback`
|
||||||
// would trigger only when the scrollable offset actually changes
|
// would trigger only when the scrollable offset actually changes
|
||||||
Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled?.call(entry));
|
Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled?.call(entry));
|
||||||
|
@ -132,11 +141,10 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
||||||
// `Scrollable.ensureVisible` only works on already rendered objects
|
// `Scrollable.ensureVisible` only works on already rendered objects
|
||||||
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
|
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
|
||||||
// `RenderViewport.scrollOffsetOf` is a good alternative
|
// `RenderViewport.scrollOffsetOf` is a good alternative
|
||||||
void _scrollToEntry(ImageEntry entry) {
|
void _scrollToItem(T item) {
|
||||||
final scrollableContext = widget.scrollableKey.currentContext;
|
final scrollableContext = widget.scrollableKey.currentContext;
|
||||||
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
|
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
|
||||||
final sectionedListLayout = Provider.of<SectionedListLayout>(context, listen: false);
|
final tileRect = widget.getScaledItemTileRect(context, item);
|
||||||
final tileRect = sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
|
||||||
// most of the time the app bar will be scrolled away after scaling,
|
// most of the time the app bar will be scrolled away after scaling,
|
||||||
// so we compensate for it to center the focal point thumbnail
|
// so we compensate for it to center the focal point thumbnail
|
||||||
final appBarHeight = widget.appBarHeightNotifier.value;
|
final appBarHeight = widget.appBarHeightNotifier.value;
|
||||||
|
@ -147,16 +155,20 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScaleOverlay extends StatefulWidget {
|
class ScaleOverlay extends StatefulWidget {
|
||||||
final ImageEntry imageEntry;
|
final Widget Function(double extent) builder;
|
||||||
final Offset center;
|
final Offset center;
|
||||||
final double gridWidth;
|
final double gridWidth;
|
||||||
|
final double spacing;
|
||||||
final ValueNotifier<double> scaledExtentNotifier;
|
final ValueNotifier<double> scaledExtentNotifier;
|
||||||
|
final bool showScaledGrid;
|
||||||
|
|
||||||
const ScaleOverlay({
|
const ScaleOverlay({
|
||||||
@required this.imageEntry,
|
@required this.builder,
|
||||||
@required this.center,
|
@required this.center,
|
||||||
@required this.gridWidth,
|
@required this.gridWidth,
|
||||||
|
@required this.spacing,
|
||||||
@required this.scaledExtentNotifier,
|
@required this.scaledExtentNotifier,
|
||||||
|
@required this.showScaledGrid,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -216,28 +228,30 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
||||||
}
|
}
|
||||||
final clampedCenter = center.translate(dx, 0);
|
final clampedCenter = center.translate(dx, 0);
|
||||||
|
|
||||||
return CustomPaint(
|
var child = widget.builder(extent);
|
||||||
painter: GridPainter(
|
child = Stack(
|
||||||
center: clampedCenter,
|
children: [
|
||||||
extent: extent,
|
Positioned(
|
||||||
),
|
left: clampedCenter.dx - extent / 2,
|
||||||
child: Stack(
|
top: clampedCenter.dy - extent / 2,
|
||||||
children: [
|
child: DefaultTextStyle(
|
||||||
Positioned(
|
style: TextStyle(),
|
||||||
left: clampedCenter.dx - extent / 2,
|
child: child,
|
||||||
top: clampedCenter.dy - extent / 2,
|
|
||||||
child: DefaultTextStyle(
|
|
||||||
style: TextStyle(),
|
|
||||||
child: DecoratedThumbnail(
|
|
||||||
entry: widget.imageEntry,
|
|
||||||
extent: extent,
|
|
||||||
showOverlay: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
|
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<ScaleOverlay> {
|
||||||
|
|
||||||
class GridPainter extends CustomPainter {
|
class GridPainter extends CustomPainter {
|
||||||
final Offset center;
|
final Offset center;
|
||||||
final double extent;
|
final double extent, spacing;
|
||||||
|
|
||||||
const GridPainter({
|
const GridPainter({
|
||||||
@required this.center,
|
@required this.center,
|
||||||
@required this.extent,
|
@required this.extent,
|
||||||
|
@required this.spacing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -261,7 +276,7 @@ class GridPainter extends CustomPainter {
|
||||||
..strokeWidth = DecoratedThumbnail.borderWidth
|
..strokeWidth = DecoratedThumbnail.borderWidth
|
||||||
..shader = ui.Gradient.radial(
|
..shader = ui.Gradient.radial(
|
||||||
center,
|
center,
|
||||||
size.width / 2,
|
size.width * .7,
|
||||||
[
|
[
|
||||||
DecoratedThumbnail.borderColor,
|
DecoratedThumbnail.borderColor,
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
|
@ -271,10 +286,18 @@ class GridPainter extends CustomPainter {
|
||||||
1,
|
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);
|
final topLeft = center.translate(-extent / 2, -extent / 2);
|
||||||
for (var i = -1; i <= 2; i++) {
|
draw(topLeft);
|
||||||
canvas.drawLine(Offset(0, topLeft.dy + extent * i), Offset(size.width, topLeft.dy + extent * i), paint);
|
if (spacing > 0) {
|
||||||
canvas.drawLine(Offset(topLeft.dx + extent * i, 0), Offset(topLeft.dx + extent * i, size.height), paint);
|
draw(topLeft.translate(-spacing, -spacing));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
70
lib/widgets/common/tile_extent_manager.dart
Normal file
70
lib/widgets/common/tile_extent_manager.dart
Normal file
|
@ -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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/services/android_app_service.dart';
|
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:aves/widgets/fullscreen/info/common.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/widgets/common/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/debug/android_env.dart';
|
import 'package:aves/widgets/debug/android_env.dart';
|
||||||
import 'package:aves/widgets/debug/cache.dart';
|
import 'package:aves/widgets/debug/cache.dart';
|
||||||
import 'package:aves/widgets/debug/database.dart';
|
import 'package:aves/widgets/debug/database.dart';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/utils/file_utils.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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue