Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-02-10 15:14:49 +09:00
commit f2bd7b294f
100 changed files with 1674 additions and 879 deletions

View file

@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.3.4] - 2021-02-10
### Added
- hide album / country / tag from collection
- new version check
### Changed
- Viewer: improved multipage item overlay and thumbnail loading
- deactivate geocoding and Google maps when Play Services are unavailable
### Fixed
- refreshing items externally added/moved/removed
- loading items at the root of volumes
- loading items when opening a shortcut with a location filter
- various thumbnail hero animation fixes
## [v1.3.3] - 2021-01-31
### Added
- Viewer: support for multi-track HEIF
@ -34,7 +49,7 @@ upgraded libtiff to 4.2.0 for TIFF decoding
## [v1.3.1] - 2021-01-04
### Added
- Collection: long press and move to select/deselect multiple entries
- Collection: long press and move to select/deselect multiple items
- Info: show Spherical Video V1 metadata
- Info: metadata search
@ -73,14 +88,14 @@ upgraded libtiff to 4.2.0 for TIFF decoding
### Added
- Albums / Countries / Tags: pinch to change tile size
- Album picker: added a field to filter by name
- check free space before moving entries
- check free space before moving items
- SVG source viewer
### Changed
- Navigation: changed page history handling
- Info: improved layout, especially for XMP
- About: improved layout
- faster locating of new entries
- faster locating of new items
## [v1.2.7] - 2020-11-15
### Added
@ -92,15 +107,15 @@ upgraded libtiff to 4.2.0 for TIFF decoding
- Viewer: use subsampling and tiling to display large images
### Fixed
- Fixed finding dimensions of entries with incorrect EXIF
- Fixed finding dimensions of items with incorrect EXIF
## [v1.2.6] - 2020-11-15 [YANKED]
## [v1.2.5] - 2020-11-01
### Added
- Search: show recently used filters (optional)
- Search: show filter for entries with no XMP tags
- Search: show filter for entries with no location information
- Search: show filter for items with no XMP tags
- Search: show filter for items with no location information
- Analytics: use Firebase Analytics (along Firebase Crashlytics)
### Changed
@ -108,10 +123,10 @@ upgraded libtiff to 4.2.0 for TIFF decoding
- Viewer overlay: showing shooting details is now optional
### Fixed
- Viewer: leave when the loaded entry is deleted and it is the last one
- Viewer: leave when the loaded item is deleted and it is the last one
- Viewer: refresh the viewer overlay and info page when the loaded image is modified
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF
- Fixed opening entries shared via a "file" media content URI
- Fixed opening items shared via a "file" media content URI
### Removed
- Dependencies: removed Guava as a direct dependency in Android

View file

@ -36,8 +36,8 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
| Model | Name | Android Version | API |
| ----------- | -------------------------- | --------------- | ---:|
| SM-G981N | Samsung Galaxy S20 5G | 11 | 30 |
| SM-G970N | Samsung Galaxy S10e | 10 (Q) | 29 |
| SM-G981N | Samsung Galaxy S20 5G | 11 (R) | 30 |
| SM-G970N | Samsung Galaxy S10e | 11 (R) | 30 |
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |

View file

@ -78,6 +78,13 @@ android {
applicationIdSuffix ".profile"
}
release {
// specify architectures, to specifically exclude native libs for x86,
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
signingConfig signingConfigs.release
minifyEnabled true

View file

@ -33,6 +33,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))

View file

@ -67,7 +67,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val packages = HashMap<String, FieldMap>()
fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating folders
// apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }

View file

@ -23,6 +23,7 @@ import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -96,8 +97,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
@ -106,8 +106,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}

View file

@ -14,7 +14,6 @@ import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.MimeTypes
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -31,25 +30,35 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getObsoleteEntries) }
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
}
}
private fun getObsoleteEntries(call: MethodCall, result: MethodChannel.Result) {
val known = call.argument<List<Int>>("knownContentIds")
if (known == null) {
result.error("getObsoleteEntries-args", "failed because of missing arguments", null)
private suspend fun getEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getEntry-args", "failed because of missing arguments", null)
return
}
result.success(MediaStoreImageProvider().getObsoleteContentIds(activity, known))
val provider = getProvider(uri)
if (provider == null) {
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
return
}
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
})
}
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
@ -122,31 +131,6 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
}
}
private suspend fun getEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getEntry-args", "failed because of missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
return
}
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
})
}
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
Glide.get(activity).clearDiskCache()
result.success(null)
}
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
val newName = call.argument<String>("newName")
@ -217,6 +201,11 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
})
}
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
Glide.get(activity).clearDiskCache()
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/image"
}

View file

@ -0,0 +1,43 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) }
else -> result.notImplemented()
}
}
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
val knownContentIds = call.argument<List<Int>>("knownContentIds")
if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds))
}
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
val knownPathById = call.argument<Map<Int, String>>("knownPathById")
if (knownPathById == null) {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return
}
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById))
}
companion object {
const val CHANNEL = "deckers.thibault/aves/mediastore"
}
}

View file

@ -23,6 +23,7 @@ import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
@ -37,6 +38,8 @@ import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescri
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
@ -61,6 +64,7 @@ import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -69,6 +73,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.File
import java.text.ParseException
import java.util.*
import kotlin.math.roundToLong
@ -216,6 +221,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return dirMap
}
// legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever
// set `KEY_DATE_MILLIS` from these fields (by precedence):
// - ME / Exif / DATETIME_ORIGINAL
// - ME / Exif / DATETIME
@ -223,6 +229,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// - EI / Exif / DATETIME
// - ME / XMP / xmp:CreateDate
// - ME / XMP / photoshop:DateCreated
// - ME / PNG / TIME / LAST_MODIFICATION_TIME
// - MMR / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// - ME / XMP / dc:title
@ -347,12 +354,29 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
// identification of animated GIF & WEBP, GeoTIFF
when (mimeType) {
MimeTypes.PNG -> {
// date fallback to PNG time chunk
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
for (dir in metadata.getDirectoriesOfType(PngDirectory::class.java).filter { it.name == PNG_TIME_DIR_NAME }) {
dir.getSafeString(PngDirectory.TAG_LAST_MODIFICATION_TIME) {
try {
PNG_LAST_MODIFICATION_TIME_FORMAT.parse(it)?.let { date ->
metadataMap[KEY_DATE_MILLIS] = date.time
}
} catch (e: ParseException) {
Log.w(LOG_TAG, "failed to parse PNG date=$it for uri=$uri", e)
}
}
}
}
}
MimeTypes.GIF -> {
// identification of animated GIF
if (metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)) flags = flags or MASK_IS_ANIMATED
}
MimeTypes.WEBP -> {
// identification of animated WEBP
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) {
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) {
if (it) flags = flags or MASK_IS_ANIMATED
@ -360,6 +384,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
MimeTypes.TIFF -> {
// identification of GeoTIFF
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (dir.isGeoTiff()) flags = flags or MASK_IS_GEOTIFF
}
@ -639,8 +664,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
uri.tryParseId()?.let { id ->
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
@ -649,8 +673,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls.fetchers
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
@ -23,6 +22,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeifLike
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel
class ThumbnailFetcher internal constructor(
@ -94,7 +94,7 @@ class ThumbnailFetcher internal constructor(
}
private fun getByMediaStore(): Bitmap? {
val contentId = ContentUris.parseId(uri)
val contentId = uri.tryParseId() ?: return null
val resolver = context.contentResolver
return if (isVideo(mimeType)) {
@Suppress("DEPRECATION")

View file

@ -12,6 +12,11 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
private var eventSink: EventSink? = null
private var handler: Handler? = null
private val contentObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
this.onChange(selfChange, null)
@ -23,8 +28,6 @@ class ContentChangeStreamHandler(private val context: Context) : EventChannel.St
success(uri?.toString())
}
}
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
init {
context.contentResolver.apply {
@ -45,9 +48,9 @@ class ContentChangeStreamHandler(private val context: Context) : EventChannel.St
}
private fun success(uri: String?) {
handler.post {
handler?.post {
try {
eventSink.success(uri)
eventSink?.success(uri)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to use event sink", e)
}

View file

@ -3,9 +3,13 @@ package deckers.thibault.aves.metadata
import com.drew.lang.Rational
import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifIFD0Directory
import java.text.SimpleDateFormat
import java.util.*
object MetadataExtractorHelper {
const val PNG_TIME_DIR_NAME = "PNG-tIME"
val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
// extensions
fun Directory.getSafeDescription(tag: Int, save: (value: String) -> Unit) {

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
@ -25,9 +24,9 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
@ -93,16 +92,7 @@ class SourceEntry {
// ignore when the ID is not a number
// e.g. content://com.sec.android.app.myfiles.FileProvider/device_storage/20200109_162621.jpg
private val contentId: Long?
get() {
if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
try {
return ContentUris.parseId(uri)
} catch (e: Exception) {
// ignore
}
}
return null
}
get() = if (uri.scheme == ContentResolver.SCHEME_CONTENT) uri.tryParseId() else null
val isSized: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0

View file

@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
@ -292,16 +293,18 @@ abstract class ImageProvider {
protected suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont ->
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
var contentId: Long = 0
var contentId: Long? = null
var contentUri: Uri? = null
if (newUri != null) {
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri)
if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
contentId = newUri.tryParseId()
if (contentId != null) {
if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
}
}
if (contentUri == null) {

View file

@ -19,9 +19,11 @@ import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId
import kotlinx.coroutines.delay
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
class MediaStoreImageProvider : ImageProvider() {
suspend fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
@ -34,19 +36,21 @@ class MediaStoreImageProvider : ImageProvider() {
}
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
val id = ContentUris.parseId(uri)
val id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString()
callback.onSuccess(entry)
}
val alwaysValid = { _: Int, _: Int -> true }
if (mimeType == null || isImage(mimeType)) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return
}
if (mimeType == null || isVideo(mimeType)) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
if (id != null) {
if (mimeType == null || isImage(mimeType)) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return
}
if (mimeType == null || isVideo(mimeType)) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
}
}
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
// without an equivalent image/video if it is shared from a file browser
@ -56,30 +60,53 @@ class MediaStoreImageProvider : ImageProvider() {
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
}
fun getObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val current = arrayListOf<Int>().apply {
addAll(getContentIdList(context, IMAGE_CONTENT_URI))
addAll(getContentIdList(context, VIDEO_CONTENT_URI))
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val foundContentIds = ArrayList<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) {
foundContentIds.add(cursor.getInt(idColumn))
}
cursor.close()
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e)
}
}
return knownContentIds.filter { id: Int -> !current.contains(id) }.toList()
check(context, IMAGE_CONTENT_URI)
check(context, VIDEO_CONTENT_URI)
return knownContentIds.filter { id: Int -> !foundContentIds.contains(id) }.toList()
}
private fun getContentIdList(context: Context, contentUri: Uri): List<Int> {
val foundContentIds = ArrayList<Int>()
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) {
foundContentIds.add(cursor.getInt(idColumn))
fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> {
val obsoleteIds = ArrayList<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val pathColumn = cursor.getColumnIndexOrThrow(MediaColumns.PATH)
while (cursor.moveToNext()) {
val id = cursor.getInt(idColumn)
val path = cursor.getString(pathColumn)
if (knownPathById.containsKey(id) && knownPathById[id] != path) {
obsoleteIds.add(id)
}
}
cursor.close()
}
cursor.close()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e)
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e)
}
return foundContentIds
check(context, IMAGE_CONTENT_URI)
check(context, VIDEO_CONTENT_URI)
return obsoleteIds
}
private suspend fun fetchFrom(

View file

@ -7,7 +7,6 @@ import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File
import java.util.*
@ -23,7 +22,6 @@ object PermissionManager {
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -36,7 +34,13 @@ object PermissionManager {
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ACCESS_REQUEST_CODE, null)
if (intent.resolveActivity(activity.packageManager) != null) {
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST_CODE, null)
} else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
onDenied()
}
}
fun onPermissionResult(requestCode: Int, treeUri: Uri?) {
@ -95,7 +99,6 @@ object PermissionManager {
}
}
}
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=$dirPaths -> inaccessibleDirs=$inaccessibleDirs")
return inaccessibleDirs
}
@ -124,7 +127,6 @@ object PermissionManager {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
}
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
return accessibleDirs
}

View file

@ -0,0 +1,18 @@
package deckers.thibault.aves.utils
import android.content.ContentUris
import android.net.Uri
import android.util.Log
object UriUtils {
private val LOG_TAG = LogUtils.createTag(UriUtils::class.java)
fun Uri.tryParseId(): Long? {
try {
return ContentUris.parseId(this)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to parse ID from contentUri=$this")
}
return null
}
}

View file

@ -21,7 +21,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
},
@ -69,7 +69,7 @@ class ThumbnailProviderKey {
final int pageId, rotationDegrees;
final bool isFlipped;
final int dateModifiedSecs;
final double extent, scale;
final double extent;
const ThumbnailProviderKey({
@required this.uri,
@ -79,33 +79,27 @@ class ThumbnailProviderKey {
@required this.isFlipped,
@required this.dateModifiedSecs,
this.extent = 0,
this.scale = 1,
}) : assert(uri != null),
assert(mimeType != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(dateModifiedSecs != null),
assert(extent != null),
assert(scale != null);
assert(extent != null);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale;
return other is ThumbnailProviderKey && other.uri == uri && other.pageId == pageId && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
pageId,
rotationDegrees,
isFlipped,
dateModifiedSecs,
extent,
scale,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}';
}

View file

@ -49,7 +49,7 @@ class _AvesAppState extends State<AvesApp> {
Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
final List<String> changedUris = [];
final Set<String> changedUris = {};
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
@ -190,10 +190,17 @@ class _AvesAppState extends State<AvesApp> {
}
void _onContentChange(String uri) {
changedUris.add(uri);
_contentChangeDebouncer(() {
_mediaStoreSource.refreshUris(List.of(changedUris));
changedUris.clear();
});
if (uri != null) changedUris.add(uri);
if (changedUris.isNotEmpty) {
_contentChangeDebouncer(() async {
final todo = changedUris.toSet();
changedUris.clear();
final tempUris = await _mediaStoreSource.refreshUris(todo);
if (tempUris.isNotEmpty) {
changedUris.addAll(tempUris);
_onContentChange(null);
}
});
}
}
}

View file

@ -10,6 +10,7 @@ enum ChipSetAction {
enum ChipAction {
delete,
hide,
pin,
unpin,
rename,
@ -20,6 +21,8 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return 'Delete';
case ChipAction.hide:
return 'Hide';
case ChipAction.pin:
return 'Pin to top';
case ChipAction.unpin:
@ -34,6 +37,8 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.hide:
return AIcons.hide;
case ChipAction.pin:
case ChipAction.unpin:
return AIcons.pin;

View file

@ -0,0 +1,72 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:connectivity/connectivity.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:github/github.dart';
import 'package:google_api_availability/google_api_availability.dart';
import 'package:package_info/package_info.dart';
import 'package:version/version.dart';
final AvesAvailability availability = AvesAvailability._private();
class AvesAvailability {
bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
AvesAvailability._private() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
}
void onResume() => _isConnected = null;
Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity());
_updateConnectivityFromResult(result);
return _isConnected;
}
void _updateConnectivityFromResult(ConnectivityResult result) {
final newValue = result != ConnectivityResult.none;
if (_isConnected != newValue) {
_isConnected = newValue;
debugPrint('Device is connected=$_isConnected');
}
}
Future<bool> get hasPlayServices async {
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
_hasPlayServices = result == GooglePlayServicesAvailability.success;
debugPrint('Device has Play Services=$_hasPlayServices');
return _hasPlayServices;
}
// local geolocation with `geocoder` requires Play Services
Future<bool> get canGeolocate => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);
final now = DateTime.now();
final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval);
if (now.isBefore(dueDate)) {
_isNewVersionAvailable = false;
return SynchronousFuture(_isNewVersionAvailable);
}
if (!(await isConnected)) return false;
Version version(String s) => Version.parse(s.replaceFirst('v', ''));
final currentTag = (await PackageInfo.fromPlatform()).version;
final latestTag = (await GitHub().repositories.getLatestRelease(RepositorySlug('deckerst', 'aves'))).tagName;
_isNewVersionAvailable = version(latestTag) > version(currentTag);
if (_isNewVersionAvailable) {
debugPrint('Aves $latestTag is available on github');
} else {
debugPrint('Aves $currentTag is the latest version');
settings.lastVersionCheckDate = now;
}
return _isNewVersionAvailable;
}
}

View file

@ -1,28 +0,0 @@
import 'package:connectivity/connectivity.dart';
import 'package:flutter/foundation.dart';
final AvesConnectivity connectivity = AvesConnectivity._private();
class AvesConnectivity {
bool _isConnected;
AvesConnectivity._private() {
Connectivity().onConnectivityChanged.listen(_updateFromResult);
}
void onResume() => _isConnected = null;
Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity());
_updateFromResult(result);
return _isConnected;
}
Future<bool> get canGeolocate => isConnected;
void _updateFromResult(ConnectivityResult result) {
_isConnected = result != ConnectivityResult.none;
debugPrint('Device is connected=$_isConnected');
}
}

View file

