Merge branch 'develop'
This commit is contained in:
commit
f2bd7b294f
100 changed files with 1674 additions and 879 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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 |
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
72
lib/model/availability.dart
Normal file
72
lib/model/availability.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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,
|
||||
|
|
47
lib/services/media_store_service.dart
Normal file
47
lib/services/media_store_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
74
lib/widgets/about/app_ref.dart
Normal file
74
lib/widgets/about/app_ref.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
43
lib/widgets/about/credits.dart
Normal file
43
lib/widgets/about/credits.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
92
lib/widgets/about/new_version.dart
Normal file
92
lib/widgets/about/new_version.dart
Normal 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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
12
lib/widgets/about/news_badge.dart
Normal file
12
lib/widgets/about/news_badge.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -63,11 +63,12 @@ class VectorImageThumbnail extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
);
|
||||
return heroTag == null
|
||||
? child
|
||||
: Hero(
|
||||
return heroTag != null
|
||||
? Hero(
|
||||
tag: heroTag,
|
||||
transitionOnUserGestures: true,
|
||||
child: child,
|
||||
);
|
||||
)
|
||||
: child;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}',
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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}',
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
67
lib/widgets/settings/hidden_filters.dart
Normal file
67
lib/widgets/settings/hidden_filters.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
21
lib/widgets/viewer/hero.dart
Normal file
21
lib/widgets/viewer/hero.dart
Normal 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);
|
||||
}
|
|
@ -107,7 +107,7 @@ class _InfoPageState extends State<InfoPage> {
|
|||
BackUpNotification().dispatch(context);
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: Durations.viewerPageAnimation,
|
||||
duration: Durations.viewerVerticalPageScrollAnimation,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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() {
|
||||
|
|
46
pubspec.lock
46
pubspec.lock
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue