From 020c63f4991445fdb6a97a0ef5a70c3f74ebdaa8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 31 Jan 2021 16:11:11 +0900 Subject: [PATCH 01/32] safer content URI parsing --- .../aves/channel/calls/DebugHandler.kt | 6 ++---- .../aves/channel/calls/MetadataHandler.kt | 6 ++---- .../calls/fetchers/ThumbnailFetcher.kt | 4 ++-- .../thibault/aves/model/SourceEntry.kt | 14 ++------------ .../aves/model/provider/ImageProvider.kt | 15 +++++++++------ .../model/provider/MediaStoreImageProvider.kt | 19 +++++++++++-------- .../deckers/thibault/aves/utils/UriUtils.kt | 18 ++++++++++++++++++ 7 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/UriUtils.kt diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 6b4401073..0de4bf39f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -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 } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 940ab0cba..505b50f4b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -61,6 +61,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 @@ -639,8 +640,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 +649,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 } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index e631af9eb..47d31bfaa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -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") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index 5a51905da..bfbdc737c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index f92ba728c..da1fa2c49 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index cf90f1b5d..b2386f961 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -19,6 +19,7 @@ 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.* @@ -34,19 +35,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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/UriUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/UriUtils.kt new file mode 100644 index 000000000..6ac38aeb6 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/UriUtils.kt @@ -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 + } +} \ No newline at end of file From db299d73ca0e8525ad6d3a28a11ab6ff418b7284 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 1 Feb 2021 15:03:59 +0900 Subject: [PATCH 02/32] fixed viewer hero --- lib/model/source/collection_lens.dart | 2 - .../collection/thumbnail/decorated.dart | 10 +- lib/widgets/collection/thumbnail/raster.dart | 17 ++- lib/widgets/collection/thumbnail/vector.dart | 15 +-- .../common/identity/aves_filter_chip.dart | 1 + lib/widgets/debug/settings.dart | 64 +++++----- .../viewer/entry_horizontal_pager.dart | 1 - lib/widgets/viewer/entry_viewer_stack.dart | 114 +++++++++++------- .../viewer/visual/entry_page_view.dart | 24 ++-- 9 files changed, 139 insertions(+), 109 deletions(-) diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index a151de471..b65e64934 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -77,8 +77,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) { diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 40720893f..2920c100c 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -11,12 +11,11 @@ class DecoratedThumbnail extends StatelessWidget { final CollectionLens collection; final ValueNotifier isScrollingNotifier; 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, @@ -24,8 +23,7 @@ class DecoratedThumbnail extends StatelessWidget { this.isScrollingNotifier, this.selectable = true, this.highlightable = true, - }) : heroTag = collection?.heroTag(entry), - super(key: key); + }) : super(key: key); @override Widget build(BuildContext context) { @@ -33,13 +31,13 @@ class DecoratedThumbnail extends StatelessWidget { ? VectorImageThumbnail( entry: entry, extent: extent, - heroTag: heroTag, + canHero: true, ) : RasterImageThumbnail( entry: entry, extent: extent, isScrollingNotifier: isScrollingNotifier, - heroTag: heroTag, + canHero: true, ); child = Stack( diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 5a37c8367..a67caeb4d 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -10,14 +10,14 @@ class RasterImageThumbnail extends StatefulWidget { final AvesEntry entry; final double extent; final ValueNotifier isScrollingNotifier; - final Object heroTag; + final bool canHero; const RasterImageThumbnail({ Key key, @required this.entry, @required this.extent, this.isScrollingNotifier, - this.heroTag, + this.canHero = false, }) : super(key: key); @override @@ -31,8 +31,6 @@ class _RasterImageThumbnailState extends State { double get extent => widget.extent; - Object get heroTag => widget.heroTag; - @override void initState() { super.initState(); @@ -126,18 +124,19 @@ class _RasterImageThumbnailState extends State { height: extent, fit: BoxFit.cover, ); - return heroTag == null - ? image - : Hero( - tag: heroTag, + return widget.canHero + ? Hero( + tag: entry, 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( diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index c930d9afa..dc68a25f0 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -10,13 +10,13 @@ import 'package:provider/provider.dart'; class VectorImageThumbnail extends StatelessWidget { final AvesEntry entry; final double extent; - final Object heroTag; + final bool canHero; const VectorImageThumbnail({ Key key, @required this.entry, @required this.extent, - this.heroTag, + this.canHero = false, }) : super(key: key); @override @@ -63,11 +63,12 @@ class VectorImageThumbnail extends StatelessWidget { ); }, ); - return heroTag == null - ? child - : Hero( - tag: heroTag, + return canHero + ? Hero( + tag: canHero, + transitionOnUserGestures: true, child: child, - ); + ) + : child; } } diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 6d6b9a092..d096bdcda 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -211,6 +211,7 @@ class _AvesFilterChipState extends State { if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) { chip = Hero( tag: filter, + transitionOnUserGestures: true, child: DefaultTextStyle( style: TextStyle(), child: chip, diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index c6ff629c0..d695c4126 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -11,37 +11,39 @@ import 'package:provider/provider.dart'; class DebugSettingsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer(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( + 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), + }), + ), + ], + ); + }, + ); } } diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 7927597a4..d5766ffeb 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -89,7 +89,6 @@ class _MultiEntryScrollerState extends State 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), diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index f9eec2f2d..3f0d9a965 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -57,6 +57,7 @@ class _EntryViewerStackState extends State with SingleTickerPr final List> _videoControllers = []; final List> _multiPageControllers = []; final List>> _viewStateNotifiers = []; + final ValueNotifier _visualLeaveInfoNotifier = ValueNotifier(null); CollectionLens get collection => widget.collection; @@ -161,43 +162,47 @@ class _EntryViewerStackState extends State 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.value( + value: _visualLeaveInfoNotifier, + 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 +334,7 @@ class _EntryViewerStackState extends State with SingleTickerPr } void _goToCollection(CollectionFilter filter) { - _showSystemUI(); + _onLeave(); Navigator.pushAndRemoveUntil( context, MaterialPageRoute( @@ -359,8 +364,7 @@ class _EntryViewerStackState extends State with SingleTickerPr _currentVerticalPage.value = page; if (page == transitionPage) { await _actionDelegate.dismissFeedback(); - _onLeave(); - Navigator.pop(context); + _popVisual(); } } @@ -403,18 +407,27 @@ class _EntryViewerStackState extends State with SingleTickerPr _initViewStateControllers(); } - void _onLeave() { + void _popVisual() { if (Navigator.canPop(context)) { - _showSystemUI(); - if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { - Screen.keepOn(false); - } + _visualLeaveInfoNotifier.value = VisualLeaveInfo(_entryNotifier.value); + // we post closing the viewer page so that hero animation source is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + _onLeave(); + Navigator.pop(context); + }); } else { // exit app when trying to pop a viewer page for a single entry SystemNavigator.pop(); } } + void _onLeave() { + _showSystemUI(); + if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { + Screen.keepOn(false); + } + } + // system UI static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); @@ -505,3 +518,18 @@ class _EntryViewerStackState extends State with SingleTickerPr void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); } + +class VisualLeaveInfo { + final AvesEntry entry; + + const VisualLeaveInfo(this.entry); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is VisualLeaveInfo && other.entry == entry; + } + + @override + int get hashCode => entry.hashCode; +} diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index ba18f7473..ce9e98b25 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -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/entry_viewer_stack.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> 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 { final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); final List _subscriptions = []; + AvesEntry get mainEntry => widget.mainEntry; + AvesEntry get entry => widget.entry; Size get viewportSize => widget.viewportSize; @@ -140,13 +143,14 @@ class _EntryPageViewState extends State { }, ); - return widget.heroTag != null - ? Hero( - tag: widget.heroTag, - transitionOnUserGestures: true, - child: child, - ) - : child; + return Consumer( + builder: (context, info, child) => Hero( + tag: info?.entry == mainEntry ? mainEntry : hashCode, + transitionOnUserGestures: true, + child: child, + ), + child: child, + ); } Widget _buildRasterView() { From e77ed021643bee4e2aedcbd8c217665a7f479444 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 1 Feb 2021 17:21:31 +0900 Subject: [PATCH 03/32] vector thumbnail hero fix --- lib/widgets/collection/thumbnail/vector.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index dc68a25f0..c2cb919ed 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -65,7 +65,7 @@ class VectorImageThumbnail extends StatelessWidget { ); return canHero ? Hero( - tag: canHero, + tag: entry, transitionOnUserGestures: true, child: child, ) From 910dd1fe54faca2e05d0275b2b46d652a1622965 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 1 Feb 2021 17:58:40 +0900 Subject: [PATCH 04/32] fixed viewer hero --- lib/widgets/viewer/entry_viewer_stack.dart | 44 +++++++++---------- lib/widgets/viewer/hero.dart | 16 +++++++ .../viewer/visual/entry_page_view.dart | 4 +- 3 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 lib/widgets/viewer/hero.dart diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 3f0d9a965..c6fc73aee 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.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,7 +58,7 @@ class _EntryViewerStackState extends State with SingleTickerPr final List> _videoControllers = []; final List> _multiPageControllers = []; final List>> _viewStateNotifiers = []; - final ValueNotifier _visualLeaveInfoNotifier = ValueNotifier(null); + final ValueNotifier _heroInfoNotifier = ValueNotifier(null); CollectionLens get collection => widget.collection; @@ -75,6 +76,8 @@ class _EntryViewerStackState extends State with SingleTickerPr void initState() { super.initState(); final entry = widget.initialEntry; + // opening hero, with viewer as target + _heroInfoNotifier.value = HeroInfo(entry); _entryNotifier.value = entry; _currentHorizontalPage = max(0, entries.indexOf(entry)); _currentVerticalPage = ValueNotifier(imagePage); @@ -167,8 +170,8 @@ class _EntryViewerStackState extends State with SingleTickerPr } return SynchronousFuture(false); }, - child: ValueListenableProvider.value( - value: _visualLeaveInfoNotifier, + child: ValueListenableProvider.value( + value: _heroInfoNotifier, builder: (context, snapshot) { return NotificationListener( onNotification: (notification) { @@ -365,6 +368,9 @@ class _EntryViewerStackState extends State with SingleTickerPr if (page == transitionPage) { await _actionDelegate.dismissFeedback(); _popVisual(); + } else if (page == infoPage) { + // prevent hero when viewer is offscreen + _heroInfoNotifier.value = null; } } @@ -409,12 +415,21 @@ class _EntryViewerStackState extends State with SingleTickerPr void _popVisual() { if (Navigator.canPop(context)) { - _visualLeaveInfoNotifier.value = VisualLeaveInfo(_entryNotifier.value); - // we post closing the viewer page so that hero animation source is ready - WidgetsBinding.instance.addPostFrameCallback((_) { + void pop() { _onLeave(); Navigator.pop(context); - }); + } + + // closing hero, with viewer as source + final heroInfo = HeroInfo(_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 SystemNavigator.pop(); @@ -518,18 +533,3 @@ class _EntryViewerStackState extends State with SingleTickerPr void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); } - -class VisualLeaveInfo { - final AvesEntry entry; - - const VisualLeaveInfo(this.entry); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is VisualLeaveInfo && other.entry == entry; - } - - @override - int get hashCode => entry.hashCode; -} diff --git a/lib/widgets/viewer/hero.dart b/lib/widgets/viewer/hero.dart new file mode 100644 index 000000000..c7db1594c --- /dev/null +++ b/lib/widgets/viewer/hero.dart @@ -0,0 +1,16 @@ +import 'package:aves/model/entry.dart'; + +class HeroInfo { + final AvesEntry entry; + + const HeroInfo(this.entry); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is HeroInfo && other.entry == entry; + } + + @override + int get hashCode => entry.hashCode; +} diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index ce9e98b25..d822de4ce 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -11,7 +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/entry_viewer_stack.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'; @@ -143,7 +143,7 @@ class _EntryPageViewState extends State { }, ); - return Consumer( + return Consumer( builder: (context, info, child) => Hero( tag: info?.entry == mainEntry ? mainEntry : hashCode, transitionOnUserGestures: true, From ab96741a18827ab4961df25ba9d19ad85eaacbf9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 2 Feb 2021 12:01:17 +0900 Subject: [PATCH 05/32] deactivate geocoding and Google maps when Play Services are unavailable --- lib/model/availability.dart | 41 +++++++++++++++++++ lib/model/connectivity.dart | 28 ------------- lib/model/entry.dart | 8 +++- lib/model/source/location.dart | 4 +- lib/widgets/home_page.dart | 4 +- lib/widgets/viewer/entry_vertical_pager.dart | 4 +- lib/widgets/viewer/entry_viewer_stack.dart | 4 +- lib/widgets/viewer/info/location_section.dart | 6 +-- lib/widgets/viewer/info/maps/common.dart | 17 +++++--- pubspec.lock | 7 ++++ pubspec.yaml | 1 + 11 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 lib/model/availability.dart delete mode 100644 lib/model/connectivity.dart diff --git a/lib/model/availability.dart b/lib/model/availability.dart new file mode 100644 index 000000000..735c802e3 --- /dev/null +++ b/lib/model/availability.dart @@ -0,0 +1,41 @@ +import 'package:connectivity/connectivity.dart'; +import 'package:flutter/foundation.dart'; +import 'package:google_api_availability/google_api_availability.dart'; + +final AvesAvailability availability = AvesAvailability._private(); + +class AvesAvailability { + bool _isConnected, _hasPlayServices; + + AvesAvailability._private() { + Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); + } + + void onResume() => _isConnected = null; + + Future 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 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 get canGeolocate => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); +} diff --git a/lib/model/connectivity.dart b/lib/model/connectivity.dart deleted file mode 100644 index 009c0fa1c..000000000 --- a/lib/model/connectivity.dart +++ /dev/null @@ -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 get isConnected async { - if (_isConnected != null) return SynchronousFuture(_isConnected); - final result = await (Connectivity().checkConnectivity()); - _updateFromResult(result); - return _isConnected; - } - - Future get canGeolocate => isConnected; - - void _updateFromResult(ConnectivityResult result) { - _isConnected = result != ConnectivityResult.none; - debugPrint('Device is connected=$_isConnected'); - } -} diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 384b29a4b..8e2831d0d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates; + // TODO TLAD make it dynamic if it depends on OS/lib versions static const List undecodable = [MimeTypes.crw, MimeTypes.psd]; @@ -441,7 +445,7 @@ class AvesEntry { final coordinates = Coordinates(latitude, longitude); try { - Future> call() => Geocoder.local.findAddressesFromCoordinates(coordinates); + Future> call() => _findAddresses(coordinates); final addresses = await (background ? servicePolicy.call( call, @@ -475,7 +479,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; diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 37d6c0af8..ef5d36f51 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -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'; @@ -28,7 +28,7 @@ mixin LocationMixin on SourceBase { } Future locateEntries() async { - if (!(await connectivity.canGeolocate)) return; + if (!(await availability.canGeolocate)) return; // final stopwatch = Stopwatch()..start(); final byLocated = groupBy(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 0cff14049..1a0374e18 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -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 { // 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(); } diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 2abf301a7..8a4c00325 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -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 { // 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(); } diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index c6fc73aee..405817f62 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -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'; @@ -151,7 +151,7 @@ class _EntryViewerStackState extends State with SingleTickerPr _pauseVideoControllers(); break; case AppLifecycleState.resumed: - connectivity.onResume(); + availability.onResume(); break; default: break; diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 1dd7a3e0c..485c4639a 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -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 with TickerProviderSt children: [ if (widget.showTitle) SectionRow(AIcons.location), FutureBuilder( - 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(); } diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index ee28ed748..6cf73e190 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -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( context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.infoMapStyle, - options: Map.fromEntries(EntryMapStyle.values.map((v) => MapEntry(v, v.name))), - title: 'Map Style', - ), + builder: (context) { + return AvesSelectionDialog( + 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); diff --git a/pubspec.lock b/pubspec.lock index 514b21954..432448f83 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -387,6 +387,13 @@ 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: diff --git a/pubspec.yaml b/pubspec.yaml index 19b833434..888311dc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: flutter_staggered_animations: flutter_svg: geocoder: + google_api_availability: google_maps_flutter: intl: latlong: # for flutter_map From e4ed5ef7511811484df1bd76300f7799c3ba17f9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 2 Feb 2021 12:43:37 +0900 Subject: [PATCH 06/32] media store monitoring: fixed temp entry handling --- lib/main.dart | 17 +++++++--- lib/model/source/media_store_source.dart | 40 +++++++++++++++++------- lib/theme/durations.dart | 8 +---- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 02cb28210..4cefeea66 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -190,10 +190,17 @@ class _AvesAppState extends State { } 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 = List.of(changedUris); + changedUris.clear(); + final tempUris = await _mediaStoreSource.refreshUris(todo); + if (tempUris.isNotEmpty) { + changedUris.addAll(tempUris); + _onContentChange(null); + } + }); + } } } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index ea0cd40f2..44f24eae2 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; @@ -105,8 +106,12 @@ class MediaStoreSource extends CollectionSource { ); } - Future refreshUris(List changedUris) async { - if (!_initialized) return; + // returns URIs that are 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> refreshUris(List changedUris) async { + final tempUris = []; + if (!_initialized) return tempUris; final uriByContentId = Map.fromEntries(changedUris.map((uri) { if (uri == null) return null; @@ -121,7 +126,7 @@ class MediaStoreSource extends CollectionSource { uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId)); metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); - // add new entries + // fetch new entries final newEntries = []; for (final kv in uriByContentId.entries) { final contentId = kv.key; @@ -129,20 +134,31 @@ class MediaStoreSource extends CollectionSource { 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); + final volume = androidFileUtils.getStorageVolume(sourceEntry.path); + if (volume != null) { + newEntries.add(sourceEntry); + } 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) { + addAll(newEntries); + await metadataDb.saveEntries(newEntries); + updateAlbums(); - 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 diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 9cc883fda..0c8827f16 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -48,11 +48,5 @@ class Durations { static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); - - // 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); + static const contentChangeDebounceDelay = Duration(milliseconds: 500); } From e02593def39927ebab667086bd7c42bed1aa781c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 2 Feb 2021 19:54:28 +0900 Subject: [PATCH 07/32] perf: improved task pause/resume --- lib/image_providers/thumbnail_provider.dart | 16 ++-- lib/services/image_file_service.dart | 1 - lib/services/service_policy.dart | 96 ++++++++++----------- lib/widgets/debug/overlay.dart | 3 +- 4 files changed, 54 insertions(+), 62 deletions(-) diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index fac117ab4..0f7e9d063 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -21,7 +21,7 @@ class ThumbnailProvider extends ImageProvider { 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}'; } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 723d1312e..75ad7eda1 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -204,7 +204,6 @@ class ImageFileService { } return null; }, -// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}', priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail), key: taskKey, ); diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index c4f8fa043..ab16a69eb 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -9,8 +9,8 @@ final ServicePolicy servicePolicy = ServicePolicy._private(); class ServicePolicy { final StreamController _queueStreamController = StreamController.broadcast(); final Map> _paused = {}; - final SplayTreeMap> _queues = SplayTreeMap(); - final Queue<_Task> _runningQueue = Queue(); + final SplayTreeMap> _queues = SplayTreeMap(); + final LinkedHashMap _runningQueue = LinkedHashMap(); // magic number static const concurrentTaskMax = 4; @@ -22,57 +22,59 @@ class ServicePolicy { Future call( Future Function() platformCall, { int priority = ServiceCallPriority.normal, - String debugLabel, Object key, }) { + Completer 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(); + 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(); - 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 resume(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 _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 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 queueByPriority; - final int runningQueue; + final int runningCount, pausedCount; - const QueueState(this.queueByPriority, this.runningQueue); + const QueueState(this.queueByPriority, this.runningCount, this.pausedCount); } diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 6fc169a14..463aff4a0 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -24,7 +24,8 @@ class DebugTaskQueueOverlay extends StatelessWidget { final queuedEntries = >[]; 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)); From 3980e42234cc24376ae243c863c5b987d1980400 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 2 Feb 2021 20:42:53 +0900 Subject: [PATCH 08/32] perf: improved filtering --- lib/model/entry.dart | 8 +++++++- lib/model/filters/album.dart | 3 +-- lib/model/filters/favourite.dart | 3 +-- lib/model/filters/filters.dart | 4 +++- lib/model/filters/location.dart | 12 ++++++++++-- lib/model/filters/mime.dart | 5 ++--- lib/model/filters/query.dart | 5 ++--- lib/model/filters/tag.dart | 12 +++++++++--- 8 files changed, 35 insertions(+), 17 deletions(-) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 8e2831d0d..52c6987e0 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -376,7 +376,12 @@ class AvesEntry { return 'geo:$latitude,$longitude?q=$latitude,$longitude'; } - List get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; + List _xmpSubjects; + + List get xmpSubjects { + _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; + return _xmpSubjects; + } String _bestTitle; @@ -400,6 +405,7 @@ class AvesEntry { catalogDateMillis = newMetadata?.dateMillis; _catalogMetadata = newMetadata; _bestTitle = null; + _xmpSubjects = null; metadataChangeNotifier.notifyListeners(); _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 1ab6ce062..21c912c89 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -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 filter => (entry) => entry.directory == album; @override String get label => uniqueName ?? album.split(separator).last; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index c64326959..5d17006c9 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -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 filter => (entry) => entry.isFavourite; @override String get label => 'Favourite'; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index a3e985302..1c9ce5aad 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable { String toJson() => jsonEncode(toMap()); - bool filter(AvesEntry entry); + EntryFilter get filter; bool get isUnique => true; @@ -91,3 +91,5 @@ class FilterGridItem { @override int get hashCode => hashValues(filter, entry); } + +typedef EntryFilter = bool Function(AvesEntry); diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index c702cf2c4..c739f62be 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -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 _filter; 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) { + _filter = (entry) => !entry.isLocated; + } else if (level == LocationLevel.country) { + _filter = (entry) => entry.addressDetails?.countryCode == _countryCode; + } else if (level == LocationLevel.place) { + _filter = (entry) => entry.addressDetails?.place == _location; + } } LocationFilter.fromMap(Map json) @@ -35,7 +43,7 @@ class LocationFilter extends CollectionFilter { String get countryNameAndCode => '$_location$locationSeparator$_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 filter => _filter; @override String get label => _location.isEmpty ? emptyLabel : _location; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 5944c27df..be6b5f4be 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -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,7 +14,7 @@ class MimeFilter extends CollectionFilter { static const geotiff = 'aves/geotiff'; // subset of `image/tiff` final String mime; - bool Function(AvesEntry) _filter; + EntryFilter _filter; String _label; IconData _icon; @@ -67,7 +66,7 @@ class MimeFilter extends CollectionFilter { }; @override - bool filter(AvesEntry entry) => _filter(entry); + EntryFilter get filter => _filter; @override String get label => _label; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 33fe221e4..ad740e41f 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -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,7 +11,7 @@ class QueryFilter extends CollectionFilter { final String query; final bool colorful; - bool Function(AvesEntry) _filter; + EntryFilter _filter; QueryFilter(this.query, {this.colorful = true}) { var upQuery = query.toUpperCase(); @@ -44,7 +43,7 @@ class QueryFilter extends CollectionFilter { }; @override - bool filter(AvesEntry entry) => _filter(entry); + EntryFilter get filter => _filter; @override bool get isUnique => false; diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 5d21f0b7f..648768f9c 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -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 _filter; - const TagFilter(this.tag); + TagFilter(this.tag) { + if (tag.isEmpty) { + _filter = (entry) => entry.xmpSubjects.isEmpty; + } else { + _filter = (entry) => entry.xmpSubjects.contains(tag); + } + } TagFilter.fromMap(Map 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 filter => _filter; @override bool get isUnique => false; From 9c16300d493ae095ae7bbc1274f52a61263d399c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 11:00:39 +0900 Subject: [PATCH 09/32] crashfix: artifact contained x86 native libs, preventing arm emulation, looking for non-existent x86 libflutter.so --- android/app/build.gradle | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/app/build.gradle b/android/app/build.gradle index e8cea44fa..8121a9d6a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 From 104373a18614107168c65ea31a19799c2343b1a0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 11:57:26 +0900 Subject: [PATCH 10/32] shortcut: fixed opening app with location filter --- lib/model/source/collection_lens.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index b65e64934..68275752a 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -4,7 +4,9 @@ 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/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'; @@ -40,6 +42,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _subscriptions.add(source.eventBus.on().listen((e) { + if (filters.any((filter) => filter is LocationFilter)) { + _refresh(); + } + })); } _refresh(); } From accfb2c57bbf4759addae16d956225dbe04ecb0c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 13:00:43 +0900 Subject: [PATCH 11/32] collection: prevent hero when navigating from drawer --- lib/model/source/collection_lens.dart | 3 +++ lib/widgets/collection/grid/thumbnail.dart | 14 +++++++++----- lib/widgets/collection/thumbnail/decorated.dart | 8 ++++++-- lib/widgets/collection/thumbnail/raster.dart | 8 ++++---- lib/widgets/collection/thumbnail/vector.dart | 8 ++++---- lib/widgets/viewer/entry_viewer_stack.dart | 4 ++-- lib/widgets/viewer/hero.dart | 11 ++++++++--- lib/widgets/viewer/visual/entry_page_view.dart | 2 +- 8 files changed, 37 insertions(+), 21 deletions(-) diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 68275752a..6d4f5bc74 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -21,6 +21,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel EntryGroupFactor groupFactor; EntrySortFactor sortFactor; final AChangeNotifier filterChangeNotifier = AChangeNotifier(); + int id; bool listenToSource; List _filteredEntries; @@ -33,10 +34,12 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel Iterable filters, @required EntryGroupFactor groupFactor, @required EntrySortFactor sortFactor, + this.id, this.listenToSource = true, }) : filters = {if (filters != null) ...filters.where((f) => f != null)}, groupFactor = groupFactor ?? EntryGroupFactor.month, sortFactor = sortFactor ?? EntrySortFactor.date { + id ??= hashCode; if (listenToSource) { _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 3eabb767c..d6ddf27bd 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -54,16 +54,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, + ); + }, ), ); } diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 2920c100c..938e30a34 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -27,17 +27,21 @@ class DecoratedThumbnail extends StatelessWidget { @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, extent: extent, - canHero: true, + heroTag: heroTag, ) : RasterImageThumbnail( entry: entry, extent: extent, isScrollingNotifier: isScrollingNotifier, - canHero: true, + heroTag: heroTag, ); child = Stack( diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index a67caeb4d..006e83480 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -10,14 +10,14 @@ class RasterImageThumbnail extends StatefulWidget { final AvesEntry entry; final double extent; final ValueNotifier isScrollingNotifier; - final bool canHero; + final Object heroTag; const RasterImageThumbnail({ Key key, @required this.entry, @required this.extent, this.isScrollingNotifier, - this.canHero = false, + this.heroTag, }) : super(key: key); @override @@ -124,9 +124,9 @@ class _RasterImageThumbnailState extends State { height: extent, fit: BoxFit.cover, ); - return widget.canHero + return widget.heroTag != null ? Hero( - tag: entry, + tag: widget.heroTag, flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { return TransitionImage( image: entry.getBestThumbnail(extent), diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index c2cb919ed..d9d379ff5 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -10,13 +10,13 @@ import 'package:provider/provider.dart'; class VectorImageThumbnail extends StatelessWidget { final AvesEntry entry; final double extent; - final bool canHero; + final Object heroTag; const VectorImageThumbnail({ Key key, @required this.entry, @required this.extent, - this.canHero = false, + this.heroTag, }) : super(key: key); @override @@ -63,9 +63,9 @@ class VectorImageThumbnail extends StatelessWidget { ); }, ); - return canHero + return heroTag != null ? Hero( - tag: entry, + tag: heroTag, transitionOnUserGestures: true, child: child, ) diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 405817f62..8b1d070b1 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -77,7 +77,7 @@ class _EntryViewerStackState extends State with SingleTickerPr super.initState(); final entry = widget.initialEntry; // opening hero, with viewer as target - _heroInfoNotifier.value = HeroInfo(entry); + _heroInfoNotifier.value = HeroInfo(collection?.id, entry); _entryNotifier.value = entry; _currentHorizontalPage = max(0, entries.indexOf(entry)); _currentVerticalPage = ValueNotifier(imagePage); @@ -421,7 +421,7 @@ class _EntryViewerStackState extends State with SingleTickerPr } // closing hero, with viewer as source - final heroInfo = HeroInfo(_entryNotifier.value); + 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 diff --git a/lib/widgets/viewer/hero.dart b/lib/widgets/viewer/hero.dart index c7db1594c..80fa947fa 100644 --- a/lib/widgets/viewer/hero.dart +++ b/lib/widgets/viewer/hero.dart @@ -1,16 +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.entry); + const HeroInfo(this.collectionId, this.entry); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is HeroInfo && other.entry == entry; + return other is HeroInfo && other.collectionId == collectionId && other.entry == entry; } @override - int get hashCode => entry.hashCode; + int get hashCode => hashValues(collectionId, entry); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index d822de4ce..1ba5e27d5 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -145,7 +145,7 @@ class _EntryPageViewState extends State { return Consumer( builder: (context, info, child) => Hero( - tag: info?.entry == mainEntry ? mainEntry : hashCode, + tag: info?.entry == mainEntry ? hashValues(info.collectionId, mainEntry) : hashCode, transitionOnUserGestures: true, child: child, ), From c05b646dddc0bbc23bb36dbf79f14610d8ae9a83 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 14:44:55 +0900 Subject: [PATCH 12/32] media store monitoring: fixed external removal --- .../thibault/aves/utils/PermissionManager.kt | 2 -- lib/model/source/collection_lens.dart | 2 +- lib/model/source/collection_source.dart | 3 ++- lib/model/source/media_store_source.dart | 23 +++++++++++-------- .../collection/entry_set_action_delegate.dart | 2 +- .../common/chip_action_delegate.dart | 2 +- lib/widgets/viewer/entry_action_delegate.dart | 2 +- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 3e07b7c70..7ecf9a3b5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -95,7 +95,6 @@ object PermissionManager { } } } - Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=$dirPaths -> inaccessibleDirs=$inaccessibleDirs") return inaccessibleDirs } @@ -124,7 +123,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 } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 6d4f5bc74..1c7d77933 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -46,7 +46,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) { - if (filters.any((filter) => filter is LocationFilter)) { + if (this.filters.any((filter) => filter is LocationFilter)) { _refresh(); } })); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 137ca9786..3a9509455 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -71,7 +71,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryAddedEvent()); } - void removeEntries(List entries) { + void removeEntries(Set entries) { + if (entries.isEmpty) return; entries.forEach((entry) => entry.removeFromFavourites()); _rawEntries.removeWhere(entries.contains); cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 44f24eae2..8c17c80c6 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -123,8 +123,9 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet(); - uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId)); - metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); + obsoleteContentIds.forEach(uriByContentId.remove); + final obsoleteEntries = rawEntries.where((e) => obsoleteContentIds.contains(e.contentId)).toSet(); + removeEntries(obsoleteEntries); // fetch new entries final newEntries = []; @@ -132,14 +133,16 @@ class MediaStoreSource extends CollectionSource { 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) { - final volume = androidFileUtils.getStorageVolume(sourceEntry.path); - if (volume != null) { - newEntries.add(sourceEntry); - } else { - debugPrint('$runtimeType refreshUris entry=$sourceEntry is not located on a known storage volume. Will retry soon...'); - tempUris.add(uri); + if (sourceEntry != null) { + final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) { + final volume = androidFileUtils.getStorageVolume(sourceEntry.path); + if (volume != null) { + newEntries.add(sourceEntry); + } else { + debugPrint('$runtimeType refreshUris entry=$sourceEntry is not located on a known storage volume. Will retry soon...'); + tempUris.add(uri); + } } } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 6c3276361..b51111f77 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -145,7 +145,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware 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(selection.where((e) => deletedUris.contains(e.uri)).toSet()); } collection.clearSelection(); collection.browse(); diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 3d220401b..7760fac44 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -92,7 +92,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per 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(selection.where((e) => deletedUris.contains(e.uri)).toSet()); } }, ); diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 566405e0c..d53ea166b 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -141,7 +141,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix showFeedback(context, 'Failed'); } else { if (hasCollection) { - collection.source.removeEntries([entry]); + collection.source.removeEntries({entry}); } EntryDeletedNotification(entry).dispatch(context); } From 45ba3155b00ae9e33048aed277136dc45ac09009 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 18:13:54 +0900 Subject: [PATCH 13/32] about: new version check --- lib/model/availability.dart | 33 ++++++++- lib/model/settings/settings.dart | 9 +++ lib/theme/durations.dart | 5 +- lib/utils/constants.dart | 12 ++++ lib/widgets/about/about_page.dart | 109 ++--------------------------- lib/widgets/about/app_ref.dart | 74 ++++++++++++++++++++ lib/widgets/about/credits.dart | 43 ++++++++++++ lib/widgets/about/new_version.dart | 92 ++++++++++++++++++++++++ lib/widgets/about/news_badge.dart | 12 ++++ lib/widgets/debug/settings.dart | 1 + lib/widgets/drawer/app_drawer.dart | 29 ++++++-- pubspec.lock | 21 ++++++ pubspec.yaml | 2 + 13 files changed, 330 insertions(+), 112 deletions(-) create mode 100644 lib/widgets/about/app_ref.dart create mode 100644 lib/widgets/about/credits.dart create mode 100644 lib/widgets/about/new_version.dart create mode 100644 lib/widgets/about/news_badge.dart diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 735c802e3..704211ca3 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -1,11 +1,17 @@ +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; + bool _isConnected, _hasPlayServices, _isNewVersionAvailable; AvesAvailability._private() { Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); @@ -38,4 +44,29 @@ class AvesAvailability { // local geolocation with `geocoder` requires Play Services Future get canGeolocate => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); + + Future 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; + } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index fcc10a444..1f06c24ea 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -64,6 +64,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 init() async { _prefs = await SharedPreferences.getInstance(); } @@ -214,6 +217,12 @@ class Settings extends ChangeNotifier { set searchHistory(List 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 diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 0c8827f16..2ac6a36c6 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -35,7 +35,7 @@ class Durations { static const viewerOverlayChangeAnimation = Duration(milliseconds: 150); static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200); - // info + // info animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300); @@ -49,4 +49,7 @@ class Durations { static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); static const contentChangeDebounceDelay = Duration(milliseconds: 500); + + // app life + static const lastVersionCheckInterval = Duration(days: 7); } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 87453882c..b2284ba3b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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', diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 8abc4a8db..fa43d749e 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,5 +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/about/new_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'; @@ -23,43 +26,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 +41,3 @@ class AboutPage extends StatelessWidget { ); } } - -class AppReference extends StatefulWidget { - @override - _AppReferenceState createState() => _AppReferenceState(); -} - -class _AppReferenceState extends State { - Future 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( - 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), - ); - } -} diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart new file mode 100644 index 000000000..22dfc740d --- /dev/null +++ b/lib/widgets/about/app_ref.dart @@ -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 { + Future _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( + 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), + ); + } +} diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart new file mode 100644 index 000000000..027e79f29 --- /dev/null +++ b/lib/widgets/about/credits.dart @@ -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), + ], + ), + ); + } +} diff --git a/lib/widgets/about/new_version.dart b/lib/widgets/about/new_version.dart new file mode 100644 index 000000000..4c77cdffb --- /dev/null +++ b/lib/widgets/about/new_version.dart @@ -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 { + Future _newVersionLoader; + + @override + void initState() { + super.initState(); + _newVersionLoader = availability.isNewVersionAvailable; + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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(), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/about/news_badge.dart b/lib/widgets/about/news_badge.dart new file mode 100644 index 000000000..69a571dde --- /dev/null +++ b/lib/widgets/about/news_badge.dart @@ -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, + ); + } +} diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index d695c4126..9a1759feb 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -39,6 +39,7 @@ class DebugSettingsSection extends StatelessWidget { 'infoMapZoom': '${settings.infoMapZoom}', 'pinnedFilters': toMultiline(settings.pinnedFilters), 'searchHistory': toMultiline(settings.searchHistory), + 'lastVersionCheckDate': '${settings.lastVersionCheckDate}', }), ), ], diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 112fc28ae..1604da167 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -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 { + Future _newVersionLoader; + CollectionSource get source => widget.source; + @override + void initState() { + super.initState(); + _newVersionLoader = availability.isNewVersionAvailable; + } + @override Widget build(BuildContext context) { final drawerItems = [ @@ -211,12 +221,19 @@ class _AppDrawerState extends State { pageBuilder: (_) => SettingsPage(), ); - Widget get aboutTile => NavTile( - icon: AIcons.info, - title: 'About', - topLevel: false, - routeName: AboutPage.routeName, - pageBuilder: (_) => AboutPage(), + Widget get aboutTile => FutureBuilder( + 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( diff --git a/pubspec.lock b/pubspec.lock index 432448f83..324e8b2b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -464,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: @@ -1043,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: diff --git a/pubspec.yaml b/pubspec.yaml index 888311dc2..6c7d6e862 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: flutter_staggered_animations: flutter_svg: geocoder: + github: google_api_availability: google_maps_flutter: intl: @@ -76,6 +77,7 @@ dependencies: streams_channel: tuple: url_launcher: + version: xml: dev_dependencies: From c90e0df6affd6b447eda2d2cc41df389f970f754 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 18:26:29 +0900 Subject: [PATCH 14/32] media store monitoring: safer initialization --- .../channel/streams/ContentChangeStreamHandler.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt index 8142c1bb9..fc9a0114a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ContentChangeStreamHandler.kt @@ -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) } From 4661cdecbe81f7215c311a54a61236088b48fc2d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Feb 2021 18:34:49 +0900 Subject: [PATCH 15/32] static analysis fix --- lib/widgets/about/about_page.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index fa43d749e..c65d2e6e0 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,12 +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/about/new_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 AboutPage extends StatelessWidget { static const routeName = '/about'; From 1b6febe0343601850b3d137757dfc8e9a6d4a0e7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 4 Feb 2021 12:10:17 +0900 Subject: [PATCH 16/32] catalog: use PNG last modification time as fallback --- .../aves/channel/calls/MetadataHandler.kt | 26 ++++++++++++++++++- .../aves/metadata/MetadataExtractorHelper.kt | 4 +++ lib/widgets/viewer/info/info_search.dart | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 505b50f4b..d0e315d08 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -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 @@ -70,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 @@ -217,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 @@ -224,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 @@ -348,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 @@ -361,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 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 18b23f6d8..fe05a46b3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -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) { diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 601f6b70c..f8c94fe1b 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -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', From ff517925f6a96aed12e8b815f4e47d6db61904e6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 4 Feb 2021 13:29:46 +0900 Subject: [PATCH 17/32] collection: scroll to top on sort/group change --- lib/model/settings/settings.dart | 2 -- lib/model/source/collection_lens.dart | 4 +++- lib/widgets/collection/thumbnail/overlay.dart | 2 +- lib/widgets/collection/thumbnail_collection.dart | 12 +++++++----- lib/widgets/common/grid/header.dart | 4 ++-- .../filter_grids/common/filter_grid_page.dart | 2 +- lib/widgets/viewer/entry_viewer_stack.dart | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 1f06c24ea..fcd9a5823 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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; diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 1c7d77933..93d49148a 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -20,7 +20,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel final Set filters; EntryGroupFactor groupFactor; EntrySortFactor sortFactor; - final AChangeNotifier filterChangeNotifier = AChangeNotifier(); + final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier(); int id; bool listenToSource; @@ -111,11 +111,13 @@ 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() { diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 4858f28b3..24be3b2a4 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -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(context); + final collection = context.watch(); return ValueListenableBuilder( valueListenable: collection.activityNotifier, builder: (context, activity, child) { diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index aa2c848c9..44397c9ae 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -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>(); return sectionedListLayout.getTileRect(entry) ?? Rect.zero; }, - onScaled: (entry) => Provider.of(context, listen: false).add(entry), + onScaled: (entry) => context.read().add(entry), child: scrollView, ); @@ -195,12 +195,14 @@ class _CollectionScrollViewState extends State { } 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 { ); } - void _onFilterChange() => widget.scrollController.jumpTo(0); + void _scrollToTop() => widget.scrollController.jumpTo(0); void _onScrollChange() { widget.isScrollingNotifier.value = true; diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 3acf4c2af..da4b4eca5 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -76,7 +76,7 @@ class SectionHeader extends StatelessWidget { } void _toggleSectionSelection(BuildContext context) { - final collection = Provider.of(context, listen: false); + final collection = context.read(); 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(context); + final collection = context.watch(); return ValueListenableBuilder( valueListenable: collection.activityNotifier, builder: (context, activity, child) { diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index fcdb2e61f..20b9c29b9 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -138,7 +138,7 @@ class FilterGridPage extends StatelessWidget { final sectionedListLayout = context.read>>(); return sectionedListLayout.getTileRect(item) ?? Rect.zero; }, - onScaled: (item) => Provider.of(context, listen: false).add(item.filter), + onScaled: (item) => context.read().add(item.filter), child: scrollView, ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 8b1d070b1..92a690a71 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -467,7 +467,7 @@ class _EntryViewerStackState extends State with SingleTickerPr _overlayAnimationController.value = _overlayAnimationController.upperBound; } } else { - final mediaQuery = Provider.of(context, listen: false); + final mediaQuery = context.read(); setState(() { _frozenViewInsets = mediaQuery.viewInsets; _frozenViewPadding = mediaQuery.viewPadding; From 8fa3f18aef08645cfd8376461c770d9918405f0d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 4 Feb 2021 17:45:46 +0900 Subject: [PATCH 18/32] multipage: thumbnail request cancellation --- lib/model/entry.dart | 9 +++++++++ lib/model/multipage.dart | 11 ++++++++--- lib/services/metadata_service.dart | 2 +- lib/widgets/collection/grid/thumbnail.dart | 5 ++++- lib/widgets/collection/thumbnail/decorated.dart | 6 +++--- lib/widgets/collection/thumbnail/raster.dart | 10 +++------- lib/widgets/viewer/overlay/multipage.dart | 8 +++++++- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 52c6987e0..92417998e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -172,6 +172,15 @@ class AvesEntry { addressChangeNotifier.dispose(); } + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is AvesEntry && other.uri == uri && other.pageId == pageId && other._dateModifiedSecs == _dateModifiedSecs; + } + + @override + int get hashCode => hashValues(uri, pageId, _dateModifiedSecs); + @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 9400c1beb..257b09e85 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -1,11 +1,13 @@ import 'package:flutter/foundation.dart'; class MultiPageInfo { + final String uri; final List 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 pageMaps) { - return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList()); + factory MultiPageInfo.fromPageMaps(String uri, List 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 { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index d55799255..b7a830623 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -89,7 +89,7 @@ class MetadataService { 'uri': entry.uri, }); final pageMaps = (result as List).cast(); - 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}'); } diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index d6ddf27bd..09dcd14ca 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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, ), ), ); diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 938e30a34..2a4fa81b9 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -9,7 +9,7 @@ class DecoratedThumbnail extends StatelessWidget { final AvesEntry entry; final double extent; final CollectionLens collection; - final ValueNotifier isScrollingNotifier; + final ValueNotifier cancellableNotifier; final bool selectable, highlightable; static final Color borderColor = Colors.grey.shade700; @@ -20,7 +20,7 @@ class DecoratedThumbnail extends StatelessWidget { @required this.entry, @required this.extent, this.collection, - this.isScrollingNotifier, + this.cancellableNotifier, this.selectable = true, this.highlightable = true, }) : super(key: key); @@ -40,7 +40,7 @@ class DecoratedThumbnail extends StatelessWidget { : RasterImageThumbnail( entry: entry, extent: extent, - isScrollingNotifier: isScrollingNotifier, + cancellableNotifier: cancellableNotifier, heroTag: heroTag, ); diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 006e83480..e7ac7c516 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -9,14 +9,14 @@ import 'package:flutter/material.dart'; class RasterImageThumbnail extends StatefulWidget { final AvesEntry entry; final double extent; - final ValueNotifier isScrollingNotifier; + final ValueNotifier cancellableNotifier; final Object heroTag; const RasterImageThumbnail({ Key key, @required this.entry, @required this.extent, - this.isScrollingNotifier, + this.cancellableNotifier, this.heroTag, }) : super(key: key); @@ -70,11 +70,7 @@ class _RasterImageThumbnailState extends State { } 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(); } diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index b1568b837..3f96e80bb 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -27,6 +27,7 @@ class MultiPageOverlay extends StatefulWidget { } class _MultiPageOverlayState extends State { + final _cancellableNotifier = ValueNotifier(true); ScrollController _scrollController; bool _syncScroll = true; @@ -90,7 +91,8 @@ class _MultiPageOverlayState extends State { future: controller.info, builder: (context, snapshot) { final multiPageInfo = snapshot.data; - if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox.shrink(); + if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); + if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); return Container( height: extent + separatorWidth * 2, child: Stack( @@ -125,6 +127,10 @@ class _MultiPageOverlayState extends State { 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, ), From b1fc6c24603b3cdc15df82d3c6f40cf9a6ab5bf0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 4 Feb 2021 18:26:14 +0900 Subject: [PATCH 19/32] viewer: changed multipage overlay shading --- lib/theme/durations.dart | 5 +- lib/widgets/viewer/entry_viewer_stack.dart | 2 +- lib/widgets/viewer/info/info_page.dart | 2 +- lib/widgets/viewer/overlay/multipage.dart | 130 ++++++++------------- 4 files changed, 55 insertions(+), 84 deletions(-) diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 2ac6a36c6..880eb2e33 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -30,10 +30,11 @@ 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 animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 92a690a71..d37847071 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -358,7 +358,7 @@ class _EntryViewerStackState extends State with SingleTickerPr Future _goToVerticalPage(int page) { return _verticalPager.animateToPage( page, - duration: Durations.viewerPageAnimation, + duration: Durations.viewerVerticalPageScrollAnimation, curve: Curves.easeInOut, ); } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 112bb6310..2724f3d14 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -107,7 +107,7 @@ class _InfoPageState extends State { BackUpNotification().dispatch(context); _scrollController.animateTo( 0, - duration: Durations.viewerPageAnimation, + duration: Durations.viewerVerticalPageScrollAnimation, curve: Curves.easeInOut, ); } diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index 3f96e80bb..cc5c5ece5 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -79,13 +79,6 @@ class _MultiPageOverlayState extends State { 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( future: controller.info, @@ -93,80 +86,57 @@ class _MultiPageOverlayState extends State { final multiPageInfo = snapshot.data; if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); - 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)); + 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, - // 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, - ), - ); - }, - 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, ), ); }, From 561f042b7635239863d60394444e1937a5262351 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 5 Feb 2021 12:19:37 +0900 Subject: [PATCH 20/32] media store monitoring: fixed keeping favourites on move --- lib/model/favourite_repo.dart | 18 ++++++++------ lib/model/source/collection_source.dart | 24 ++++++++++++------- .../collection/entry_set_action_delegate.dart | 22 +++++++++++------ .../common/chip_action_delegate.dart | 22 ++++++++++------- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/lib/model/favourite_repo.dart b/lib/model/favourite_repo.dart index 78daf8883..e80eb6fd6 100644 --- a/lib/model/favourite_repo.dart +++ b/lib/model/favourite_repo.dart @@ -24,33 +24,37 @@ class FavouriteRepo { Future add(Iterable entries) async { final newRows = entries.map(_entryToRow); + await metadataDb.addFavourites(newRows); _rows.addAll(newRows); + changeNotifier.notifyListeners(); } Future remove(Iterable entries) async { final removedRows = entries.map(_entryToRow); + await metadataDb.removeFavourites(removedRows); removedRows.forEach(_rows.remove); + changeNotifier.notifyListeners(); } Future 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 clear() async { await metadataDb.clearFavourites(); _rows.clear(); + changeNotifier.notifyListeners(); } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 3a9509455..ac57ff7e6 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -91,12 +91,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM invalidateFilterEntryCounts(); } - // `dateModifiedSecs` changes when moving entries to another directory, - // but it does not change when renaming the containing directory - Future moveEntry(AvesEntry entry, Map newFields) async { + Future _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; @@ -107,14 +107,17 @@ 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 selection, + @required Set todoEntries, + @required Set favouriteEntries, @required bool copy, @required String destinationAlbum, - @required Iterable movedOps, + @required Set movedOps, }) async { if (movedOps.isEmpty) return; @@ -124,7 +127,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM 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, @@ -141,11 +144,14 @@ 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); } } }); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index b51111f77..fc4441c79 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -78,24 +78,32 @@ 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(); showOpReport( 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, diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 7760fac44..d8eb689df 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -108,28 +108,32 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final selection = source.rawEntries.where(filter.filter).toSet(); + final todoEntries = source.rawEntries.where(filter.filter).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(); showOpReport( 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, From 319fd9584b9ff9c015cc33fbd67d28909986061e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 5 Feb 2021 12:24:07 +0900 Subject: [PATCH 21/32] minor change --- lib/model/source/collection_lens.dart | 9 +++++---- lib/widgets/drawer/collection_tile.dart | 3 --- lib/widgets/filter_grids/common/filter_nav_page.dart | 3 --- lib/widgets/home_page.dart | 2 -- lib/widgets/search/search_delegate.dart | 2 -- lib/widgets/stats/stats.dart | 5 +---- 6 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 93d49148a..ec7d3702a 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -5,6 +5,7 @@ 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'; @@ -32,13 +33,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel CollectionLens({ @required this.source, Iterable 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().listen((e) => _refresh())); diff --git a/lib/widgets/drawer/collection_tile.dart b/lib/widgets/drawer/collection_tile.dart index e255a8059..61c2ad3bd 100644 --- a/lib/widgets/drawer/collection_tile.dart +++ b/lib/widgets/drawer/collection_tile.dart @@ -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, diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 64f3a5656..326ec83af 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -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 extends StatelessWidget { builder: (context) => CollectionPage(CollectionLens( source: source, filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, )), ), ), diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 1a0374e18..d4d5718a0 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -161,8 +161,6 @@ class _HomePageState extends State { CollectionLens( source: source, filters: filters, - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, ), ), ); diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index f1d008e86..446672b87 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -219,8 +219,6 @@ class CollectionSearchDelegate { builder: (context) => CollectionPage(CollectionLens( source: source, filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, )), ), (route) => false, diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 0292f1a0a..a11c82b21 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -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'; @@ -273,8 +272,6 @@ class StatsPage extends StatelessWidget { builder: (context) => CollectionPage(CollectionLens( source: source, filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, )), ), (route) => false, From c5ee55adb003bb2e694e794490e959a667a10cfd Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 6 Feb 2021 10:25:16 +0900 Subject: [PATCH 22/32] various collection model fixes --- .../aves/channel/calls/AppAdapterHandler.kt | 2 +- lib/model/filters/location.dart | 2 + lib/model/filters/query.dart | 3 +- lib/model/metadata_db.dart | 4 +- lib/model/source/album.dart | 54 +++++++++---- lib/model/source/collection_lens.dart | 10 ++- lib/model/source/collection_source.dart | 77 +++++++++++-------- lib/model/source/location.dart | 26 ++++++- lib/model/source/media_store_source.dart | 11 +-- lib/model/source/section_keys.dart | 10 +-- lib/model/source/tag.dart | 28 ++++++- lib/widgets/collection/collection_page.dart | 15 +++- .../collection/grid/headers/album.dart | 20 ++--- lib/widgets/collection/grid/headers/any.dart | 2 +- .../common/identity/aves_filter_chip.dart | 3 +- lib/widgets/debug/database.dart | 4 +- lib/widgets/drawer/app_drawer.dart | 7 +- lib/widgets/filter_grids/albums_page.dart | 3 +- .../filter_grids/common/filter_nav_page.dart | 13 ++-- lib/widgets/filter_grids/countries_page.dart | 3 +- lib/widgets/filter_grids/tags_page.dart | 3 +- lib/widgets/search/search_delegate.dart | 9 ++- 22 files changed, 211 insertions(+), 98 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index c4b8e3377..a813d1943 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -67,7 +67,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val packages = HashMap() 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) } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index c739f62be..79e515ce1 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -42,6 +42,8 @@ class LocationFilter extends CollectionFilter { String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; + String get countryCode => _countryCode; + @override EntryFilter get filter => _filter; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index ad740e41f..5d84812e0 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -1,5 +1,6 @@ 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'; @@ -55,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(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white); + Future color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor); @override String get typeKey => type; diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 3630f5a27..389c640e0 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -116,11 +116,11 @@ class MetadataDb { debugPrint('$runtimeType clearEntries deleted $count entries'); } - Future> loadEntries() async { + Future> 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; } diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 44b2789c1..004517b7b 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -7,9 +7,9 @@ import 'package:collection/collection.dart'; import 'package:path/path.dart'; mixin AlbumMixin on SourceBase { - final Set _folderPaths = {}; + final Set _directories = {}; - List sortedAlbums = List.unmodifiable([]); + List 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; @@ -51,9 +46,9 @@ mixin AlbumMixin on SourceBase { } Map getAlbumEntries() { - final entries = sortedEntriesForFilterList; + final entries = sortedEntriesByDate; final regularAlbums = [], appAlbums = [], specialAlbums = []; - for (final album in sortedAlbums) { + for (final album in rawAlbums) { switch (androidFileUtils.getAlbumType(album)) { case AlbumType.regular: regularAlbums.add(album); @@ -72,13 +67,17 @@ mixin AlbumMixin on SourceBase { ))); } - void addFolderPath(Iterable albums) => _folderPaths.addAll(albums); + void addDirectory(Iterable albums) { + _directories.addAll(albums); + _notifyAlbumChange(); + } void cleanEmptyAlbums([Set 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)))); @@ -87,6 +86,31 @@ mixin AlbumMixin on SourceBase { } bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album); + + // filter summary + + // by directory + final Map _filterEntryCountMap = {}; + final Map _filterRecentEntryMap = {}; + + void invalidateAlbumFilterSummary({Set entries, Set 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, () => rawEntries.where((entry) => filter.filter(entry)).length); + } + + AvesEntry albumRecentEntry(AlbumFilter filter) { + return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + } } class AlbumsChangedEvent {} diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index ec7d3702a..455619fa0 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -42,7 +42,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel sortFactor = sortFactor ?? settings.collectionSortFactor { id ??= hashCode; if (listenToSource) { - _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _subscriptions.add(source.eventBus.on().listen((e) => onEntryAdded(e.entries))); _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); @@ -167,7 +167,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel break; case EntrySortFactor.name: final byAlbum = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); - sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory)); break; } sections = Map.unmodifiable(sections); @@ -183,7 +183,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel _applyGroup(); } - void onEntryRemoved(Iterable entries) { + void onEntryAdded(Set entries) { + _refresh(); + } + + void onEntryRemoved(Set entries) { // we should remove obsolete entries and sections // but do not apply sort/group // as section order change would surprise the user while browsing diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index ac57ff7e6..6743e0c6e 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -2,19 +2,19 @@ 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/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 _rawEntries = []; @@ -24,11 +24,7 @@ mixin SourceBase { EventBus get eventBus => _eventBus; - List get sortedEntriesForFilterList; - - final Map _filterEntryCountMap = {}; - - void invalidateFilterEntryCounts() => _filterEntryCountMap.clear(); + List get sortedEntriesByDate; final StreamController _progressStreamController = StreamController.broadcast(); @@ -38,12 +34,13 @@ mixin SourceBase { } abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { + List _sortedEntriesByDate; + @override - List get sortedEntriesForFilterList => CollectionLens( - source: this, - groupFactor: EntryGroupFactor.none, - sortFactor: EntrySortFactor.date, - ).sortedEntries; + List get sortedEntriesByDate { + _sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate); + return _sortedEntriesByDate; + } ValueNotifier stateNotifier = ValueNotifier(SourceState.ready); @@ -55,7 +52,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries'); } - void addAll(Iterable entries) { + void addAll(Set entries) { if (entries.isEmpty) return; if (_rawEntries.isNotEmpty) { final newContentIds = entries.map((entry) => entry.contentId).toList(); @@ -66,9 +63,9 @@ 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()); + addDirectory(_rawEntries.map((entry) => entry.directory)); + _invalidateFilterSummaries(entries); + eventBus.fire(EntryAddedEvent(entries)); } void removeEntries(Set entries) { @@ -78,17 +75,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); updateLocations(); updateTags(); - invalidateFilterEntryCounts(); + _invalidateFilterSummaries(entries); eventBus.fire(EntryRemovedEvent(entries)); } void clearEntries() { _rawEntries.clear(); cleanEmptyAlbums(); - updateAlbums(); updateLocations(); updateTags(); - invalidateFilterEntryCounts(); + _invalidateFilterSummaries(); } Future _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { @@ -122,7 +118,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (movedOps.isEmpty) return; final fromAlbums = {}; - final movedEntries = []; + final movedEntries = {}; if (copy) { movedOps.forEach((movedOp) { final sourceUri = movedOp.uri; @@ -161,17 +157,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM addAll(movedEntries); } else { cleanEmptyAlbums(fromAlbums); - addFolderPath({destinationAlbum}); + addDirectory({destinationAlbum}); } - updateAlbums(); - invalidateFilterEntryCounts(); + invalidateAlbumFilterSummary(directories: fromAlbums); + _invalidateFilterSummaries(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 init(); @@ -179,18 +171,41 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future refresh(); Future refreshMetadata(Set entries); + + // filter summary + + void _invalidateFilterSummaries([Set entries]) { + _sortedEntriesByDate = null; + invalidateAlbumFilterSummary(entries: entries); + invalidateCountryFilterSummary(entries); + invalidateTagFilterSummary(entries); + } + + 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; + } } enum SourceState { loading, cataloguing, locating, ready } class EntryAddedEvent { - final AvesEntry entry; + final Set entries; - const EntryAddedEvent([this.entry]); + const EntryAddedEvent([this.entries]); } class EntryRemovedEvent { - final Iterable entries; + final Set entries; const EntryRemovedEvent(this.entries); } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index ef5d36f51..8c81cfca1 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -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.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 _filterEntryCountMap = {}; + final Map _filterRecentEntryMap = {}; + + void invalidateCountryFilterSummary([Set 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, () => rawEntries.where((entry) => filter.filter(entry)).length); + } + + AvesEntry countryRecentEntry(LocationFilter filter) { + return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + } } class AddressMetadataChangedEvent {} diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 8c17c80c6..ff8dfe584 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -66,7 +66,7 @@ class MediaStoreSource extends CollectionSource { // refresh after the first 10 entries, then after 100 more, then every 1000 entries var refreshCount = 10; const refreshCountMax = 1000; - final allNewEntries = [], pendingNewEntries = []; + final allNewEntries = {}, pendingNewEntries = {}; void addPendingEntries() { allNewEntries.addAll(pendingNewEntries); addAll(pendingNewEntries); @@ -86,10 +86,11 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}'); await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries - updateAlbums(); + invalidateAlbumFilterSummary(entries: allNewEntries); + 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: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString())); stateNotifier.value = SourceState.cataloguing; await catalogEntries(); @@ -128,7 +129,7 @@ class MediaStoreSource extends CollectionSource { removeEntries(obsoleteEntries); // fetch new entries - final newEntries = []; + final newEntries = {}; for (final kv in uriByContentId.entries) { final contentId = kv.key; final uri = kv.value; @@ -150,7 +151,7 @@ class MediaStoreSource extends CollectionSource { if (newEntries.isNotEmpty) { addAll(newEntries); await metadataDb.saveEntries(newEntries); - updateAlbums(); + invalidateAlbumFilterSummary(entries: newEntries); stateNotifier.value = SourceState.cataloguing; await catalogEntries(); diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart index 072ddcbb3..66988f27d 100644 --- a/lib/model/source/section_keys.dart +++ b/lib/model/source/section_keys.dart @@ -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 { diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index d5b1ca3c0..9ce9d8cf0 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -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'; @@ -56,9 +57,34 @@ mixin TagMixin on SourceBase { void updateTags() { final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); sortedTags = List.unmodifiable(tags); - invalidateFilterEntryCounts(); + + invalidateTagFilterSummary(); eventBus.fire(TagsChangedEvent()); } + + // filter summary + + // by tag + final Map _filterEntryCountMap = {}; + final Map _filterRecentEntryMap = {}; + + void invalidateTagFilterSummary([Set 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, () => rawEntries.where((entry) => filter.filter(entry)).length); + } + + AvesEntry tagRecentEntry(TagFilter filter) { + return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + } } class CatalogMetadataChangedEvent {} diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 34fc10f68..75e6ee911 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -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 { + CollectionLens get collection => widget.collection; + + @override + void dispose() { + collection.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MediaQueryDataProvider( diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index f6db50107..cf67b3b6d 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -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), ); } } diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 9a21b47ce..7ed5bf63c 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -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) { diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index d096bdcda..db71d1320 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -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 { // 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 diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 1e261e43f..ea881a82e 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -13,7 +13,7 @@ class DebugAppDatabaseSection extends StatefulWidget { class _DebugAppDatabaseSectionState extends State with AutomaticKeepAliveClientMixin { Future _dbFileSizeLoader; - Future> _dbEntryLoader; + Future> _dbEntryLoader; Future> _dbDateLoader; Future> _dbMetadataLoader; Future> _dbAddressLoader; @@ -57,7 +57,7 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), - FutureBuilder( + FutureBuilder>( future: _dbEntryLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 1604da167..3871da673 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -142,10 +142,11 @@ class _AppDrawerState extends State { return StreamBuilder( stream: source.eventBus.on(), 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( @@ -185,7 +186,7 @@ class _AppDrawerState extends State { title: 'Albums', trailing: StreamBuilder( stream: source.eventBus.on(), - builder: (context, _) => Text('${source.sortedAlbums.length}'), + builder: (context, _) => Text('${source.rawAlbums.length}'), ), routeName: AlbumListPage.routeName, pageBuilder: (_) => AlbumListPage(source: source), diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index a50328f3f..90b8750c4 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -60,8 +60,7 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries static Map>> 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); diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 326ec83af..d67f41a8b 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -160,19 +160,22 @@ class FilterNavigationPage extends StatelessWidget { return c != 0 ? c : a.key.compareTo(b.key); } - static Iterable> sort(ChipSortFactor sortFactor, CollectionSource source, Iterable filters) { + static int compareFiltersByName(FilterGridItem a, FilterGridItem b) { + return a.filter.compareTo(b.filter); + } + + static Iterable> sort(ChipSortFactor sortFactor, CollectionSource source, Set filters) { Iterable> toGridItem(CollectionSource source, Iterable filters) { - final entriesByDate = source.sortedEntriesForFilterList; return filters.map((filter) => FilterGridItem( filter, - entriesByDate.firstWhere(filter.filter, orElse: () => null), + source.recentEntry(filter), )); } Iterable> 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); @@ -180,7 +183,7 @@ class FilterNavigationPage 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; } diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 64da80407..f348480c4 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -50,8 +50,7 @@ class CountryListPage extends StatelessWidget { } Map>> _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); diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 122a2c4b9..5743cec5d 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -50,8 +50,7 @@ class TagListPage extends StatelessWidget { } Map>> _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); diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 446672b87..b3627ec5f 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -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(), 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(), 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 filters, + @required List filters, HeroType Function(CollectionFilter filter) heroTypeBuilder, }) { return ExpandableFilterRow( From ea3d79afbeb49f90fd505219c53b80b7c65339cd Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 13:38:53 +0900 Subject: [PATCH 23/32] #13 hidden filters --- lib/model/actions/chip_actions.dart | 5 + lib/model/entry.dart | 10 +- lib/model/settings/settings.dart | 5 + lib/model/source/album.dart | 35 +++++-- lib/model/source/collection_lens.dart | 30 +++--- lib/model/source/collection_source.dart | 98 ++++++++++++++----- lib/model/source/location.dart | 10 +- lib/model/source/media_store_source.dart | 14 +-- lib/model/source/tag.dart | 10 +- lib/theme/icons.dart | 1 + lib/utils/android_file_utils.dart | 7 +- lib/widgets/collection/app_bar.dart | 4 + .../collection/entry_set_action_delegate.dart | 6 +- lib/widgets/debug/app_debug_page.dart | 9 +- lib/widgets/filter_grids/albums_page.dart | 1 + .../common/chip_action_delegate.dart | 25 +++-- .../filter_grids/common/filter_nav_page.dart | 2 +- .../filter_grids/common/section_keys.dart | 4 +- lib/widgets/filter_grids/countries_page.dart | 3 +- lib/widgets/filter_grids/tags_page.dart | 3 +- lib/widgets/search/expandable_filter_row.dart | 12 +-- lib/widgets/settings/hidden_filters.dart | 67 +++++++++++++ lib/widgets/settings/settings_page.dart | 2 + lib/widgets/stats/stats.dart | 2 +- lib/widgets/viewer/entry_action_delegate.dart | 2 +- 25 files changed, 260 insertions(+), 107 deletions(-) create mode 100644 lib/widgets/settings/hidden_filters.dart diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index b2bfb1689..7a25b78ca 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -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; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 92417998e..de75b6219 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -172,14 +172,8 @@ class AvesEntry { addressChangeNotifier.dispose(); } - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is AvesEntry && other.uri == uri && other.pageId == pageId && other._dateModifiedSecs == _dateModifiedSecs; - } - - @override - int get hashCode => hashValues(uri, pageId, _dateModifiedSecs); + // 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}'; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index fcd9a5823..a2f439dae 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -43,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'; @@ -167,6 +168,10 @@ class Settings extends ChangeNotifier { set pinnedFilters(Set newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList()); + Set get hiddenFilters => (_prefs.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet(); + + set hiddenFilters(Set newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList()); + // viewer bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 004517b7b..1c358d6f3 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -34,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; @@ -67,9 +76,17 @@ mixin AlbumMixin on SourceBase { ))); } - void addDirectory(Iterable albums) { - _directories.addAll(albums); - _notifyAlbumChange(); + void updateDirectories() { + final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet(); + addDirectories(visibleDirectories); + cleanEmptyAlbums(); + } + + void addDirectories(Set albums) { + if (!_directories.containsAll(albums)) { + _directories.addAll(albums); + _notifyAlbumChange(); + } } void cleanEmptyAlbums([Set albums]) { @@ -85,7 +102,7 @@ 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 @@ -105,11 +122,11 @@ mixin AlbumMixin on SourceBase { } int albumEntryCount(AlbumFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.album, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry albumRecentEntry(AlbumFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 455619fa0..d20d9554e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -25,7 +25,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel int id; bool listenToSource; - List _filteredEntries; + List _filteredSortedEntries; List _subscriptions = []; Map> sections = Map.unmodifiable({}); @@ -64,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 _sortedEntries; @@ -122,20 +122,20 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel } 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.fold(true, (prev, filter) => prev && filter.filter(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; } } @@ -145,28 +145,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel case EntrySortFactor.date: switch (groupFactor) { case EntryGroupFactor.album: - sections = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); break; case EntryGroupFactor.month: - sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); + sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); break; case EntryGroupFactor.day: - sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); + sections = groupBy(_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(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory)); break; } @@ -191,7 +191,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel // 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))); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 6743e0c6e..5f8280dc4 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -8,6 +8,7 @@ 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/location.dart'; import 'package:aves/model/source/tag.dart'; @@ -16,13 +17,9 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; mixin SourceBase { - final List _rawEntries = []; + EventBus get eventBus; - List get rawEntries => List.unmodifiable(_rawEntries); - - final EventBus _eventBus = EventBus(); - - EventBus get eventBus => _eventBus; + Set get visibleEntries; List get sortedEntriesByDate; @@ -34,11 +31,30 @@ mixin SourceBase { } abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { + final EventBus _eventBus = EventBus(); + + @override + EventBus get eventBus => _eventBus; + + final Set _rawEntries = {}; + + // TODO TLAD use `Set.unmodifiable()` when possible + Set get allEntries => Set.of(_rawEntries); + + Set _visibleEntries; + + @override + Set get visibleEntries { + // TODO TLAD use `Set.unmodifiable()` when possible + _visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries)); + return _visibleEntries; + } + List _sortedEntriesByDate; @override List get sortedEntriesByDate { - _sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate); + _sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate)); return _sortedEntriesByDate; } @@ -52,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(Set entries) { + Iterable _applyHiddenFilters(Iterable entries) { + final hiddenFilters = settings.hiddenFilters; + return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.filter(entry))); + } + + void _invalidate([Set entries]) { + _visibleEntries = null; + _sortedEntriesByDate = null; + invalidateAlbumFilterSummary(entries: entries); + invalidateCountryFilterSummary(entries); + invalidateTagFilterSummary(entries); + } + + void addEntries(Set 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) { @@ -63,28 +92,32 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis; }); _rawEntries.addAll(entries); - addDirectory(_rawEntries.map((entry) => entry.directory)); - _invalidateFilterSummaries(entries); + _invalidate(entries); + + addDirectories(_applyHiddenFilters(entries).map((entry) => entry.directory).toSet()); eventBus.fire(EntryAddedEvent(entries)); } - void removeEntries(Set entries) { - if (entries.isEmpty) return; + void removeEntries(Set 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(); - _invalidateFilterSummaries(entries); eventBus.fire(EntryRemovedEvent(entries)); } void clearEntries() { _rawEntries.clear(); - cleanEmptyAlbums(); + _invalidate(); + + updateDirectories(); updateLocations(); updateTags(); - _invalidateFilterSummaries(); } Future _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { @@ -154,13 +187,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } if (copy) { - addAll(movedEntries); + addEntries(movedEntries); } else { cleanEmptyAlbums(fromAlbums); - addDirectory({destinationAlbum}); + addDirectories({destinationAlbum}); } invalidateAlbumFilterSummary(directories: fromAlbums); - _invalidateFilterSummaries(movedEntries); + _invalidate(movedEntries); eventBus.fire(EntryMovedEvent(movedEntries)); } @@ -174,13 +207,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM // filter summary - void _invalidateFilterSummaries([Set entries]) { - _sortedEntriesByDate = null; - invalidateAlbumFilterSummary(entries: entries); - invalidateCountryFilterSummary(entries); - invalidateTagFilterSummary(entries); - } - int count(CollectionFilter filter) { if (filter is AlbumFilter) return albumEntryCount(filter); if (filter is LocationFilter) return countryEntryCount(filter); @@ -194,6 +220,24 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM 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(); + } } enum SourceState { loading, cataloguing, locating, ready } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 8c81cfca1..616e55463 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -19,7 +19,7 @@ mixin LocationMixin on SourceBase { Future 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); }); @@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase { if (!(await availability.canGeolocate)) return; // final stopwatch = Stopwatch()..start(); - final byLocated = groupBy(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); + final byLocated = groupBy(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.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 @@ -121,11 +121,11 @@ mixin LocationMixin on SourceBase { } int countryEntryCount(LocationFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry countryRecentEntry(LocationFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index ff8dfe584..e3b69463e 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -54,7 +54,7 @@ class MediaStoreSource extends CollectionSource { 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}'); @@ -69,7 +69,7 @@ class MediaStoreSource extends CollectionSource { final allNewEntries = {}, pendingNewEntries = {}; void addPendingEntries() { allNewEntries.addAll(pendingNewEntries); - addAll(pendingNewEntries); + addEntries(pendingNewEntries); pendingNewEntries.clear(); } @@ -89,7 +89,7 @@ class MediaStoreSource extends CollectionSource { invalidateAlbumFilterSummary(entries: allNewEntries); final analytics = FirebaseAnalytics(); - unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(rawEntries.length, 3)).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; @@ -124,9 +124,9 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet(); + final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet(); + removeEntries(obsoleteUris); obsoleteContentIds.forEach(uriByContentId.remove); - final obsoleteEntries = rawEntries.where((e) => obsoleteContentIds.contains(e.contentId)).toSet(); - removeEntries(obsoleteEntries); // fetch new entries final newEntries = {}; @@ -135,7 +135,7 @@ class MediaStoreSource extends CollectionSource { final uri = kv.value; final sourceEntry = await ImageFileService.getEntry(uri, null); if (sourceEntry != null) { - final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) { final volume = androidFileUtils.getStorageVolume(sourceEntry.path); if (volume != null) { @@ -149,7 +149,7 @@ class MediaStoreSource extends CollectionSource { } if (newEntries.isNotEmpty) { - addAll(newEntries); + addEntries(newEntries); await metadataDb.saveEntries(newEntries); invalidateAlbumFilterSummary(entries: newEntries); diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 9ce9d8cf0..781c5f2ed 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -14,7 +14,7 @@ mixin TagMixin on SourceBase { Future 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); }); @@ -24,7 +24,7 @@ mixin TagMixin on SourceBase { Future 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; @@ -55,7 +55,7 @@ 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); invalidateTagFilterSummary(); @@ -79,11 +79,11 @@ mixin TagMixin on SourceBase { } int tagEntryCount(TagFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.tag, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry tagRecentEntry(TagFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 2ff083f0e..2e7745856 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 949e3424a..12a71dd47 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -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; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 091f38a24..8db5ed80a 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -194,6 +194,7 @@ class _CollectionAppBarState extends State with SingleTickerPr return PopupMenuButton( key: Key('appbar-menu-button'), itemBuilder: (context) { + final isNotEmpty = !collection.isEmpty; final hasSelection = collection.selection.isNotEmpty; return [ PopupMenuItem( @@ -216,10 +217,12 @@ class _CollectionAppBarState extends State 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 +251,7 @@ class _CollectionAppBarState extends State with SingleTickerPr PopupMenuDivider(), PopupMenuItem( value: CollectionAction.selectAll, + enabled: collection.selection.length < collection.entryCount, child: MenuRow(text: 'Select all'), ), PopupMenuItem( diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index fc4441c79..7bfaffe0a 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -146,15 +146,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware 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)).toSet()); - } + source.removeEntries(deletedUris); collection.clearSelection(); collection.browse(); }, diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index b600f0380..a21969462 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -27,7 +27,9 @@ class AppDebugPage extends StatefulWidget { } class _AppDebugPageState extends State { - List get entries => widget.source.rawEntries; + CollectionSource get source => widget.source; + + Set get visibleEntries => source.visibleEntries; static OverlayEntry _taskQueueOverlayEntry; @@ -59,7 +61,7 @@ class _AppDebugPageState extends State { } 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 { 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}', diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 90b8750c4..a484a158e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -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( diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index d8eb689df..b2fa1c7a1 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -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 _showDeleteDialog(BuildContext context, AlbumFilter filter) async { - final selection = source.rawEntries.where(filter.filter).toSet(); + final selection = source.visibleEntries.where(filter.filter).toSet(); final count = selection.length; final confirmed = await showDialog( @@ -85,15 +92,13 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per 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)).toSet()); - } + source.removeEntries(deletedUris); }, ); } @@ -108,7 +113,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final todoEntries = source.rawEntries.where(filter.filter).toSet(); + final todoEntries = source.visibleEntries.where(filter.filter).toSet(); final destinationAlbum = path.join(path.dirname(album), newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index d67f41a8b..448b7f62f 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -151,7 +151,7 @@ class FilterNavigationPage extends StatelessWidget { } static int compareFiltersByDate(FilterGridItem a, FilterGridItem 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); } diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 9828bf51b..1f672758f 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -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; } diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index f348480c4..eceecf5c8 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -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( diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 5743cec5d..c31525c3e 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -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( diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index a838d3901..c5501f33f 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -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; diff --git a/lib/widgets/settings/hidden_filters.dart b/lib/widgets/settings/hidden_filters.dart new file mode 100644 index 000000000..ccbde9d95 --- /dev/null +++ b/lib/widgets/settings/hidden_filters.dart @@ -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>( + 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( + 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().changeFilterVisibility(filter, true), + )) + .toList(), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index e82cab105..5da66e45e 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -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 { onChanged: (v) => settings.isCrashlyticsEnabled = v, title: Text('Allow anonymous analytics and crash reporting'), ), + HiddenFilters(), Padding( padding: EdgeInsets.only(top: 8, bottom: 16), child: GrantedDirectories(), diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index a11c82b21..92132e5b6 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -29,7 +29,7 @@ class StatsPage extends StatelessWidget { final CollectionLens parentCollection; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; - List get entries => parentCollection?.sortedEntries ?? source.rawEntries; + Set get entries => parentCollection?.sortedEntries?.toSet() ?? source.visibleEntries; static const mimeDonutMinWidth = 124.0; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index d53ea166b..c85c8cc86 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -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); } From 3d12825e68f854cdd00e4e8ce49205aad1a2c82d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 14:14:48 +0900 Subject: [PATCH 24/32] media store monitoring: pause monitoring during bulk op --- lib/main.dart | 4 ++-- lib/model/source/collection_lens.dart | 11 +++++++---- lib/model/source/collection_source.dart | 10 ++++++++++ lib/model/source/media_store_source.dart | 10 ++++++---- lib/widgets/collection/app_bar.dart | 5 +---- lib/widgets/collection/collection_page.dart | 1 - lib/widgets/collection/entry_set_action_delegate.dart | 7 ++++--- .../filter_grids/common/chip_action_delegate.dart | 4 ++++ 8 files changed, 34 insertions(+), 18 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4cefeea66..92a0b6e65 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -49,7 +49,7 @@ class _AvesAppState extends State { Future _appSetup; final _mediaStoreSource = MediaStoreSource(); final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); - final List changedUris = []; + final Set changedUris = {}; // observers are not registered when using the same list object with different items // the list itself needs to be reassigned @@ -193,7 +193,7 @@ class _AvesAppState extends State { if (uri != null) changedUris.add(uri); if (changedUris.isNotEmpty) { _contentChangeDebouncer(() async { - final todo = List.of(changedUris); + final todo = changedUris.toSet(); changedUris.clear(); final tempUris = await _mediaStoreSource.refreshUris(todo); if (tempUris.isNotEmpty) { diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index d20d9554e..109ba3318 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -16,7 +16,7 @@ import 'package:flutter/foundation.dart'; import 'enums.dart'; -class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSelectionMixin { +class CollectionLens with ChangeNotifier, CollectionActivityMixin { final CollectionSource source; final Set filters; EntryGroupFactor groupFactor; @@ -209,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 _selection = {}; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 5f8280dc4..53bf70ed8 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -205,6 +205,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future refreshMetadata(Set entries); + // monitoring + + bool _monitoring = true; + + void pauseMonitoring() => _monitoring = false; + + void resumeMonitoring() => _monitoring = true; + + bool get isMonitoring => _monitoring; + // filter summary int count(CollectionFilter filter) { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index e3b69463e..8b098132b 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -107,12 +107,13 @@ class MediaStoreSource extends CollectionSource { ); } - // returns URIs that are in the Media Store but still being processed by their owner in a temporary location + // 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> refreshUris(List changedUris) async { - final tempUris = []; - if (!_initialized) return tempUris; + Future> refreshUris(Set changedUris) async { + if (!_initialized || !isMonitoring) return changedUris; final uriByContentId = Map.fromEntries(changedUris.map((uri) { if (uri == null) return null; @@ -129,6 +130,7 @@ class MediaStoreSource extends CollectionSource { obsoleteContentIds.forEach(uriByContentId.remove); // fetch new entries + final tempUris = {}; final newEntries = {}; for (final kv in uriByContentId.entries) { final contentId = kv.key; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 8db5ed80a..4cc51796f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -124,10 +124,7 @@ class _CollectionAppBarState extends State 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( diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 75e6ee911..99a6547bf 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -37,7 +37,6 @@ class _CollectionPageState extends State { body: WillPopScope( onWillPop: () { if (collection.isSelecting) { - collection.clearSelection(); collection.browse(); return SynchronousFuture(false); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 7bfaffe0a..2d41d0bce 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -55,7 +55,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware break; case CollectionAction.refreshMetadata: source.refreshMetadata(selection); - collection.clearSelection(); collection.browse(); break; default: @@ -87,6 +86,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware // 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( context: context, opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), @@ -108,8 +108,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware destinationAlbum: destinationAlbum, movedOps: movedOps, ); - collection.clearSelection(); collection.browse(); + source.resumeMonitoring(); }, ); } @@ -141,6 +141,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (!await checkStoragePermission(context, selection)) return; final selectionCount = selection.length; + source.pauseMonitoring(); showOpReport( context: context, opStream: ImageFileService.delete(selection), @@ -153,8 +154,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); } source.removeEntries(deletedUris); - collection.clearSelection(); collection.browse(); + source.resumeMonitoring(); }, ); } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index b2fa1c7a1..86d1c318e 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -87,6 +87,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermission(context, selection)) return; final selectionCount = selection.length; + source.pauseMonitoring(); showOpReport( context: context, opStream: ImageFileService.delete(selection), @@ -99,6 +100,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); } source.removeEntries(deletedUris); + source.resumeMonitoring(); }, ); } @@ -122,6 +124,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per // 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( context: context, opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), @@ -148,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(); }, ); } From 7fb40c0810e0e97455488bc23129eaf47675d9f9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 14:16:58 +0900 Subject: [PATCH 25/32] updated README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 34fbae115..ba87c7bcd 100644 --- a/README.md +++ b/README.md @@ -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 | From dcfc07ff00681595206b11c3fb6fab17d7865b16 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 14:53:35 +0900 Subject: [PATCH 26/32] hidden filters: refresh metadata of newly visible items --- lib/model/filters/album.dart | 2 +- lib/model/filters/favourite.dart | 2 +- lib/model/filters/filters.dart | 3 +-- lib/model/filters/location.dart | 10 +++++----- lib/model/filters/mime.dart | 16 ++++++++-------- lib/model/filters/query.dart | 6 +++--- lib/model/filters/tag.dart | 8 ++++---- lib/model/source/album.dart | 4 ++-- lib/model/source/collection_lens.dart | 2 +- lib/model/source/collection_source.dart | 6 +++++- lib/model/source/location.dart | 4 ++-- lib/model/source/tag.dart | 4 ++-- .../common/chip_action_delegate.dart | 4 ++-- 13 files changed, 37 insertions(+), 34 deletions(-) diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 21c912c89..ec48d627b 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -32,7 +32,7 @@ class AlbumFilter extends CollectionFilter { }; @override - EntryFilter get filter => (entry) => entry.directory == album; + EntryFilter get test => (entry) => entry.directory == album; @override String get label => uniqueName ?? album.split(separator).last; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 5d17006c9..348bb4aeb 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -12,7 +12,7 @@ class FavouriteFilter extends CollectionFilter { }; @override - EntryFilter get filter => (entry) => entry.isFavourite; + EntryFilter get test => (entry) => entry.isFavourite; @override String get label => 'Favourite'; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 1c9ce5aad..be2e18149 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable { String toJson() => jsonEncode(toMap()); - EntryFilter get filter; + EntryFilter get test; bool get isUnique => true; @@ -75,7 +75,6 @@ abstract class CollectionFilter implements Comparable { } } -// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source class FilterGridItem { final T filter; final AvesEntry entry; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 79e515ce1..1d596cba4 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -11,7 +11,7 @@ class LocationFilter extends CollectionFilter { final LocationLevel level; String _location; String _countryCode; - EntryFilter _filter; + EntryFilter _test; LocationFilter(this.level, this._location) { final split = _location.split(locationSeparator); @@ -19,11 +19,11 @@ class LocationFilter extends CollectionFilter { if (split.length > 1) _countryCode = split[1]; if (_location.isEmpty) { - _filter = (entry) => !entry.isLocated; + _test = (entry) => !entry.isLocated; } else if (level == LocationLevel.country) { - _filter = (entry) => entry.addressDetails?.countryCode == _countryCode; + _test = (entry) => entry.addressDetails?.countryCode == _countryCode; } else if (level == LocationLevel.place) { - _filter = (entry) => entry.addressDetails?.place == _location; + _test = (entry) => entry.addressDetails?.place == _location; } } @@ -45,7 +45,7 @@ class LocationFilter extends CollectionFilter { String get countryCode => _countryCode; @override - EntryFilter get filter => _filter; + EntryFilter get test => _test; @override String get label => _location.isEmpty ? emptyLabel : _location; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index be6b5f4be..ef21fe65c 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -14,31 +14,31 @@ class MimeFilter extends CollectionFilter { static const geotiff = 'aves/geotiff'; // subset of `image/tiff` final String mime; - EntryFilter _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; @@ -48,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; @@ -66,7 +66,7 @@ class MimeFilter extends CollectionFilter { }; @override - EntryFilter get filter => _filter; + EntryFilter get test => _test; @override String get label => _label; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 5d84812e0..dcbf6064e 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter { final String query; final bool colorful; - EntryFilter _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 json) @@ -44,7 +44,7 @@ class QueryFilter extends CollectionFilter { }; @override - EntryFilter get filter => _filter; + EntryFilter get test => _test; @override bool get isUnique => false; diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 648768f9c..bec9dbe74 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -8,13 +8,13 @@ class TagFilter extends CollectionFilter { static const emptyLabel = 'untagged'; final String tag; - EntryFilter _filter; + EntryFilter _test; TagFilter(this.tag) { if (tag.isEmpty) { - _filter = (entry) => entry.xmpSubjects.isEmpty; + _test = (entry) => entry.xmpSubjects.isEmpty; } else { - _filter = (entry) => entry.xmpSubjects.contains(tag); + _test = (entry) => entry.xmpSubjects.contains(tag); } } @@ -30,7 +30,7 @@ class TagFilter extends CollectionFilter { }; @override - EntryFilter get filter => _filter; + EntryFilter get test => _test; @override bool get isUnique => false; diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 1c358d6f3..2ae41ee85 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -122,11 +122,11 @@ mixin AlbumMixin on SourceBase { } int albumEntryCount(AlbumFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length); } AvesEntry albumRecentEntry(AlbumFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); + return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); } } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 109ba3318..fd60b4231 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -123,7 +123,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin { void _applyFilters() { final entries = source.visibleEntries; - _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry)))); + _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); } void _applySort() { diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 53bf70ed8..1e4bd0243 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -70,7 +70,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Iterable _applyHiddenFilters(Iterable entries) { final hiddenFilters = settings.hiddenFilters; - return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.filter(entry))); + return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } void _invalidate([Set entries]) { @@ -247,6 +247,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM updateDirectories(); updateLocations(); updateTags(); + + if (visible) { + refreshMetadata(visibleEntries.where(filter.test).toSet()); + } } } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 616e55463..bf25c2551 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -121,11 +121,11 @@ mixin LocationMixin on SourceBase { } int countryEntryCount(LocationFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where(filter.test).length); } AvesEntry countryRecentEntry(LocationFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); + return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); } } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 781c5f2ed..1d4a3b32c 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -79,11 +79,11 @@ mixin TagMixin on SourceBase { } int tagEntryCount(TagFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where(filter.test).length); } AvesEntry tagRecentEntry(TagFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); + return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere(filter.test, orElse: () => null)); } } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 86d1c318e..c5345788b 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -60,7 +60,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } Future _showDeleteDialog(BuildContext context, AlbumFilter filter) async { - final selection = source.visibleEntries.where(filter.filter).toSet(); + final selection = source.visibleEntries.where(filter.test).toSet(); final count = selection.length; final confirmed = await showDialog( @@ -115,7 +115,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final todoEntries = source.visibleEntries.where(filter.filter).toSet(); + final todoEntries = source.visibleEntries.where(filter.test).toSet(); final destinationAlbum = path.join(path.dirname(album), newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; From b5d800edc2d7e5f5c154ea54153b66b12b324d7e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 15:28:35 +0900 Subject: [PATCH 27/32] safer activity request to get volume access --- .../deckers/thibault/aves/utils/PermissionManager.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 7ecf9a3b5..bb737b892 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -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?) { From 55acafc1ab361e512bf231711a75752daba3a56b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 10 Feb 2021 11:32:14 +0900 Subject: [PATCH 28/32] #45 collection: find entries with obsolete paths --- .../deckers/thibault/aves/MainActivity.kt | 1 + .../aves/channel/calls/ImageFileHandler.kt | 55 +++++++---------- .../aves/channel/calls/MediaStoreHandler.kt | 43 +++++++++++++ .../model/provider/MediaStoreImageProvider.kt | 60 +++++++++++++------ lib/model/source/media_store_source.dart | 19 ++++-- lib/services/image_file_service.dart | 37 ++---------- lib/services/media_store_service.dart | 47 +++++++++++++++ 7 files changed, 175 insertions(+), 87 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt create mode 100644 lib/services/media_store_service.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index a6742b22c..98e308d7f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -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)) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index a9c831565..5af8a45f3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -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>("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("mimeType") // MIME type is optional + val uri = call.argument("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("mimeType") // MIME type is optional - val uri = call.argument("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("entry") val newName = call.argument("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" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt new file mode 100644 index 000000000..9ac2992a0 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaStoreHandler.kt @@ -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>("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>("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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index b2386f961..78e65b524 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -23,6 +23,7 @@ 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, handleNewEntry: NewEntryHandler) { @@ -59,30 +60,53 @@ class MediaStoreImageProvider : ImageProvider() { callback.onFailure(Exception("failed to fetch entry at uri=$uri")) } - fun getObsoleteContentIds(context: Context, knownContentIds: List): List { - val current = arrayListOf().apply { - addAll(getContentIdList(context, IMAGE_CONTENT_URI)) - addAll(getContentIdList(context, VIDEO_CONTENT_URI)) + fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { + val foundContentIds = ArrayList() + 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 { - val foundContentIds = ArrayList() - 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): List { + val obsoleteIds = ArrayList() + 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( diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 8b098132b..103d82ec3 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/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'; @@ -49,8 +50,8 @@ 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 @@ -62,6 +63,13 @@ 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) { + knownDateById[contentId] = 0; + }); + // fetch new entries // refresh after the first 10 entries, then after 100 more, then every 1000 entries var refreshCount = 10; @@ -73,7 +81,7 @@ class MediaStoreSource extends CollectionSource { pendingNewEntries.clear(); } - ImageFileService.getEntries(knownEntryMap).listen( + MediaStoreService.getEntries(knownDateById).listen( (entry) { pendingNewEntries.add(entry); if (pendingNewEntries.length >= refreshCount) { @@ -124,7 +132,7 @@ class MediaStoreSource extends CollectionSource { }).where((kv) => kv != null)); // clean up obsolete entries - final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet(); + final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet(); removeEntries(obsoleteUris); obsoleteContentIds.forEach(uriByContentId.remove); @@ -138,7 +146,8 @@ class MediaStoreSource extends CollectionSource { final sourceEntry = await ImageFileService.getEntry(uri, null); if (sourceEntry != null) { final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); - if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) { + // 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); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 75ad7eda1..20b75ecee 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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 _toPlatformEntryMap(AvesEntry entry) { @@ -32,30 +31,6 @@ class ImageFileService { }; } - // knownEntries: map of contentId -> dateModifiedSecs - static Stream getEntries(Map knownEntries) { - try { - return mediaStoreChannel.receiveBroadcastStream({ - '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> getObsoleteEntries(List knownContentIds) async { - try { - final result = await platform.invokeMethod('getObsoleteEntries', { - 'knownContentIds': knownContentIds, - }); - return (result as List).cast(); - } on PlatformException catch (e) { - debugPrint('getObsoleteEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return []; - } - static Future getEntry(String uri, String mimeType) async { try { final result = await platform.invokeMethod('getEntry', { @@ -97,7 +72,7 @@ class ImageFileService { final completer = Completer.sync(); final sink = _OutputBuffer(); var bytesReceived = 0; - byteChannel.receiveBroadcastStream({ + _byteStreamChannel.receiveBroadcastStream({ 'uri': uri, 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, @@ -225,7 +200,7 @@ class ImageFileService { static Stream delete(Iterable entries) { try { - return opChannel.receiveBroadcastStream({ + return _opStreamChannel.receiveBroadcastStream({ 'op': 'delete', 'entries': entries.map(_toPlatformEntryMap).toList(), }).map((event) => ImageOpEvent.fromMap(event)); @@ -241,7 +216,7 @@ class ImageFileService { @required String destinationAlbum, }) { try { - return opChannel.receiveBroadcastStream({ + return _opStreamChannel.receiveBroadcastStream({ 'op': 'move', 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, @@ -259,7 +234,7 @@ class ImageFileService { @required String destinationAlbum, }) { try { - return opChannel.receiveBroadcastStream({ + return _opStreamChannel.receiveBroadcastStream({ 'op': 'export', 'entries': entries.map(_toPlatformEntryMap).toList(), 'mimeType': mimeType, diff --git a/lib/services/media_store_service.dart b/lib/services/media_store_service.dart new file mode 100644 index 000000000..43f380358 --- /dev/null +++ b/lib/services/media_store_service.dart @@ -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> checkObsoleteContentIds(List knownContentIds) async { + try { + final result = await platform.invokeMethod('checkObsoleteContentIds', { + 'knownContentIds': knownContentIds, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } + + static Future> checkObsoletePaths(Map knownPathById) async { + try { + final result = await platform.invokeMethod('checkObsoletePaths', { + 'knownPathById': knownPathById, + }); + return (result as List).cast(); + } 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 getEntries(Map knownEntries) { + try { + return _streamChannel.receiveBroadcastStream({ + '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); + } + } +} From d7d04cb234169f35c71e31cec6ae36382020be13 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 10 Feb 2021 11:48:56 +0900 Subject: [PATCH 29/32] updated changelog --- CHANGELOG.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e021228..93079fe33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 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 @@ -34,7 +47,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 +86,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 +105,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 +121,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 From 0689a3541b7c3cbafdb289dd76b16d82b26551ab Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 10 Feb 2021 12:10:43 +0900 Subject: [PATCH 30/32] packages upgrade --- pubspec.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 324e8b2b9..a832d319c 100644 --- a/pubspec.lock +++ b/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: @@ -407,14 +407,14 @@ packages: 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: @@ -680,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: @@ -827,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: @@ -888,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: @@ -1105,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: From 1833a8cde553b788458ee4ce725c4130087e2b44 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 10 Feb 2021 14:50:14 +0900 Subject: [PATCH 31/32] minor fixes --- lib/model/entry.dart | 4 +--- lib/model/source/media_store_source.dart | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/model/entry.dart b/lib/model/entry.dart index de75b6219..db9cbfe03 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -652,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); } } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 103d82ec3..590960d0e 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -67,6 +67,7 @@ class MediaStoreSource extends CollectionSource { 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; }); @@ -94,7 +95,13 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}'); await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries - invalidateAlbumFilterSummary(entries: allNewEntries); + + 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(allEntries.length, 3)).toString())); @@ -140,6 +147,7 @@ class MediaStoreSource extends CollectionSource { // fetch new entries final tempUris = {}; final newEntries = {}; + final existingDirectories = {}; for (final kv in uriByContentId.entries) { final contentId = kv.key; final uri = kv.value; @@ -151,6 +159,9 @@ class MediaStoreSource extends CollectionSource { 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); @@ -160,9 +171,10 @@ class MediaStoreSource extends CollectionSource { } if (newEntries.isNotEmpty) { + invalidateAlbumFilterSummary(directories: existingDirectories); addEntries(newEntries); await metadataDb.saveEntries(newEntries); - invalidateAlbumFilterSummary(entries: newEntries); + cleanEmptyAlbums(existingDirectories); stateNotifier.value = SourceState.cataloguing; await catalogEntries(); From 312f94e87e2c7f5a565d55cc3f29ec808b476446 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 10 Feb 2021 15:14:21 +0900 Subject: [PATCH 32/32] version bump --- CHANGELOG.md | 2 ++ pubspec.yaml | 2 +- whatsnew/whatsnew-en-US | 7 +++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93079fe33..7ff63c3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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 diff --git a/pubspec.yaml b/pubspec.yaml index 6c7d6e862..95b1178cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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) diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 61628d128..dd8e84e27 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -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 \ No newline at end of file