@ -42,6 +42,10 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// Local geocoding requires Google Play Services
// Google remote geocoding requires an API key and is not free
final Future<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates;
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
@ -168,6 +172,9 @@ class AvesEntry {
addressChangeNotifier.dispose();
}
// do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
@ -372,7 +379,12 @@ class AvesEntry {
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
}
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
List<String> _xmpSubjects;
List<String> get xmpSubjects {
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
return _xmpSubjects;
}
String _bestTitle;
@ -396,6 +408,7 @@ class AvesEntry {
catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata;
_bestTitle = null;
_xmpSubjects = null;
metadataChangeNotifier.notifyListeners();
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
@ -441,7 +454,7 @@ class AvesEntry {
final coordinates = Coordinates(latitude, longitude);
try {
Future<List<Address>> call() => Geocoder.local.findAddressesFromCoordinates(coordinates);
Future<List<Address>> call() => _findAddresses(coordinates);
final addresses = await (background
? servicePolicy.call(
call,
@ -475,7 +488,7 @@ class AvesEntry {
final coordinates = Coordinates(latitude, longitude);
try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
final addresses = await _findAddresses(coordinates);
if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first;
return address.addressLine;
@ -639,8 +652,6 @@ class AvesEntry {
static int compareByDate(AvesEntry a, AvesEntry b) {
var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
if (c != 0) return c;
c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0);
if (c != 0) return c;
return -compareByName(a, b);
return compareByName(b, a);
}
}

View file

@ -24,33 +24,37 @@ class FavouriteRepo {
Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
changeNotifier.notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final removedRows = entries.map(_entryToRow);
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
changeNotifier.notifyListeners();
}
Future<void> move(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
if (oldRow != null) {
_rows.remove(oldRow);
final newRow = _entryToRow(entry);
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.add(newRow);
changeNotifier.notifyListeners();
}
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
changeNotifier.notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
changeNotifier.notifyListeners();
}
}

View file

@ -1,5 +1,4 @@
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
@ -33,7 +32,7 @@ class AlbumFilter extends CollectionFilter {
};
@override
bool filter(AvesEntry entry) => entry.directory == album;
EntryFilter get test => (entry) => entry.directory == album;
@override
String get label => uniqueName ?? album.split(separator).last;

View file

@ -1,4 +1,3 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/material.dart';
@ -13,7 +12,7 @@ class FavouriteFilter extends CollectionFilter {
};
@override
bool filter(AvesEntry entry) => entry.isFavourite;
EntryFilter get test => (entry) => entry.isFavourite;
@override
String get label => 'Favourite';

View file

@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String toJson() => jsonEncode(toMap());
bool filter(AvesEntry entry);
EntryFilter get test;
bool get isUnique => true;
@ -75,7 +75,6 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
}
}
// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
class FilterGridItem<T extends CollectionFilter> {
final T filter;
final AvesEntry entry;
@ -91,3 +90,5 @@ class FilterGridItem<T extends CollectionFilter> {
@override
int get hashCode => hashValues(filter, entry);
}
typedef EntryFilter = bool Function(AvesEntry);

View file

@ -1,4 +1,3 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
@ -12,11 +11,20 @@ class LocationFilter extends CollectionFilter {
final LocationLevel level;
String _location;
String _countryCode;
EntryFilter _test;
LocationFilter(this.level, this._location) {
final split = _location.split(locationSeparator);
if (split.isNotEmpty) _location = split[0];
if (split.length > 1) _countryCode = split[1];
if (_location.isEmpty) {
_test = (entry) => !entry.isLocated;
} else if (level == LocationLevel.country) {
_test = (entry) => entry.addressDetails?.countryCode == _countryCode;
} else if (level == LocationLevel.place) {
_test = (entry) => entry.addressDetails?.place == _location;
}
}
LocationFilter.fromMap(Map<String, dynamic> json)
@ -34,8 +42,10 @@ class LocationFilter extends CollectionFilter {
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
String get countryCode => _countryCode;
@override
bool filter(AvesEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
EntryFilter get test => _test;
@override
String get label => _location.isEmpty ? emptyLabel : _location;

View file

@ -1,5 +1,4 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:flutter/foundation.dart';
@ -15,31 +14,31 @@ class MimeFilter extends CollectionFilter {
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime;
bool Function(AvesEntry) _filter;
EntryFilter _test;
String _label;
IconData _icon;
MimeFilter(this.mime) {
var lowMime = mime.toLowerCase();
if (mime == animated) {
_filter = (entry) => entry.isAnimated;
_test = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (mime == panorama) {
_filter = (entry) => entry.isImage && entry.is360;
_test = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (mime == sphericalVideo) {
_filter = (entry) => entry.isVideo && entry.is360;
_test = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (mime == geotiff) {
_filter = (entry) => entry.isGeotiff;
_test = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
} else if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_filter = (entry) => entry.mimeType.startsWith(lowMime);
_test = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') {
_label = 'Video';
_icon = AIcons.video;
@ -49,7 +48,7 @@ class MimeFilter extends CollectionFilter {
}
_label ??= lowMime.split('/')[0].toUpperCase();
} else {
_filter = (entry) => entry.mimeType == lowMime;
_test = (entry) => entry.mimeType == lowMime;
_label = MimeUtils.displayType(lowMime);
}
_icon ??= AIcons.vector;
@ -67,7 +66,7 @@ class MimeFilter extends CollectionFilter {
};
@override
bool filter(AvesEntry entry) => _filter(entry);
EntryFilter get test => _test;
@override
String get label => _label;

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
final String query;
final bool colorful;
bool Function(AvesEntry) _filter;
EntryFilter _test;
QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase();
@ -29,7 +29,7 @@ class QueryFilter extends CollectionFilter {
upQuery = matches.first.group(1);
}
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
_test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
}
QueryFilter.fromMap(Map<String, dynamic> json)
@ -44,7 +44,7 @@ class QueryFilter extends CollectionFilter {
};
@override
bool filter(AvesEntry entry) => _filter(entry);
EntryFilter get test => _test;
@override
bool get isUnique => false;
@ -56,7 +56,7 @@ class QueryFilter extends CollectionFilter {
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size);
@override
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white);
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor);
@override
String get typeKey => type;

View file

@ -1,4 +1,3 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
@ -9,8 +8,15 @@ class TagFilter extends CollectionFilter {
static const emptyLabel = 'untagged';
final String tag;
EntryFilter _test;
const TagFilter(this.tag);
TagFilter(this.tag) {
if (tag.isEmpty) {
_test = (entry) => entry.xmpSubjects.isEmpty;
} else {
_test = (entry) => entry.xmpSubjects.contains(tag);
}
}
TagFilter.fromMap(Map<String, dynamic> json)
: this(
@ -24,7 +30,7 @@ class TagFilter extends CollectionFilter {
};
@override
bool filter(AvesEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
EntryFilter get test => _test;
@override
bool get isUnique => false;

View file

@ -116,11 +116,11 @@ class MetadataDb {
debugPrint('$runtimeType clearEntries deleted $count entries');
}
Future<List<AvesEntry>> loadEntries() async {
Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toList();
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries;
}

View file

@ -1,11 +1,13 @@
import 'package:flutter/foundation.dart';
class MultiPageInfo {
final String uri;
final List<SinglePageInfo> pages;
int get pageCount => pages.length;
MultiPageInfo({
@required this.uri,
this.pages,
}) {
if (pages.isNotEmpty) {
@ -18,8 +20,11 @@ class MultiPageInfo {
}
}
factory MultiPageInfo.fromPageMaps(List<Map> pageMaps) {
return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList());
factory MultiPageInfo.fromPageMaps(String uri, List<Map> pageMaps) {
return MultiPageInfo(
uri: uri,
pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList(),
);
}
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
@ -29,7 +34,7 @@ class MultiPageInfo {
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
@override
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, pages=$pages}';
}
class SinglePageInfo implements Comparable<SinglePageInfo> {

View file

@ -16,8 +16,6 @@ import '../source/enums.dart';
final Settings settings = Settings._private();
typedef SettingsCallback = void Function(String key, dynamic oldValue, dynamic newValue);
class Settings extends ChangeNotifier {
static SharedPreferences _prefs;
@ -45,6 +43,7 @@ class Settings extends ChangeNotifier {
static const countrySortFactorKey = 'country_sort_factor';
static const tagSortFactorKey = 'tag_sort_factor';
static const pinnedFiltersKey = 'pinned_filters';
static const hiddenFiltersKey = 'hidden_filters';
// viewer
static const showOverlayMinimapKey = 'show_overlay_minimap';
@ -64,6 +63,9 @@ class Settings extends ChangeNotifier {
static const saveSearchHistoryKey = 'save_search_history';
static const searchHistoryKey = 'search_history';
// version
static const lastVersionCheckDateKey = 'last_version_check_date';
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
@ -166,6 +168,10 @@ class Settings extends ChangeNotifier {
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
Set<CollectionFilter> get hiddenFilters => (_prefs.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
set hiddenFilters(Set<CollectionFilter> newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
// viewer
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
@ -214,6 +220,12 @@ class Settings extends ChangeNotifier {
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
// version
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs.getInt(lastVersionCheckDateKey) ?? 0);
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
// convenience methods
// ignore: avoid_positional_boolean_parameters

View file

@ -7,9 +7,9 @@ import 'package:collection/collection.dart';
import 'package:path/path.dart';
mixin AlbumMixin on SourceBase {
final Set<String> _folderPaths = {};
final Set<String> _directories = {};
List<String> sortedAlbums = List.unmodifiable([]);
List<String> get rawAlbums => List.unmodifiable(_directories);
int compareAlbumsByName(String a, String b) {
final ua = getUniqueAlbumName(a);
@ -21,15 +21,10 @@ mixin AlbumMixin on SourceBase {
return compareAsciiUpperCase(va, vb);
}
void updateAlbums() {
final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
sortedAlbums = List.unmodifiable(sorted);
invalidateFilterEntryCounts();
eventBus.fire(AlbumsChangedEvent());
}
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getUniqueAlbumName(String album) {
final otherAlbums = _folderPaths.where((item) => item != album);
final otherAlbums = _directories.where((item) => item != album);
final parts = album.split(separator);
var partCount = 0;
String testName;
@ -39,9 +34,18 @@ mixin AlbumMixin on SourceBase {
final uniqueName = parts.skip(parts.length - partCount).join(separator);
final volume = androidFileUtils.getStorageVolume(album);
final volumeRoot = volume?.path ?? '';
final albumRelativePath = album.substring(volumeRoot.length);
if (uniqueName.length < albumRelativePath.length || volume == null) {
if (volume == null) {
return uniqueName;
}
final volumeRootLength = volume.path.length;
if (album.length < volumeRootLength) {
// `album` is at the root, without trailing '/'
return uniqueName;
}
final albumRelativePath = album.substring(volumeRootLength);
if (uniqueName.length < albumRelativePath.length) {
return uniqueName;
} else if (volume.isPrimary) {
return albumRelativePath;
@ -51,9 +55,9 @@ mixin AlbumMixin on SourceBase {
}
Map<String, AvesEntry> getAlbumEntries() {
final entries = sortedEntriesForFilterList;
final entries = sortedEntriesByDate;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (final album in sortedAlbums) {
for (final album in rawAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
@ -72,13 +76,25 @@ mixin AlbumMixin on SourceBase {
)));
}
void addFolderPath(Iterable<String> albums) => _folderPaths.addAll(albums);
void updateDirectories() {
final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet();
addDirectories(visibleDirectories);
cleanEmptyAlbums();
}
void addDirectories(Set<String> albums) {
if (!_directories.containsAll(albums)) {
_directories.addAll(albums);
_notifyAlbumChange();
}
}
void cleanEmptyAlbums([Set<String> albums]) {
final emptyAlbums = (albums ?? _folderPaths).where(_isEmptyAlbum).toList();
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
if (emptyAlbums.isNotEmpty) {
_folderPaths.removeAll(emptyAlbums);
updateAlbums();
_directories.removeAll(emptyAlbums);
_notifyAlbumChange();
invalidateAlbumFilterSummary(directories: emptyAlbums);
final pinnedFilters = settings.pinnedFilters;
emptyAlbums.forEach((album) => pinnedFilters.remove(AlbumFilter(album, getUniqueAlbumName(album))));
@ -86,7 +102,32 @@ mixin AlbumMixin on SourceBase {
}
}
bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album);
bool _isEmptyAlbum(String album) => !visibleEntries.any((entry) => entry.directory == album);
// filter summary
// by directory
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateAlbumFilterSummary({Set<AvesEntry> entries, Set<String> directories}) {
if (entries == null && directories == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
directories ??= entries.map((entry) => entry.directory).toSet();
directories.forEach(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove);
}
}
int albumEntryCount(AlbumFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length);
}
AvesEntry albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
}
}
class AlbumsChangedEvent {}

View file

@ -4,7 +4,10 @@ import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart';
@ -13,15 +16,16 @@ import 'package:flutter/foundation.dart';
import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSelectionMixin {
class CollectionLens with ChangeNotifier, CollectionActivityMixin {
final CollectionSource source;
final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor;
EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
int id;
bool listenToSource;
List<AvesEntry> _filteredEntries;
List<AvesEntry> _filteredSortedEntries;
List<StreamSubscription> _subscriptions = [];
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
@ -29,17 +33,24 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
CollectionLens({
@required this.source,
Iterable<CollectionFilter> filters,
@required EntryGroupFactor groupFactor,
@required EntrySortFactor sortFactor,
EntryGroupFactor groupFactor,
EntrySortFactor sortFactor,
this.id,
this.listenToSource = true,
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
groupFactor = groupFactor ?? EntryGroupFactor.month,
sortFactor = sortFactor ?? EntrySortFactor.date {
groupFactor = groupFactor ?? settings.collectionGroupFactor,
sortFactor = sortFactor ?? settings.collectionSortFactor {
id ??= hashCode;
if (listenToSource) {
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<AddressMetadataChangedEvent>().listen((e) {
if (this.filters.any((filter) => filter is LocationFilter)) {
_refresh();
}
}));
}
_refresh();
}
@ -53,9 +64,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
super.dispose();
}
bool get isEmpty => _filteredEntries.isEmpty;
bool get isEmpty => _filteredSortedEntries.isEmpty;
int get entryCount => _filteredEntries.length;
int get entryCount => _filteredSortedEntries.length;
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
List<AvesEntry> _sortedEntries;
@ -77,8 +88,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
return true;
}
Object heroTag(AvesEntry entry) => entry.uri;
void addFilter(CollectionFilter filter) {
if (filter == null || filters.contains(filter)) return;
if (filter.isUnique) {
@ -103,28 +112,30 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
this.sortFactor = sortFactor;
_applySort();
_applyGroup();
sortGroupChangeNotifier.notifyListeners();
}
void group(EntryGroupFactor groupFactor) {
this.groupFactor = groupFactor;
_applyGroup();
sortGroupChangeNotifier.notifyListeners();
}
void _applyFilters() {
final rawEntries = source.rawEntries;
_filteredEntries = List.of(filters.isEmpty ? rawEntries : rawEntries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry))));
final entries = source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
}
void _applySort() {
switch (sortFactor) {
case EntrySortFactor.date:
_filteredEntries.sort(AvesEntry.compareByDate);
_filteredSortedEntries.sort(AvesEntry.compareByDate);
break;
case EntrySortFactor.size:
_filteredEntries.sort(AvesEntry.compareBySize);
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
case EntrySortFactor.name:
_filteredEntries.sort(AvesEntry.compareByName);
_filteredSortedEntries.sort(AvesEntry.compareByName);
break;
}
}
@ -134,29 +145,29 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
case EntrySortFactor.date:
switch (groupFactor) {
case EntryGroupFactor.album:
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break;
case EntryGroupFactor.month:
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
break;
case EntryGroupFactor.day:
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
break;
case EntryGroupFactor.none:
sections = Map.fromEntries([
MapEntry(null, _filteredEntries),
MapEntry(null, _filteredSortedEntries),
]);
break;
}
break;
case EntrySortFactor.size:
sections = Map.fromEntries([
MapEntry(null, _filteredEntries),
MapEntry(null, _filteredSortedEntries),
]);
break;
case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory));
break;
}
sections = Map.unmodifiable(sections);
@ -172,11 +183,15 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
_applyGroup();
}
void onEntryRemoved(Iterable<AvesEntry> entries) {
void onEntryAdded(Set<AvesEntry> entries) {
_refresh();
}
void onEntryRemoved(Set<AvesEntry> entries) {
// we should remove obsolete entries and sections
// but do not apply sort/group
// as section order change would surprise the user while browsing
_filteredEntries.removeWhere(entries.contains);
_filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
@ -194,12 +209,15 @@ mixin CollectionActivityMixin {
bool get isSelecting => _activityNotifier.value == Activity.select;
void browse() => _activityNotifier.value = Activity.browse;
void browse() {
clearSelection();
_activityNotifier.value = Activity.browse;
}
void select() => _activityNotifier.value = Activity.select;
}
mixin CollectionSelectionMixin on CollectionActivityMixin {
// selection
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
final Set<AvesEntry> _selection = {};

View file

@ -2,33 +2,26 @@ import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
mixin SourceBase {
final List<AvesEntry> _rawEntries = [];
EventBus get eventBus;
List<AvesEntry> get rawEntries => List.unmodifiable(_rawEntries);
Set<AvesEntry> get visibleEntries;
final EventBus _eventBus = EventBus();
EventBus get eventBus => _eventBus;
List<AvesEntry> get sortedEntriesForFilterList;
final Map<CollectionFilter, int> _filterEntryCountMap = {};
void invalidateFilterEntryCounts() => _filterEntryCountMap.clear();
List<AvesEntry> get sortedEntriesByDate;
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
@ -38,12 +31,32 @@ mixin SourceBase {
}
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
final EventBus _eventBus = EventBus();
@override
List<AvesEntry> get sortedEntriesForFilterList => CollectionLens(
source: this,
groupFactor: EntryGroupFactor.none,
sortFactor: EntrySortFactor.date,
).sortedEntries;
EventBus get eventBus => _eventBus;
final Set<AvesEntry> _rawEntries = {};
// TODO TLAD use `Set.unmodifiable()` when possible
Set<AvesEntry> get allEntries => Set.of(_rawEntries);
Set<AvesEntry> _visibleEntries;
@override
Set<AvesEntry> get visibleEntries {
// TODO TLAD use `Set.unmodifiable()` when possible
_visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries));
return _visibleEntries;
}
List<AvesEntry> _sortedEntriesByDate;
@override
List<AvesEntry> get sortedEntriesByDate {
_sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate));
return _sortedEntriesByDate;
}
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
@ -55,10 +68,23 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
}
void addAll(Iterable<AvesEntry> entries) {
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = settings.hiddenFilters;
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
}
void _invalidate([Set<AvesEntry> entries]) {
_visibleEntries = null;
_sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries);
invalidateCountryFilterSummary(entries);
invalidateTagFilterSummary(entries);
}
void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return;
if (_rawEntries.isNotEmpty) {
final newContentIds = entries.map((entry) => entry.contentId).toList();
final newContentIds = entries.map((entry) => entry.contentId).toSet();
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
}
entries.forEach((entry) {
@ -66,36 +92,40 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
});
_rawEntries.addAll(entries);
addFolderPath(_rawEntries.map((entry) => entry.directory));
invalidateFilterEntryCounts();
eventBus.fire(EntryAddedEvent());
_invalidate(entries);
addDirectories(_applyHiddenFilters(entries).map((entry) => entry.directory).toSet());
eventBus.fire(EntryAddedEvent(entries));
}
void removeEntries(List<AvesEntry> entries) {
void removeEntries(Set<String> uris) {
if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
entries.forEach((entry) => entry.removeFromFavourites());
_rawEntries.removeWhere(entries.contains);
_rawEntries.removeAll(entries);
_invalidate(entries);
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
updateLocations();
updateTags();
invalidateFilterEntryCounts();
eventBus.fire(EntryRemovedEvent(entries));
}
void clearEntries() {
_rawEntries.clear();
cleanEmptyAlbums();
updateAlbums();
_invalidate();
updateDirectories();
updateLocations();
updateTags();
invalidateFilterEntryCounts();
}
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
Future<void> moveEntry(AvesEntry entry, Map newFields) async {
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs;
entry.path = newFields['path'] as String;
entry.uri = newFields['uri'] as String;
@ -106,24 +136,27 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.move(oldContentId, entry);
if (isFavourite) {
await favourites.move(oldContentId, entry);
}
}
void updateAfterMove({
@required Set<AvesEntry> selection,
@required Set<AvesEntry> todoEntries,
@required Set<AvesEntry> favouriteEntries,
@required bool copy,
@required String destinationAlbum,
@required Iterable<MoveOpEvent> movedOps,
@required Set<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final fromAlbums = <String>{};
final movedEntries = <AvesEntry>[];
final movedEntries = <AvesEntry>{};
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
final sourceEntry = todoEntries.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith(
uri: newFields['uri'] as String,
@ -140,31 +173,30 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final newFields = movedOp.newFields;
if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
final entry = todoEntries.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await moveEntry(entry, newFields);
// do not rely on current favourite repo state to assess whether the moved entry is a favourite
// as source monitoring may already have removed the entry from the favourite repo
final isFavourite = favouriteEntries.contains(entry);
await _moveEntry(entry, newFields, isFavourite);
}
}
});
}
if (copy) {
addAll(movedEntries);
addEntries(movedEntries);
} else {
cleanEmptyAlbums(fromAlbums);
addFolderPath({destinationAlbum});
addDirectories({destinationAlbum});
}
updateAlbums();
invalidateFilterEntryCounts();
invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(movedEntries);
eventBus.fire(EntryMovedEvent(movedEntries));
}
int count(CollectionFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
}
bool get initialized => false;
Future<void> init();
@ -172,18 +204,66 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> refresh();
Future<void> refreshMetadata(Set<AvesEntry> entries);
// monitoring
bool _monitoring = true;
void pauseMonitoring() => _monitoring = false;
void resumeMonitoring() => _monitoring = true;
bool get isMonitoring => _monitoring;
// filter summary
int count(CollectionFilter filter) {
if (filter is AlbumFilter) return albumEntryCount(filter);
if (filter is LocationFilter) return countryEntryCount(filter);
if (filter is TagFilter) return tagEntryCount(filter);
return 0;
}
AvesEntry recentEntry(CollectionFilter filter) {
if (filter is AlbumFilter) return albumRecentEntry(filter);
if (filter is LocationFilter) return countryRecentEntry(filter);
if (filter is TagFilter) return tagRecentEntry(filter);
return null;
}
void changeFilterVisibility(CollectionFilter filter, bool visible) {
final hiddenFilters = settings.hiddenFilters;
if (visible) {
hiddenFilters.remove(filter);
} else {
hiddenFilters.add(filter);
settings.searchHistory = settings.searchHistory..remove(filter);
}
settings.hiddenFilters = hiddenFilters;
_invalidate();
// it is possible for entries hidden by a filter type, to have an impact on other types
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
updateDirectories();
updateLocations();
updateTags();
if (visible) {
refreshMetadata(visibleEntries.where(filter.test).toSet());
}
}
}
enum SourceState { loading, cataloguing, locating, ready }
class EntryAddedEvent {
final AvesEntry entry;
final Set<AvesEntry> entries;
const EntryAddedEvent([this.entry]);
const EntryAddedEvent([this.entries]);
}
class EntryRemovedEvent {
final Iterable<AvesEntry> entries;
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}

View file

@ -1,6 +1,6 @@
import 'dart:math';
import 'package:aves/model/connectivity.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata.dart';
@ -19,7 +19,7 @@ mixin LocationMixin on SourceBase {
Future<void> loadAddresses() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses();
rawEntries.forEach((entry) {
visibleEntries.forEach((entry) {
final contentId = entry.contentId;
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
});
@ -28,10 +28,10 @@ mixin LocationMixin on SourceBase {
}
Future<void> locateEntries() async {
if (!(await connectivity.canGeolocate)) return;
if (!(await availability.canGeolocate)) return;
// final stopwatch = Stopwatch()..start();
final byLocated = groupBy<AvesEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
final todo = byLocated[false] ?? [];
if (todo.isEmpty) return;
@ -91,7 +91,7 @@ mixin LocationMixin on SourceBase {
}
void updateLocations() {
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
// the same country code could be found with different country names
@ -100,9 +100,33 @@ mixin LocationMixin on SourceBase {
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
invalidateFilterEntryCounts();
invalidateCountryFilterSummary();
eventBus.fire(LocationsChangedEvent());
}
// filter summary
// by country code
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final countryCodes = entries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails.countryCode).toSet();
countryCodes.forEach(_filterEntryCountMap.remove);
}
}
int countryEntryCount(LocationFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where(filter.test).length);
}
AvesEntry countryRecentEntry(LocationFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
}
}
class AddressMetadataChangedEvent {}

View file

@ -7,6 +7,8 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
@ -48,12 +50,12 @@ class MediaStoreSource extends CollectionSource {
clearEntries();
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries
addAll(oldEntries);
addEntries(oldEntries);
await loadCatalogMetadata(); // 600ms for 5500 entries
await loadAddresses(); // 200ms for 3000 entries
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
@ -61,18 +63,26 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
// verify paths because some apps move files without updating their `last modified date`
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
final movedContentIds = (await MediaStoreService.checkObsoletePaths(knownPathById)).toSet();
movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date
knownDateById[contentId] = 0;
});
// fetch new entries
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10;
const refreshCountMax = 1000;
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries);
addAll(pendingNewEntries);
addEntries(pendingNewEntries);
pendingNewEntries.clear();
}
ImageFileService.getEntries(knownEntryMap).listen(
MediaStoreService.getEntries(knownDateById).listen(
(entry) {
pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) {
@ -85,10 +95,17 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries
updateAlbums();
if (allNewEntries.isNotEmpty) {
// new entries include existing entries with obsolete paths
// so directories may be added, but also removed or simply have their content summary changed
invalidateAlbumFilterSummary();
updateDirectories();
}
final analytics = FirebaseAnalytics();
unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(rawEntries.length, 3)).toString()));
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(sortedAlbums.length, 1)).toString()));
unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()));
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()));
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
@ -105,8 +122,13 @@ class MediaStoreSource extends CollectionSource {
);
}
Future<void> refreshUris(List<String> changedUris) async {
if (!_initialized) return;
// returns URIs to retry later. They could be URIs that are:
// 1) currently being processed during bulk move/deletion
// 2) registered in the Media Store but still being processed by their owner in a temporary location
// For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store
// sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
Future<Set<String>> refreshUris(Set<String> changedUris) async {
if (!_initialized || !isMonitoring) return changedUris;
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
if (uri == null) return null;
@ -117,32 +139,53 @@ class MediaStoreSource extends CollectionSource {
}).where((kv) => kv != null));
// clean up obsolete entries
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId));
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
removeEntries(obsoleteUris);
obsoleteContentIds.forEach(uriByContentId.remove);
// add new entries
final newEntries = <AvesEntry>[];
// fetch new entries
final tempUris = <String>{};
final newEntries = <AvesEntry>{};
final existingDirectories = <String>{};
for (final kv in uriByContentId.entries) {
final contentId = kv.key;
final uri = kv.value;
final sourceEntry = await ImageFileService.getEntry(uri, null);
final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
newEntries.add(sourceEntry);
if (sourceEntry != null) {
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
// compare paths because some apps move files without updating their `last modified date`
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs || sourceEntry.path != existingEntry.path) {
final volume = androidFileUtils.getStorageVolume(sourceEntry.path);
if (volume != null) {
newEntries.add(sourceEntry);
if (existingEntry != null) {
existingDirectories.add(existingEntry.directory);
}
} else {
debugPrint('$runtimeType refreshUris entry=$sourceEntry is not located on a known storage volume. Will retry soon...');
tempUris.add(uri);
}
}
}
}
addAll(newEntries);
await metadataDb.saveEntries(newEntries);
updateAlbums();
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
if (newEntries.isNotEmpty) {
invalidateAlbumFilterSummary(directories: existingDirectories);
addEntries(newEntries);
await metadataDb.saveEntries(newEntries);
cleanEmptyAlbums(existingDirectories);
stateNotifier.value = SourceState.locating;
await locateEntries();
stateNotifier.value = SourceState.cataloguing;
await catalogEntries();
stateNotifier.value = SourceState.ready;
stateNotifier.value = SourceState.locating;
await locateEntries();
stateNotifier.value = SourceState.ready;
}
return tempUris;
}
@override

View file

@ -5,21 +5,21 @@ class SectionKey {
}
class EntryAlbumSectionKey extends SectionKey {
final String folderPath;
final String directory;
const EntryAlbumSectionKey(this.folderPath);
const EntryAlbumSectionKey(this.directory);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is EntryAlbumSectionKey && other.folderPath == folderPath;
return other is EntryAlbumSectionKey && other.directory == directory;
}
@override
int get hashCode => folderPath.hashCode;
int get hashCode => directory.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
}
class EntryDateSectionKey extends SectionKey {

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart';
@ -13,7 +14,7 @@ mixin TagMixin on SourceBase {
Future<void> loadCatalogMetadata() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries();
rawEntries.forEach((entry) {
visibleEntries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
});
@ -23,7 +24,7 @@ mixin TagMixin on SourceBase {
Future<void> catalogEntries() async {
// final stopwatch = Stopwatch()..start();
final todo = rawEntries.where((entry) => !entry.isCatalogued).toList();
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
if (todo.isEmpty) return;
var progressDone = 0;
@ -54,11 +55,36 @@ mixin TagMixin on SourceBase {
}
void updateTags() {
final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
final tags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
sortedTags = List.unmodifiable(tags);
invalidateFilterEntryCounts();
invalidateTagFilterSummary();
eventBus.fire(TagsChangedEvent());
}
// filter summary
// by tag
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry> entries]) {
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
tags.forEach(_filterEntryCountMap.remove);
}
}
int tagEntryCount(TagFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where(filter.test).length);
}
AvesEntry tagRecentEntry(TagFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null));
}
}
class CatalogMetadataChangedEvent {}

View file

@ -13,9 +13,8 @@ import 'package:streams_channel/streams_channel.dart';
class ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel mediaStoreChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static final StreamsChannel byteChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
@ -32,30 +31,6 @@ class ImageFileService {
};
}
// knownEntries: map of contentId -> dateModifiedSecs
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try {
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,
}).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) {
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}
static Future<List<int>> getObsoleteEntries(List<int> knownContentIds) async {
try {
final result = await platform.invokeMethod('getObsoleteEntries', <String, dynamic>{
'knownContentIds': knownContentIds,
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
debugPrint('getObsoleteEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<AvesEntry> getEntry(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
@ -97,7 +72,7 @@ class ImageFileService {
final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer();
var bytesReceived = 0;
byteChannel.receiveBroadcastStream(<String, dynamic>{
_byteStreamChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0,
@ -204,7 +179,6 @@ class ImageFileService {
}
return null;
},
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
key: taskKey,
);
@ -226,7 +200,7 @@ class ImageFileService {
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event));
@ -242,7 +216,7 @@ class ImageFileService {
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy,
@ -260,7 +234,7 @@ class ImageFileService {
@required String destinationAlbum,
}) {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType,

View file

@ -0,0 +1,47 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
class MediaStoreService {
static const platform = MethodChannel('deckers.thibault/aves/mediastore');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds,
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById,
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
debugPrint('checkObsoletePaths failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
// knownEntries: map of contentId -> dateModifiedSecs
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,
}).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) {
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}
}

View file

@ -89,7 +89,7 @@ class MetadataService {
'uri': entry.uri,
});
final pageMaps = (result as List).cast<Map>();
return MultiPageInfo.fromPageMaps(pageMaps);
return MultiPageInfo.fromPageMaps(entry.uri, pageMaps);
} on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}

View file

@ -9,8 +9,8 @@ final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
final Queue<_Task> _runningQueue = Queue();
final SplayTreeMap<int, LinkedHashMap<Object, _Task>> _queues = SplayTreeMap();
final LinkedHashMap<Object, _Task> _runningQueue = LinkedHashMap();
// magic number
static const concurrentTaskMax = 4;
@ -22,57 +22,59 @@ class ServicePolicy {
Future<T> call<T>(
Future<T> Function() platformCall, {
int priority = ServiceCallPriority.normal,
String debugLabel,
Object key,
}) {
Completer<T> completer;
_Task task;
key ??= platformCall.hashCode;
final priorityTask = _paused.remove(key);
if (priorityTask != null) {
debugPrint('resume task with key=$key');
priority = priorityTask.item1;
task = priorityTask.item2;
final toResume = _paused.remove(key);
if (toResume != null) {
priority = toResume.item1;
task = toResume.item2;
completer = task.completer;
} else {
completer = Completer<T>();
task = _Task(
() async {
try {
completer.complete(await platformCall());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
_runningQueue.remove(key);
_pickNext();
},
completer,
);
}
var completer = task?.completer ?? Completer<T>();
task ??= _Task(
() async {
if (debugLabel != null) debugPrint('$runtimeType $debugLabel start');
try {
completer.complete(await platformCall());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
_runningQueue.removeWhere((task) => task.key == key);
_pickNext();
},
completer,
key,
);
_getQueue(priority).addLast(task);
_getQueue(priority)[key] = task;
_pickNext();
return completer.future;
}
Future<T> resume<T>(Object key) {
final priorityTask = _paused.remove(key);
if (priorityTask == null) return null;
final priority = priorityTask.item1;
final task = priorityTask.item2;
_getQueue(priority).addLast(task);
_pickNext();
return task.completer.future;
final toResume = _paused.remove(key);
if (toResume != null) {
final priority = toResume.item1;
final task = toResume.item2;
_getQueue(priority)[key] = task;
_pickNext();
return task.completer.future;
} else {
return null;
}
}
Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>());
LinkedHashMap<Object, _Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => LinkedHashMap());
void _pickNext() {
_notifyQueueState();
if (_runningQueue.length >= concurrentTaskMax) return;
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
final task = queue?.removeFirst();
if (task != null) {
_runningQueue.addLast(task);
if (queue != null && queue.isNotEmpty) {
final key = queue.keys.first;
final task = queue.remove(key);
_runningQueue[key] = task;
task.callback();
}
}
@ -80,14 +82,11 @@ class ServicePolicy {
bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) {
var out = false;
priorities.forEach((priority) {
final queue = _getQueue(priority);
final tasks = queue.where((task) => task.key == key).toList();
tasks.forEach((task) {
if (queue.remove(task)) {
out = true;
action(priority, task);
}
});
final task = _getQueue(priority).remove(key);
if (task != null) {
out = true;
action(priority, task);
}
});
return out;
}
@ -106,16 +105,15 @@ class ServicePolicy {
if (!_queueStreamController.hasListener) return;
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
_queueStreamController.add(QueueState(queueByPriority, _runningQueue.length));
_queueStreamController.add(QueueState(queueByPriority, _runningQueue.length, _paused.length));
}
}
class _Task {
final VoidCallback callback;
final Completer completer;
final Object key;
const _Task(this.callback, this.completer, this.key);
const _Task(this.callback, this.completer);
}
class CancelledException {}
@ -131,7 +129,7 @@ class ServiceCallPriority {
class QueueState {
final Map<int, int> queueByPriority;
final int runningQueue;
final int runningCount, pausedCount;
const QueueState(this.queueByPriority, this.runningQueue);
const QueueState(this.queueByPriority, this.runningCount, this.pausedCount);
}

View file

@ -30,12 +30,13 @@ class Durations {
static const filterRowExpandAnimation = Duration(milliseconds: 300);
// viewer animations
static const viewerPageAnimation = Duration(milliseconds: 300);
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 300);
static const viewerOverlayAnimation = Duration(milliseconds: 200);
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200);
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150);
// info
// info animations
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
@ -48,11 +49,8 @@ class Durations {
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 500);
// Content change monitoring delay should be large enough,
// so that querying the Media Store yields final entries.
// For example, when taking a picture with a Galaxy S10e default camera app,
// querying the Media Store just 1 second after sometimes yields an entry with
// its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
static const contentChangeDebounceDelay = Duration(milliseconds: 1500);
// app life
static const lastVersionCheckInterval = Duration(days: 7);
}

View file

@ -37,6 +37,7 @@ class AIcons {
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 hide = Icons.visibility_off_outlined;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;

View file

@ -42,7 +42,12 @@ class AndroidFileUtils {
bool isDownloadPath(String path) => path == downloadPath;
StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null);
StorageVolume getStorageVolume(String path) {
final volume = storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null);
// storage volume path includes trailing '/', but argument path may or may not,
// which is an issue when the path is at the root
return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/');
}
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;

View file

@ -167,6 +167,12 @@ class Constants {
licenseUrl: 'https://github.com/aloisdeniel/flutter_geocoder/blob/master/LICENSE',
sourceUrl: 'https://github.com/aloisdeniel/flutter_geocoder',
),
Dependency(
name: 'Github',
license: 'MIT',
licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE',
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
),
Dependency(
name: 'Google Maps for Flutter',
license: 'BSD 3-Clause',
@ -287,6 +293,12 @@ class Constants {
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
),
Dependency(
name: 'Version',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE',
sourceUrl: 'https://github.com/dartninja/version',
),
Dependency(
name: 'XML',
license: 'MIT',

View file

@ -1,9 +1,8 @@
import 'package:aves/flutter_version.dart';
import 'package:aves/widgets/about/app_ref.dart';
import 'package:aves/widgets/about/credits.dart';
import 'package:aves/widgets/about/licenses.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/about/new_version.dart';
import 'package:flutter/material.dart';
import 'package:package_info/package_info.dart';
class AboutPage extends StatelessWidget {
static const routeName = '/about';
@ -23,43 +22,9 @@ class AboutPage extends StatelessWidget {
delegate: SliverChildListDelegate(
[
AppReference(),
SizedBox(height: 16),
Divider(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(minHeight: 48),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
'Credits',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
),
),
RichText(
text: TextSpan(
children: [
TextSpan(text: 'This app uses the font '),
WidgetSpan(
child: LinkChip(
text: 'Concourse',
url: 'https://mbtype.com/fonts/concourse/',
textStyle: TextStyle(fontWeight: FontWeight.bold),
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' for titles and the media information page.'),
],
),
),
SizedBox(height: 16),
],
),
),
AboutNewVersion(),
AboutCredits(),
Divider(),
],
),
@ -72,71 +37,3 @@ class AboutPage extends StatelessWidget {
);
}
}
class AppReference extends StatefulWidget {
@override
_AppReferenceState createState() => _AppReferenceState();
}
class _AppReferenceState extends State<AppReference> {
Future<PackageInfo> packageInfoLoader;
@override
void initState() {
super.initState();
packageInfoLoader = PackageInfo.fromPlatform();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
_buildAvesLine(),
_buildFlutterLine(),
],
),
);
}
Widget _buildAvesLine() {
final textTheme = Theme.of(context).textTheme;
final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold);
return FutureBuilder<PackageInfo>(
future: packageInfoLoader,
builder: (context, snapshot) {
return LinkChip(
leading: AvesLogo(
size: style.fontSize * 1.25,
),
text: 'Aves ${snapshot.data?.version}',
url: 'https://github.com/deckerst/aves',
textStyle: style,
);
},
);
}
Widget _buildFlutterLine() {
final style = DefaultTextStyle.of(context).style;
final subColor = style.color.withOpacity(.6);
return Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: EdgeInsetsDirectional.only(end: 4),
child: FlutterLogo(
size: style.fontSize * 1.25,
),
),
),
TextSpan(text: 'Flutter ${version['frameworkVersion']}'),
],
),
style: TextStyle(color: subColor),
);
}
}

View file

@ -0,0 +1,74 @@
import 'package:aves/flutter_version.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:flutter/material.dart';
import 'package:package_info/package_info.dart';
class AppReference extends StatefulWidget {
@override
_AppReferenceState createState() => _AppReferenceState();
}
class _AppReferenceState extends State<AppReference> {
Future<PackageInfo> _packageInfoLoader;
@override
void initState() {
super.initState();
_packageInfoLoader = PackageInfo.fromPlatform();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
_buildAvesLine(),
_buildFlutterLine(),
SizedBox(height: 16),
],
),
);
}
Widget _buildAvesLine() {
final textTheme = Theme.of(context).textTheme;
final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold);
return FutureBuilder<PackageInfo>(
future: _packageInfoLoader,
builder: (context, snapshot) {
return LinkChip(
leading: AvesLogo(
size: style.fontSize * 1.25,
),
text: 'Aves ${snapshot.data?.version}',
url: 'https://github.com/deckerst/aves',
textStyle: style,
);
},
);
}
Widget _buildFlutterLine() {
final style = DefaultTextStyle.of(context).style;
final subColor = style.color.withOpacity(.6);
return Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: EdgeInsetsDirectional.only(end: 4),
child: FlutterLogo(
size: style.fontSize * 1.25,
),
),
),
TextSpan(text: 'Flutter ${version['frameworkVersion']}'),
],
),
style: TextStyle(color: subColor),
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:flutter/material.dart';
class AboutCredits extends StatelessWidget {
@override
Widget build(BuildContext context) {
return 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'),
),
),
),
Text.rich(
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),
],
),
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:aves/model/availability.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:flutter/material.dart';
class AboutNewVersion extends StatefulWidget {
@override
_AboutNewVersionState createState() => _AboutNewVersionState();
}
class _AboutNewVersionState extends State<AboutNewVersion> {
Future<bool> _newVersionLoader;
@override
void initState() {
super.initState();
_newVersionLoader = availability.isNewVersionAvailable;
}
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
if (!newVersion) return SizedBox();
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(minHeight: 48),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: EdgeInsetsDirectional.only(end: 8),
child: AboutNewsBadge(),
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(
text: 'New Version Available',
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
),
],
),
),
),
),
Text.rich(
TextSpan(
children: [
TextSpan(text: 'A new version of Aves is available on '),
WidgetSpan(
child: LinkChip(
text: 'Github',
url: 'https://github.com/deckerst/aves/releases',
textStyle: TextStyle(fontWeight: FontWeight.bold),
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' and '),
WidgetSpan(
child: LinkChip(
text: 'Google Play',
url: 'https://play.google.com/store/apps/details?id=deckers.thibault.aves',
textStyle: TextStyle(fontWeight: FontWeight.bold),
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 16),
],
),
),
Divider(),
],
);
},
);
}
}

View file

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class AboutNewsBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Icon(
Icons.circle,
size: 12,
color: Colors.red,
);
}
}

View file

@ -124,10 +124,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
} else if (collection.isSelecting) {
onPressed = () {
collection.clearSelection();
collection.browse();
};
onPressed = collection.browse;
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
}
return IconButton(
@ -194,6 +191,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return PopupMenuButton<CollectionAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty;
return [
PopupMenuItem(
@ -216,10 +214,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
if (AvesApp.mode == AppMode.main)
PopupMenuItem(
value: CollectionAction.select,
enabled: isNotEmpty,
child: MenuRow(text: 'Select', icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.stats,
enabled: isNotEmpty,
child: MenuRow(text: 'Stats', icon: AIcons.stats),
),
if (AvesApp.mode == AppMode.main && canAddShortcuts)
@ -248,6 +248,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.selectAll,
enabled: collection.selection.length < collection.entryCount,
child: MenuRow(text: 'Select all'),
),
PopupMenuItem(

View file

@ -8,13 +8,26 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CollectionPage extends StatelessWidget {
class CollectionPage extends StatefulWidget {
static const routeName = '/collection';
final CollectionLens collection;
const CollectionPage(this.collection);
@override
_CollectionPageState createState() => _CollectionPageState();
}
class _CollectionPageState extends State<CollectionPage> {
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
@ -24,7 +37,6 @@ class CollectionPage extends StatelessWidget {
body: WillPopScope(
onWillPop: () {
if (collection.isSelecting) {
collection.clearSelection();
collection.browse();
return SynchronousFuture(false);
}

View file

@ -55,7 +55,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
break;
case CollectionAction.refreshMetadata:
source.refreshMetadata(selection);
collection.clearSelection();
collection.browse();
break;
default:
@ -78,30 +77,39 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
// do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection
final todoEntries = selection.toSet();
final copy = moveType == MoveType.copy;
final selectionCount = selection.length;
final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring();
showOpReport<MoveOpEvent>(
context: context,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
itemCount: selectionCount,
opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
itemCount: todoCount,
onDone: (processed) async {
final movedOps = processed.where((e) => e.success);
final movedOps = processed.where((e) => e.success).toSet();
final movedCount = movedOps.length;
if (movedCount < selectionCount) {
final count = selectionCount - movedCount;
if (movedCount < todoCount) {
final count = todoCount - movedCount;
showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
} else {
final count = movedCount;
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
await source.updateAfterMove(
selection: selection,
todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
collection.clearSelection();
collection.browse();
source.resumeMonitoring();
},
);
}
@ -133,22 +141,21 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (!await checkStoragePermission(context, selection)) return;
final selectionCount = selection.length;
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
context: context,
opStream: ImageFileService.delete(selection),
itemCount: selectionCount,
onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
final deletedCount = deletedUris.length;
if (deletedCount < selectionCount) {
final count = selectionCount - deletedCount;
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (deletedCount > 0) {
source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toList());
}
collection.clearSelection();
source.removeEntries(deletedUris);
collection.browse();
source.resumeMonitoring();
},
);
}

View file

@ -7,18 +7,18 @@ import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
class AlbumSectionHeader extends StatelessWidget {
final String folderPath, albumName;
final String directory, albumName;
AlbumSectionHeader({
Key key,
@required CollectionSource source,
@required this.folderPath,
}) : albumName = source.getUniqueAlbumName(folderPath),
@required this.directory,
}) : albumName = source.getUniqueAlbumName(directory),
super(key: key);
@override
Widget build(BuildContext context) {
var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath);
var albumIcon = IconUtils.getAlbumIcon(context: context, album: directory);
if (albumIcon != null) {
albumIcon = Material(
type: MaterialType.circle,
@ -29,10 +29,10 @@ class AlbumSectionHeader extends StatelessWidget {
);
}
return SectionHeader(
sectionKey: EntryAlbumSectionKey(folderPath),
sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon,
title: albumName,
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
trailing: androidFileUtils.isOnRemovableStorage(directory)
? Icon(
AIcons.removableStorage,
size: 16,
@ -43,13 +43,13 @@ class AlbumSectionHeader extends StatelessWidget {
}
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
final folderPath = sectionKey.folderPath;
final directory = sectionKey.directory;
return SectionHeader.getPreferredHeight(
context: context,
maxWidth: maxWidth,
title: source.getUniqueAlbumName(folderPath),
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular,
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath),
title: source.getUniqueAlbumName(directory),
hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
);
}
}

View file

@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
Widget _buildAlbumHeader() => AlbumSectionHeader(
key: ValueKey(sectionKey),
source: collection.source,
folderPath: (sectionKey as EntryAlbumSectionKey).folderPath,
directory: (sectionKey as EntryAlbumSectionKey).directory,
);
switch (collection.sortFactor) {

View file

@ -43,7 +43,10 @@ class InteractiveThumbnail extends StatelessWidget {
entry: entry,
extent: tileExtent,
collection: collection,
isScrollingNotifier: isScrollingNotifier,
// when the user is scrolling faster than we can retrieve the thumbnails,
// the retrieval task queue can pile up for thumbnails that got disposed
// in this case we pause the image retrieval task to get it out of the queue
cancellableNotifier: isScrollingNotifier,
),
),
);
@ -54,16 +57,20 @@ class InteractiveThumbnail extends StatelessWidget {
context,
TransparentMaterialPageRoute(
settings: RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) => EntryViewerPage(
collection: CollectionLens(
pageBuilder: (c, a, sa) {
final viewerCollection = CollectionLens(
source: collection.source,
filters: collection.filters,
groupFactor: collection.groupFactor,
sortFactor: collection.sortFactor,
id: collection.id,
listenToSource: false,
),
initialEntry: entry,
),
);
return EntryViewerPage(
collection: viewerCollection,
initialEntry: entry,
);
},
),
);
}

View file

@ -9,26 +9,28 @@ class DecoratedThumbnail extends StatelessWidget {
final AvesEntry entry;
final double extent;
final CollectionLens collection;
final ValueNotifier<bool> isScrollingNotifier;
final ValueNotifier<bool> cancellableNotifier;
final bool selectable, highlightable;
final Object heroTag;
static final Color borderColor = Colors.grey.shade700;
static const double borderWidth = .5;
DecoratedThumbnail({
const DecoratedThumbnail({
Key key,
@required this.entry,
@required this.extent,
this.collection,
this.isScrollingNotifier,
this.cancellableNotifier,
this.selectable = true,
this.highlightable = true,
}) : heroTag = collection?.heroTag(entry),
super(key: key);
}) : super(key: key);
@override
Widget build(BuildContext context) {
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final heroTag = hashValues(collection?.id, entry);
var child = entry.isSvg
? VectorImageThumbnail(
entry: entry,
@ -38,7 +40,7 @@ class DecoratedThumbnail extends StatelessWidget {
: RasterImageThumbnail(
entry: entry,
extent: extent,
isScrollingNotifier: isScrollingNotifier,
cancellableNotifier: cancellableNotifier,
heroTag: heroTag,
);

View file

@ -75,7 +75,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
const duration = Durations.thumbnailOverlayAnimation;
final fontSize = min(14.0, (extent / 8)).roundToDouble();
final iconSize = fontSize * 2;
final collection = Provider.of<CollectionLens>(context);
final collection = context.watch<CollectionLens>();
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {

View file

@ -9,14 +9,14 @@ import 'package:flutter/material.dart';
class RasterImageThumbnail extends StatefulWidget {
final AvesEntry entry;
final double extent;
final ValueNotifier<bool> isScrollingNotifier;
final ValueNotifier<bool> cancellableNotifier;
final Object heroTag;
const RasterImageThumbnail({
Key key,
@required this.entry,
@required this.extent,
this.isScrollingNotifier,
this.cancellableNotifier,
this.heroTag,
}) : super(key: key);
@ -31,8 +31,6 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
double get extent => widget.extent;
Object get heroTag => widget.heroTag;
@override
void initState() {
super.initState();
@ -72,11 +70,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
}
void _pauseProvider() {
final isScrolling = widget.isScrollingNotifier?.value ?? false;
// when the user is scrolling faster than we can retrieve the thumbnails,
// the retrieval task queue can pile up for thumbnails that got disposed
// in this case we pause the image retrieval task to get it out of the queue
if (isScrolling) {
if (widget.cancellableNotifier?.value ?? false) {
_fastThumbnailProvider?.pause();
_sizedThumbnailProvider?.pause();
}
@ -126,18 +120,19 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
height: extent,
fit: BoxFit.cover,
);
return heroTag == null
? image
: Hero(
tag: heroTag,
return widget.heroTag != null
? Hero(
tag: widget.heroTag,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
return TransitionImage(
image: entry.getBestThumbnail(extent),
animation: animation,
);
},
transitionOnUserGestures: true,
child: image,
);
)
: image;
}
Widget _buildError(BuildContext context, Object error, StackTrace stackTrace) => ErrorThumbnail(

View file

@ -63,11 +63,12 @@ class VectorImageThumbnail extends StatelessWidget {
);
},
);
return heroTag == null
? child
: Hero(
return heroTag != null
? Hero(
tag: heroTag,
transitionOnUserGestures: true,
child: child,
);
)
: child;
}
}

View file

@ -1,10 +1,10 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/mime_types.dart';
@ -109,7 +109,7 @@ class ThumbnailCollection extends StatelessWidget {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
onScaled: (entry) => context.read<HighlightInfo>().add(entry),
child: scrollView,
);
@ -195,12 +195,14 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
}
void _registerWidget(CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_onFilterChange);
widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChange);
}
void _unregisterWidget(CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_onFilterChange);
widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChange);
}
@ -283,7 +285,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
);
}
void _onFilterChange() => widget.scrollController.jumpTo(0);
void _scrollToTop() => widget.scrollController.jumpTo(0);
void _onScrollChange() {
widget.isScrollingNotifier.value = true;

View file

@ -76,7 +76,7 @@ class SectionHeader extends StatelessWidget {
}
void _toggleSectionSelection(BuildContext context) {
final collection = Provider.of<CollectionLens>(context, listen: false);
final collection = context.read<CollectionLens>();
final sectionEntries = collection.sections[sectionKey];
final selected = collection.isSelected(sectionEntries);
if (selected) {
@ -140,7 +140,7 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(context);
final collection = Provider.of<CollectionLens>(context);
final collection = context.watch<CollectionLens>();
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {

View file

@ -20,6 +20,7 @@ class AvesFilterChip extends StatefulWidget {
final OffsetFilterCallback onLongPress;
final BorderRadius borderRadius;
static const Color defaultOutlineColor = Colors.white;
static const double defaultRadius = 32;
static const double outlineWidth = 2;
static const double minChipHeight = kMinInteractiveDimension;
@ -82,7 +83,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
// the existing widget FutureBuilder cycles again from the start, with a frame in `waiting` state and no data.
// So we save the result of the Future to a local variable because of this specific case.
_colorFuture = filter.color(context);
_outlineColor = Colors.transparent;
_outlineColor = AvesFilterChip.defaultOutlineColor;
}
@override
@ -211,6 +212,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) {
chip = Hero(
tag: filter,
transitionOnUserGestures: true,
child: DefaultTextStyle(
style: TextStyle(),
child: chip,

View file

@ -27,7 +27,9 @@ class AppDebugPage extends StatefulWidget {
}
class _AppDebugPageState extends State<AppDebugPage> {
List<AvesEntry> get entries => widget.source.rawEntries;
CollectionSource get source => widget.source;
Set<AvesEntry> get visibleEntries => source.visibleEntries;
static OverlayEntry _taskQueueOverlayEntry;
@ -59,7 +61,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
}
Widget _buildGeneralTabView() {
final catalogued = entries.where((entry) => entry.isCatalogued);
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
return AvesExpansionTile(
@ -98,7 +100,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
{
'Entries': '${entries.length}',
'All entries': '${source.allEntries.length}',
'Visible entries': '${visibleEntries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${located.length}',

View file

@ -13,7 +13,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
Future<int> _dbFileSizeLoader;
Future<List<AvesEntry>> _dbEntryLoader;
Future<Set<AvesEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
@ -57,7 +57,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<List>(
FutureBuilder<Set<AvesEntry>>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());

View file

@ -24,7 +24,8 @@ class DebugTaskQueueOverlay extends StatelessWidget {
final queuedEntries = <MapEntry<dynamic, int>>[];
if (snapshot.hasData) {
final state = snapshot.data;
queuedEntries.add(MapEntry('run', state.runningQueue));
queuedEntries.add(MapEntry('run', state.runningCount));
queuedEntries.add(MapEntry('paused', state.pausedCount));
queuedEntries.addAll(state.queueByPriority.entries.map((kv) => MapEntry(kv.key.toString(), kv.value)));
}
queuedEntries.sort((a, b) => a.key.compareTo(b.key));

View file

@ -11,37 +11,40 @@ import 'package:provider/provider.dart';
class DebugSettingsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<Settings>(builder: (context, settings, child) {
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
return AvesExpansionTile(
title: 'Settings',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton(
onPressed: () => settings.reset(),
child: Text('Reset'),
return Consumer<Settings>(
builder: (context, settings, child) {
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
return AvesExpansionTile(
title: 'Settings',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton(
onPressed: () => settings.reset(),
child: Text('Reset'),
),
),
),
SwitchListTile(
value: settings.hasAcceptedTerms,
onChanged: (v) => settings.hasAcceptedTerms = v,
title: Text('hasAcceptedTerms'),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}',
'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}',
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': toMultiline(settings.pinnedFilters),
'searchHistory': toMultiline(settings.searchHistory),
}),
),
],
);
});
SwitchListTile(
value: settings.hasAcceptedTerms,
onChanged: (v) => settings.hasAcceptedTerms = v,
title: Text('hasAcceptedTerms'),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}',
'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}',
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': toMultiline(settings.pinnedFilters),
'searchHistory': toMultiline(settings.searchHistory),
'lastVersionCheckDate': '${settings.lastVersionCheckDate}',
}),
),
],
);
},
);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:aves/model/availability.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
@ -11,6 +12,7 @@ import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -35,8 +37,16 @@ class AppDrawer extends StatefulWidget {
}
class _AppDrawerState extends State<AppDrawer> {
Future<bool> _newVersionLoader;
CollectionSource get source => widget.source;
@override
void initState() {
super.initState();
_newVersionLoader = availability.isNewVersionAvailable;
}
@override
Widget build(BuildContext context) {
final drawerItems = <Widget>[
@ -132,10 +142,11 @@ class _AppDrawerState extends State<AppDrawer> {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final specialAlbums = source.sortedAlbums.where((album) {
final specialAlbums = source.rawAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album);
return [AlbumType.camera, AlbumType.screenshots].contains(type);
});
}).toList()
..sort(source.compareAlbumsByName);
if (specialAlbums.isEmpty) return SizedBox.shrink();
return Column(
@ -175,7 +186,7 @@ class _AppDrawerState extends State<AppDrawer> {
title: 'Albums',
trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, _) => Text('${source.sortedAlbums.length}'),
builder: (context, _) => Text('${source.rawAlbums.length}'),
),
routeName: AlbumListPage.routeName,
pageBuilder: (_) => AlbumListPage(source: source),
@ -211,12 +222,19 @@ class _AppDrawerState extends State<AppDrawer> {
pageBuilder: (_) => SettingsPage(),
);
Widget get aboutTile => NavTile(
icon: AIcons.info,
title: 'About',
topLevel: false,
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
Widget get aboutTile => FutureBuilder<bool>(
future: _newVersionLoader,
builder: (context, snapshot) {
final newVersion = snapshot.data == true;
return NavTile(
icon: AIcons.info,
title: 'About',
trailing: newVersion ? AboutNewsBadge() : null,
topLevel: false,
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
);
},
);
Widget get debugTile => NavTile(

View file

@ -1,5 +1,4 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/collection/collection_page.dart';
@ -47,8 +46,6 @@ class CollectionNavTile extends StatelessWidget {
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,

View file

@ -44,6 +44,7 @@ class AlbumListPage extends StatelessWidget {
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.rename,
ChipAction.delete,
ChipAction.hide,
],
filterSections: getAlbumEntries(source),
emptyBuilder: () => EmptyContent(
@ -60,8 +61,7 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) {
// albums are initially sorted by name at the source level
final filters = source.sortedAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album)));
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album))).toSet();
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(sorted);

View file

@ -16,6 +16,12 @@ import 'package:intl/intl.dart';
import 'package:path/path.dart' as path;
class ChipActionDelegate {
final CollectionSource source;
ChipActionDelegate({
@required this.source,
});
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.pin:
@ -24,6 +30,9 @@ class ChipActionDelegate {
case ChipAction.unpin:
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
break;
case ChipAction.hide:
source.changeFilterVisibility(filter, false);
break;
default:
break;
}
@ -31,11 +40,9 @@ class ChipActionDelegate {
}
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final CollectionSource source;
AlbumChipActionDelegate({
@required this.source,
});
@required CollectionSource source,
}) : super(source: source);
@override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
@ -53,7 +60,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
final selection = source.rawEntries.where(filter.filter).toSet();
final selection = source.visibleEntries.where(filter.test).toSet();
final count = selection.length;
final confirmed = await showDialog<bool>(
@ -80,20 +87,20 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkStoragePermission(context, selection)) return;
final selectionCount = selection.length;
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
context: context,
opStream: ImageFileService.delete(selection),
itemCount: selectionCount,
onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
final deletedCount = deletedUris.length;
if (deletedCount < selectionCount) {
final count = selectionCount - deletedCount;
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (deletedCount > 0) {
source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toList());
}
source.removeEntries(deletedUris);
source.resumeMonitoring();
},
);
}
@ -108,28 +115,33 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkStoragePermissionForAlbums(context, {album})) return;
final selection = source.rawEntries.where(filter.filter).toSet();
final todoEntries = source.visibleEntries.where(filter.test).toSet();
final destinationAlbum = path.join(path.dirname(album), newName);
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, MoveType.move)) return;
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
final selectionCount = selection.length;
final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring();
showOpReport<MoveOpEvent>(
context: context,
opStream: ImageFileService.move(selection, copy: false, destinationAlbum: destinationAlbum),
itemCount: selectionCount,
opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
itemCount: todoCount,
onDone: (processed) async {
final movedOps = processed.where((e) => e.success);
final movedOps = processed.where((e) => e.success).toSet();
final movedCount = movedOps.length;
if (movedCount < selectionCount) {
final count = selectionCount - movedCount;
if (movedCount < todoCount) {
final count = todoCount - movedCount;
showFeedback(context, 'Failed to move ${Intl.plural(count, one: '$count item', other: '$count items')}');
} else {
showFeedback(context, 'Done!');
}
final pinned = settings.pinnedFilters.contains(filter);
await source.updateAfterMove(
selection: selection,
todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
@ -139,6 +151,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(destinationAlbum));
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
source.resumeMonitoring();
},
);
}

View file

@ -138,7 +138,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
},
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
onScaled: (item) => context.read<HighlightInfo>().add(item.filter),
child: scrollView,
);

View file

@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:aves/main.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
@ -78,8 +77,6 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
),
@ -154,7 +151,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
}
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1;
final c = (b.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)).compareTo(a.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0));
return c != 0 ? c : a.filter.compareTo(b.filter);
}
@ -163,19 +160,22 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return c != 0 ? c : a.key.compareTo(b.key);
}
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Iterable<T> filters) {
static int compareFiltersByName(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
return a.filter.compareTo(b.filter);
}
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Iterable<T> filters) {
final entriesByDate = source.sortedEntriesForFilterList;
return filters.map((filter) => FilterGridItem(
filter,
entriesByDate.firstWhere(filter.filter, orElse: () => null),
source.recentEntry(filter),
));
}
Iterable<FilterGridItem<T>> allMapEntries;
switch (sortFactor) {
case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters);
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
break;
case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
@ -183,7 +183,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));
filtersWithCount.sort(compareFiltersByEntryCount);
filters = filtersWithCount.map((kv) => kv.key).toList();
filters = filtersWithCount.map((kv) => kv.key).toSet();
allMapEntries = toGridItem(source, filters);
break;
}

View file

@ -75,8 +75,8 @@ extension ExtraAlbumImportance on AlbumImportance {
class StorageVolumeSectionKey extends ChipSectionKey {
final StorageVolume volume;
StorageVolumeSectionKey(this.volume) : super(title: volume.description);
StorageVolumeSectionKey(this.volume) : super(title: volume?.description ?? 'Unknown');
@override
Widget get leading => volume.isRemovable ? Icon(AIcons.removableStorage) : null;
Widget get leading => (volume?.isRemovable ?? false) ? Icon(AIcons.removableStorage) : null;
}

View file

@ -34,9 +34,10 @@ class CountryListPage extends StatelessWidget {
source: source,
title: 'Countries',
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(),
chipActionDelegate: ChipActionDelegate(source: source),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.hide,
],
filterSections: _getCountryEntries(),
emptyBuilder: () => EmptyContent(
@ -50,8 +51,7 @@ class CountryListPage extends StatelessWidget {
}
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries() {
// countries are initially sorted by name at the source level
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location));
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted);

View file

@ -34,9 +34,10 @@ class TagListPage extends StatelessWidget {
source: source,
title: 'Tags',
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(),
chipActionDelegate: ChipActionDelegate(source: source),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.hide,
],
filterSections: _getTagEntries(),
emptyBuilder: () => EmptyContent(
@ -50,8 +51,7 @@ class TagListPage extends StatelessWidget {
}
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() {
// tags are initially sorted by name at the source level
final filters = source.sortedTags.map((tag) => TagFilter(tag));
final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted);

View file

@ -1,5 +1,5 @@
import 'package:aves/main.dart';
import 'package:aves/model/connectivity.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart';
@ -115,7 +115,7 @@ class _HomePageState extends State<HomePage> {
// cataloguing is essential for coordinates and video rotation
await entry.catalog();
// locating is fine in the background
unawaited(connectivity.canGeolocate.then((connected) {
unawaited(availability.canGeolocate.then((connected) {
if (connected) {
entry.locate();
}
@ -161,8 +161,6 @@ class _HomePageState extends State<HomePage> {
CollectionLens(
source: source,
filters: filters,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
),
),
);

View file

@ -15,7 +15,7 @@ class ExpandableFilterRow extends StatelessWidget {
const ExpandableFilterRow({
this.title,
@required this.filters,
this.expandedNotifier,
@required this.expandedNotifier,
this.heroTypeBuilder,
@required this.onTap,
});
@ -29,7 +29,7 @@ class ExpandableFilterRow extends StatelessWidget {
final hasTitle = title != null && title.isNotEmpty;
final isExpanded = hasTitle && expandedNotifier?.value == title;
final isExpanded = hasTitle && expandedNotifier.value == title;
Widget titleRow;
if (hasTitle) {
@ -52,7 +52,7 @@ class ExpandableFilterRow extends StatelessWidget {
);
}
final filtersList = filters.toList();
final filterList = filters.toList();
final wrap = Container(
key: ValueKey('wrap$title'),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
@ -62,7 +62,7 @@ class ExpandableFilterRow extends StatelessWidget {
child: Wrap(
spacing: horizontalPadding,
runSpacing: verticalPadding,
children: filtersList.map(_buildFilterChip).toList(),
children: filterList.map(_buildFilterChip).toList(),
),
);
final list = Container(
@ -76,10 +76,10 @@ class ExpandableFilterRow extends StatelessWidget {
physics: BouncingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
itemBuilder: (context, index) {
return index < filtersList.length ? _buildFilterChip(filtersList[index]) : null;
return index < filterList.length ? _buildFilterChip(filterList[index]) : null;
},
separatorBuilder: (context, index) => SizedBox(width: 8),
itemCount: filtersList.length,
itemCount: filterList.length,
),
);
final filterChips = isExpanded ? wrap : list;

View file

@ -86,7 +86,7 @@ class CollectionSearchDelegate {
MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)),
].where((f) => f != null && containQuery(f.label)).toList(),
// usually perform hero animation only on tapped chips,
// but we also need to animate the query chip when it is selected by submitting the search query
heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap,
@ -100,7 +100,8 @@ class CollectionSearchDelegate {
StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final filters = source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName));
// filter twice: full path, and then unique name
final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)).toList()..sort();
return _buildFilterRow(
context: context,
title: 'Albums',
@ -110,7 +111,7 @@ class CollectionSearchDelegate {
StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) {
final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s));
final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)).toList();
return _buildFilterRow(
context: context,
title: 'Countries',
@ -154,7 +155,7 @@ class CollectionSearchDelegate {
Widget _buildFilterRow({
@required BuildContext context,
String title,
@required Iterable<CollectionFilter> filters,
@required List<CollectionFilter> filters,
HeroType Function(CollectionFilter filter) heroTypeBuilder,
}) {
return ExpandableFilterRow(
@ -219,8 +220,6 @@ class CollectionSearchDelegate {
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,

View file

@ -0,0 +1,67 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HiddenFilters extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => s.hiddenFilters,
builder: (context, hiddenFilters, child) {
return ListTile(
title: hiddenFilters.isEmpty ? Text('There are no hidden filters') : Text('Hidden filters'),
trailing: hiddenFilters.isEmpty
? null
: OutlinedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: HiddenFilterPage.routeName),
builder: (context) => HiddenFilterPage(),
),
);
},
child: Text('Edit'.toUpperCase()),
),
);
});
}
}
class HiddenFilterPage extends StatelessWidget {
static const routeName = '/settings/hidden';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Hidden Filters'),
),
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(8),
child: Consumer<Settings>(
builder: (context, settings, child) {
final filterList = settings.hiddenFilters.toList()..sort();
return Wrap(
spacing: 8,
runSpacing: 8,
children: filterList
.map((filter) => AvesFilterChip(
filter: filter,
removable: true,
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
))
.toList(),
);
},
),
),
),
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/entry_background.dart';
import 'package:aves/widgets/settings/hidden_filters.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
@ -241,6 +242,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text('Allow anonymous analytics and crash reporting'),
),
HiddenFilters(),
Padding(
padding: EdgeInsets.only(top: 8, bottom: 16),
child: GrantedDirectories(),

View file

@ -1,11 +1,10 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
@ -30,7 +29,7 @@ class StatsPage extends StatelessWidget {
final CollectionLens parentCollection;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
List<AvesEntry> get entries => parentCollection?.sortedEntries ?? source.rawEntries;
Set<AvesEntry> get entries => parentCollection?.sortedEntries?.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0;
@ -273,8 +272,6 @@ class StatsPage extends StatelessWidget {
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,

View file

@ -141,7 +141,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
showFeedback(context, 'Failed');
} else {
if (hasCollection) {
collection.source.removeEntries([entry]);
collection.source.removeEntries({entry.uri});
}
EntryDeletedNotification(entry).dispatch(context);
}

View file

@ -89,7 +89,6 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
mainEntry: entry,
page: page,
viewportSize: mqSize,
heroTag: widget.collection.heroTag(entry),
onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),

View file

@ -1,6 +1,6 @@
import 'dart:math';
import 'package:aves/model/connectivity.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
@ -152,7 +152,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
// make sure to locate the entry,
// so that we can display the address instead of coordinates
// even when initial collection locating has not reached this entry yet
connectivity.canGeolocate.then((connected) {
availability.canGeolocate.then((connected) {
if (connected) {
entry.locate();
}

View file

@ -1,6 +1,6 @@
import 'dart:math';
import 'package:aves/model/connectivity.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/screen_on.dart';
@ -12,6 +12,7 @@ import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage.dart';
import 'package:aves/widgets/viewer/overlay/bottom.dart';
@ -57,6 +58,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
final List<Tuple2<String, MultiPageController>> _multiPageControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final ValueNotifier<HeroInfo> _heroInfoNotifier = ValueNotifier(null);
CollectionLens get collection => widget.collection;
@ -74,6 +76,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void initState() {
super.initState();
final entry = widget.initialEntry;
// opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry;
_currentHorizontalPage = max(0, entries.indexOf(entry));
_currentVerticalPage = ValueNotifier(imagePage);
@ -147,7 +151,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
_pauseVideoControllers();
break;
case AppLifecycleState.resumed:
connectivity.onResume();
availability.onResume();
break;
default:
break;
@ -161,43 +165,47 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
if (_currentVerticalPage.value == infoPage) {
// back from info to image
_goToVerticalPage(imagePage);
return SynchronousFuture(false);
} else {
_popVisual();
}
_onLeave();
return SynchronousFuture(true);
return SynchronousFuture(false);
},
child: NotificationListener(
onNotification: (notification) {
if (notification is FilterNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry);
}
return false;
},
child: Stack(
children: [
ViewerVerticalPageView(
collection: collection,
entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
multiPageControllers: _multiPageControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
),
_buildTopOverlay(),
_buildBottomOverlay(),
BottomGestureAreaProtector(),
],
),
),
child: ValueListenableProvider<HeroInfo>.value(
value: _heroInfoNotifier,
builder: (context, snapshot) {
return NotificationListener(
onNotification: (notification) {
if (notification is FilterNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry);
}
return false;
},
child: Stack(
children: [
ViewerVerticalPageView(
collection: collection,
entryNotifier: _entryNotifier,
videoControllers: _videoControllers,
multiPageControllers: _multiPageControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
),
_buildTopOverlay(),
_buildBottomOverlay(),
BottomGestureAreaProtector(),
],
),
);
}),
);
}
@ -329,7 +337,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
void _goToCollection(CollectionFilter filter) {
_showSystemUI();
_onLeave();
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
@ -350,7 +358,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Future<void> _goToVerticalPage(int page) {
return _verticalPager.animateToPage(
page,
duration: Durations.viewerPageAnimation,
duration: Durations.viewerVerticalPageScrollAnimation,
curve: Curves.easeInOut,
);
}
@ -359,8 +367,10 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
_currentVerticalPage.value = page;
if (page == transitionPage) {
await _actionDelegate.dismissFeedback();
_onLeave();
Navigator.pop(context);
_popVisual();
} else if (page == infoPage) {
// prevent hero when viewer is offscreen
_heroInfoNotifier.value = null;
}
}
@ -403,11 +413,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
_initViewStateControllers();
}
void _onLeave() {
void _popVisual() {
if (Navigator.canPop(context)) {
_showSystemUI();
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
Screen.keepOn(false);
void pop() {
_onLeave();
Navigator.pop(context);
}
// closing hero, with viewer as source
final heroInfo = HeroInfo(collection?.id, _entryNotifier.value);
if (_heroInfoNotifier.value != heroInfo) {
_heroInfoNotifier.value = heroInfo;
// we post closing the viewer page so that hero animation source is ready
WidgetsBinding.instance.addPostFrameCallback((_) => pop());
} else {
// viewer already has correct hero info, no need to rebuild
pop();
}
} else {
// exit app when trying to pop a viewer page for a single entry
@ -415,6 +436,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
}
void _onLeave() {
_showSystemUI();
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
Screen.keepOn(false);
}
}
// system UI
static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
@ -439,7 +467,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
_overlayAnimationController.value = _overlayAnimationController.upperBound;
}
} else {
final mediaQuery = Provider.of<MediaQueryData>(context, listen: false);
final mediaQuery = context.read<MediaQueryData>();
setState(() {
_frozenViewInsets = mediaQuery.viewInsets;
_frozenViewPadding = mediaQuery.viewPadding;

View file

@ -0,0 +1,21 @@
import 'package:aves/model/entry.dart';
import 'package:flutter/widgets.dart';
class HeroInfo {
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final int collectionId;
final AvesEntry entry;
const HeroInfo(this.collectionId, this.entry);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is HeroInfo && other.collectionId == collectionId && other.entry == entry;
}
@override
int get hashCode => hashValues(collectionId, entry);
}

View file

@ -107,7 +107,7 @@ class _InfoPageState extends State<InfoPage> {
BackUpNotification().dispatch(context);
_scrollController.animateTo(
0,
duration: Durations.viewerPageAnimation,
duration: Durations.viewerVerticalPageScrollAnimation,
curve: Curves.easeInOut,
);
}

View file

@ -17,7 +17,7 @@ class InfoSearchDelegate extends SearchDelegate {
static const suggestions = {
'Date & time': 'date or time or when -timer -uptime -exposure -timeline',
'Description': 'abstract or description or comment',
'Description': 'abstract or description or comment or textual',
'Dimensions': 'width or height or dimension or framesize or imagelength',
'Resolution': 'resolution',
'Rights': 'rights or copyright or artist or creator or by-line or credit -tool',

View file

@ -1,4 +1,4 @@
import 'package:aves/model/connectivity.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/coordinate_format.dart';
@ -104,7 +104,7 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
children: [
if (widget.showTitle) SectionRow(AIcons.location),
FutureBuilder<bool>(
future: connectivity.isConnected,
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return SizedBox();
return NotificationListener(
@ -181,7 +181,7 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> {
@override
void initState() {
super.initState();
_addressLineLoader = connectivity.canGeolocate.then((connected) {
_addressLineLoader = availability.canGeolocate.then((connected) {
if (connected) {
return entry.findAddressLine();
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart';
@ -73,13 +74,19 @@ class MapButtonPanel extends StatelessWidget {
MapOverlayButton(
icon: AIcons.layers,
onPressed: () async {
final hasPlayServices = await availability.hasPlayServices;
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
context: context,
builder: (context) => AvesSelectionDialog<EntryMapStyle>(
initialValue: settings.infoMapStyle,
options: Map.fromEntries(EntryMapStyle.values.map((v) => MapEntry(v, v.name))),
title: 'Map Style',
),
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.name))),
title: 'Map Style',
);
},
);
// wait for the dialog to hide because switching to Google Maps layer may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);

View file

@ -27,6 +27,7 @@ class MultiPageOverlay extends StatefulWidget {
}
class _MultiPageOverlayState extends State<MultiPageOverlay> {
final _cancellableNotifier = ValueNotifier(true);
ScrollController _scrollController;
bool _syncScroll = true;
@ -78,89 +79,64 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
final marginWidth = max(0, (availableWidth - extent) / 2 - separatorWidth);
final horizontalMargin = SizedBox(width: marginWidth);
final separator = SizedBox(width: separatorWidth);
final shade = IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black38,
),
),
);
return FutureBuilder<MultiPageInfo>(
future: controller.info,
builder: (context, snapshot) {
final multiPageInfo = snapshot.data;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox.shrink();
return Container(
height: extent + separatorWidth * 2,
child: Stack(
children: [
Positioned(
top: separatorWidth,
width: availableWidth,
height: extent,
child: ListView.separated(
key: ValueKey(mainEntry),
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
final page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page));
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox();
if (multiPageInfo.uri != mainEntry.uri) return SizedBox();
return SizedBox(
height: extent,
child: ListView.separated(
key: ValueKey(mainEntry),
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
final page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page));
return GestureDetector(
onTap: () async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageChooserAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
child: DecoratedThumbnail(
entry: pageEntry,
extent: extent,
selectable: false,
highlightable: false,
),
);
},
separatorBuilder: (context, index) => separator,
itemCount: multiPageInfo.pageCount + 2,
),
),
Positioned(
left: 0,
top: separatorWidth,
width: marginWidth + separatorWidth,
height: extent,
child: shade,
),
Positioned(
top: separatorWidth,
right: 0,
width: marginWidth + separatorWidth,
height: extent,
child: shade,
),
Positioned(
top: 0,
width: availableWidth,
height: separatorWidth,
child: shade,
),
Positioned(
bottom: 0,
width: availableWidth,
height: separatorWidth,
child: shade,
),
],
return Stack(
children: [
GestureDetector(
onTap: () async {
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
child: DecoratedThumbnail(
entry: pageEntry,
extent: extent,
// the retrieval task queue can pile up for thumbnails of heavy pages
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
// so we cancel these requests when possible
cancellableNotifier: _cancellableNotifier,
selectable: false,
highlightable: false,
),
),
IgnorePointer(
child: AnimatedContainer(
color: controller.page == page ? Colors.transparent : Colors.black45,
width: extent,
height: extent,
duration: Durations.viewerOverlayPageShadeAnimation,
),
)
],
);
},
separatorBuilder: (context, index) => separator,
itemCount: multiPageInfo.pageCount + 2,
),
);
},

View file

@ -11,6 +11,7 @@ import 'package:aves/widgets/common/magnifier/magnifier.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/visual/error.dart';
import 'package:aves/widgets/viewer/visual/raster.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
@ -20,13 +21,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryPageView extends StatefulWidget {
final AvesEntry mainEntry;
final AvesEntry entry;
final SinglePageInfo page;
final Size viewportSize;
final Object heroTag;
final MagnifierTapCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed;
@ -35,10 +37,9 @@ class EntryPageView extends StatefulWidget {
EntryPageView({
Key key,
AvesEntry mainEntry,
this.mainEntry,
this.page,
this.viewportSize,
this.heroTag,
@required this.onTap,
@required this.videoControllers,
this.onDisposed,
@ -54,6 +55,8 @@ class _EntryPageViewState extends State<EntryPageView> {
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
final List<StreamSubscription> _subscriptions = [];
AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get entry => widget.entry;
Size get viewportSize => widget.viewportSize;
@ -140,13 +143,14 @@ class _EntryPageViewState extends State<EntryPageView> {
},
);
return widget.heroTag != null
? Hero(
tag: widget.heroTag,
transitionOnUserGestures: true,
child: child,
)
: child;
return Consumer<HeroInfo>(
builder: (context, info, child) => Hero(
tag: info?.entry == mainEntry ? hashValues(info.collectionId, mainEntry) : hashCode,
transitionOnUserGestures: true,
child: child,
),
child: child,
);
}
Widget _buildRasterView() {

View file

@ -168,7 +168,7 @@ packages:
name: decorated_icon
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "1.0.3"
event_bus:
dependency: "direct main"
description:
@ -380,6 +380,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
github:
dependency: "direct main"
description:
name: github
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.4"
glob:
dependency: transitive
description:
@ -387,20 +394,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
google_api_availability:
dependency: "direct main"
description:
name: google_api_availability
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
google_maps_flutter:
dependency: "direct main"
description:
name: google_maps_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
version: "1.2.0"
google_maps_flutter_platform_interface:
dependency: transitive
description:
name: google_maps_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
version: "1.2.0"
highlight:
dependency: transitive
description:
@ -457,6 +471,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3-nullsafety.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
json_rpc_2:
dependency: transitive
description:
@ -659,21 +680,21 @@ packages:
name: percent_indicator
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.9"
version: "2.1.9+1"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.1+1"
version: "5.1.0+2"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
version: "2.0.2"
petitparser:
dependency: transitive
description:
@ -806,7 +827,7 @@ packages:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2+2"
version: "0.0.2+3"
shelf:
dependency: transitive
description:
@ -867,7 +888,7 @@ packages:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.2+2"
version: "1.3.2+3"
sqflite_common:
dependency: transitive
description:
@ -1036,6 +1057,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0-nullsafety.3"
version:
dependency: "direct main"
description:
name: version
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
vm_service:
dependency: transitive
description:
@ -1077,7 +1105,7 @@ packages:
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.4"
version: "0.7.5"
win32:
dependency: transitive
description:

View file

@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.3.3+39
version: 1.3.4+40
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)
@ -55,6 +55,8 @@ dependencies:
flutter_staggered_animations:
flutter_svg:
geocoder:
github:
google_api_availability:
google_maps_flutter:
intl:
latlong: # for flutter_map
@ -75,6 +77,7 @@ dependencies:
streams_channel:
tuple:
url_launcher:
version:
xml:
dev_dependencies:

View file

@ -1,6 +1,5 @@
Thanks for using Aves!
v1.3.3:
- multi-track HEIF support
- image export (including embedded and multi-page images)
- listen to Media Store changes
v1.3.4:
- hide album, country or tag from collection
- new version check
Full changelog available on Github