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 3efc65b51..6930273bc 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 @@ -13,7 +13,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.request.RequestOptions import deckers.thibault.aves.utils.BitmapUtils.getBytes -import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -257,7 +257,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } companion object { - private val LOG_TAG = createTag(AppAdapterHandler::class.java) + private val LOG_TAG = LogUtils.createTag(AppAdapterHandler::class.java) const val CHANNEL = "deckers.thibault/aves/app" } } \ No newline at end of file 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 a6ba66dea..f0a95b2d6 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 @@ -619,7 +619,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) exif.thumbnailBitmap?.let { bitmap -> TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let { - thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false)) + it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) } } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index b08332540..04087ca04 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -40,7 +40,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen override fun onCancel(o: Any) {} - private fun success(bytes: ByteArray) { + private fun success(bytes: ByteArray?) { handler.post { try { eventSink.success(bytes) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index fe759f7c5..503fad11e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -9,7 +9,7 @@ import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider -import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -145,7 +145,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments: } companion object { - private val LOG_TAG = createTag(ImageOpStreamHandler::class.java) + private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java) const val CHANNEL = "deckers.thibault/aves/imageopstream" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt index 050f42cca..30c3627c2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt @@ -83,7 +83,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va if (bitmap == null) { callback.onLoadFailed(Exception("null bitmap")) } else { - callback.onDataReady(bitmap.getBytes().inputStream()) + callback.onDataReady(bitmap.getBytes()?.inputStream()) } } 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 c1eeca279..8814ee5c7 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 @@ -10,7 +10,7 @@ import androidx.exifinterface.media.ExifInterface import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.ExifOrientationOp -import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp @@ -195,7 +195,7 @@ abstract class ImageProvider { } companion object { - private val LOG_TAG = createTag(ImageProvider::class.java) + private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java) } } 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 5c285de53..7329453a0 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 @@ -10,7 +10,7 @@ import android.util.Log import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.model.AvesImageEntry import deckers.thibault.aves.model.SourceImageEntry -import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo @@ -312,7 +312,7 @@ class MediaStoreImageProvider : ImageProvider() { } companion object { - private val LOG_TAG = createTag(MediaStoreImageProvider::class.java) + private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java) private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt index c8ef015e5..cb70f3347 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -2,23 +2,31 @@ package deckers.thibault.aves.utils import android.content.Context import android.graphics.Bitmap +import android.util.Log import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.TransformationUtils import deckers.thibault.aves.metadata.Metadata.getExifCode import java.io.ByteArrayOutputStream object BitmapUtils { - fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray { - val stream = ByteArrayOutputStream() - // we compress the bitmap because Flutter cannot decode the raw bytes - // `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency - if (canHaveAlpha) { - this.compress(Bitmap.CompressFormat.PNG, quality, stream) - } else { - this.compress(Bitmap.CompressFormat.JPEG, quality, stream) + private val LOG_TAG = LogUtils.createTag(BitmapUtils::class.java) + + fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray? { + try { + val stream = ByteArrayOutputStream() + // we compress the bitmap because Flutter cannot decode the raw bytes + // `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency + if (canHaveAlpha) { + this.compress(Bitmap.CompressFormat.PNG, quality, stream) + } else { + this.compress(Bitmap.CompressFormat.JPEG, quality, stream) + } + if (recycle) this.recycle() + return stream.toByteArray() + } catch (e: IllegalStateException) { + Log.e(LOG_TAG, "failed to get bytes from bitmap", e) } - if (recycle) this.recycle() - return stream.toByteArray() + return null; } fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? { 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 2ba59174a..3e07b7c70 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 @@ -8,14 +8,13 @@ import android.os.Build import android.os.storage.StorageManager import android.util.Log import androidx.core.app.ActivityCompat -import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* import java.util.concurrent.ConcurrentHashMap object PermissionManager { - private val LOG_TAG = createTag(PermissionManager::class.java) + private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java) const val VOLUME_ACCESS_REQUEST_CODE = 1 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index ab26c9932..e3f644b90 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -13,7 +13,6 @@ import android.text.TextUtils import android.util.Log import android.webkit.MimeTypeMap import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import java.io.File import java.io.FileNotFoundException @@ -23,7 +22,7 @@ import java.util.* import java.util.regex.Pattern object StorageUtils { - private val LOG_TAG = createTag(StorageUtils::class.java) + private val LOG_TAG = LogUtils.createTag(StorageUtils::class.java) /** * Volume paths diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index f5cfd1a6e..b2bfb1689 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart'; import 'package:flutter/widgets.dart'; enum ChipSetAction { + group, sort, refresh, stats, diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 5e3b2b14c..e70f39dd8 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -74,3 +74,20 @@ abstract class CollectionFilter implements Comparable { return c != 0 ? c : compareAsciiUpperCase(label, other.label); } } + +// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source +class FilterGridItem { + final T filter; + final ImageEntry entry; + + const FilterGridItem(this.filter, this.entry); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is FilterGridItem && other.filter == filter && other.entry == entry; + } + + @override + int get hashCode => hashValues(filter, entry); +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 008affa65..3e5fe5246 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -40,6 +40,7 @@ class Settings extends ChangeNotifier { static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration'; // filter grids + static const albumGroupFactorKey = 'album_group_factor'; static const albumSortFactorKey = 'album_sort_factor'; static const countrySortFactorKey = 'country_sort_factor'; static const tagSortFactorKey = 'tag_sort_factor'; @@ -145,6 +146,10 @@ class Settings extends ChangeNotifier { // filter grids + AlbumChipGroupFactor get albumGroupFactor => getEnumOrDefault(albumGroupFactorKey, AlbumChipGroupFactor.importance, AlbumChipGroupFactor.values); + + set albumGroupFactor(AlbumChipGroupFactor newValue) => setAndNotify(albumGroupFactorKey, newValue.toString()); + ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values); set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString()); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 42bbfcaae..f56893da9 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -139,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel case EntrySortFactor.date: switch (groupFactor) { case EntryGroupFactor.album: - sections = groupBy(_filteredEntries, (entry) => AlbumSectionKey(entry.directory)); + sections = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); break; case EntryGroupFactor.month: - sections = groupBy(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken)); + sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); break; case EntryGroupFactor.day: - sections = groupBy(_filteredEntries, (entry) => DateSectionKey(entry.dayTaken)); + sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); break; case EntryGroupFactor.none: sections = Map.fromEntries([ @@ -160,8 +160,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel ]); break; case EntrySortFactor.name: - final byAlbum = groupBy(_filteredEntries, (entry) => AlbumSectionKey(entry.directory)); - sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); + final byAlbum = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); break; } sections = Map.unmodifiable(sections); diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 65b52917b..a1ba59bb3 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -2,6 +2,8 @@ enum Activity { browse, select } enum ChipSortFactor { date, name, count } +enum AlbumChipGroupFactor { none, importance, volume } + enum EntrySortFactor { date, size, name } enum EntryGroupFactor { none, album, month, day } diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart index 5af4ef493..072ddcbb3 100644 --- a/lib/model/source/section_keys.dart +++ b/lib/model/source/section_keys.dart @@ -4,15 +4,15 @@ class SectionKey { const SectionKey(); } -class AlbumSectionKey extends SectionKey { +class EntryAlbumSectionKey extends SectionKey { final String folderPath; - const AlbumSectionKey(this.folderPath); + const EntryAlbumSectionKey(this.folderPath); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is AlbumSectionKey && other.folderPath == folderPath; + return other is EntryAlbumSectionKey && other.folderPath == folderPath; } @override @@ -22,15 +22,15 @@ class AlbumSectionKey extends SectionKey { String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}'; } -class DateSectionKey extends SectionKey { +class EntryDateSectionKey extends SectionKey { final DateTime date; - const DateSectionKey(this.date); + const EntryDateSectionKey(this.date); @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is DateSectionKey && other.date == date; + return other is EntryDateSectionKey && other.date == date; } @override diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index 272d80216..f6db50107 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -1,8 +1,8 @@ import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/grid/header.dart'; -import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/material.dart'; @@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget { ); } return SectionHeader( - sectionKey: AlbumSectionKey(folderPath), + sectionKey: EntryAlbumSectionKey(folderPath), leading: albumIcon, title: albumName, trailing: androidFileUtils.isOnRemovableStorage(folderPath) @@ -42,7 +42,7 @@ class AlbumSectionHeader extends StatelessWidget { ); } - static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, AlbumSectionKey sectionKey) { + static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) { final folderPath = sectionKey.folderPath; return SectionHeader.getPreferredHeight( context: context, diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 7091fb389..9a21b47ce 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 AlbumSectionKey).folderPath, + folderPath: (sectionKey as EntryAlbumSectionKey).folderPath, ); switch (collection.sortFactor) { @@ -45,9 +45,9 @@ class CollectionSectionHeader extends StatelessWidget { case EntryGroupFactor.album: return _buildAlbumHeader(); case EntryGroupFactor.month: - return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date); + return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); case EntryGroupFactor.day: - return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date); + return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); case EntryGroupFactor.none: break; } @@ -62,7 +62,7 @@ class CollectionSectionHeader extends StatelessWidget { static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) { var headerExtent = 0.0; - if (sectionKey is AlbumSectionKey) { + if (sectionKey is EntryAlbumSectionKey) { // only compute height for album headers, as they're the only likely ones to split on multiple lines headerExtent = AlbumSectionHeader.getPreferredHeight(context, maxWidth, source, sectionKey); } diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index d1d3a5af9..8de36ce61 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -37,7 +37,7 @@ class DaySectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { return SectionHeader( - sectionKey: DateSectionKey(date), + sectionKey: EntryDateSectionKey(date), title: text, ); } @@ -66,7 +66,7 @@ class MonthSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { return SectionHeader( - sectionKey: DateSectionKey(date), + sectionKey: EntryDateSectionKey(date), title: text, ); } diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart index 2c55d9cb0..3dad0d735 100644 --- a/lib/widgets/collection/grid/section_layout.dart +++ b/lib/widgets/collection/grid/section_layout.dart @@ -25,10 +25,10 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider collection.showHeaders; + bool get showHeaders => collection.showHeaders; @override - Map> getSections() => collection.sections; + Map> get sections => collection.sections; @override double getHeaderExtent(BuildContext context, SectionKey sectionKey) { diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/collection/grid/selector.dart index dda6c2b05..83c38e506 100644 --- a/lib/widgets/collection/grid/selector.dart +++ b/lib/widgets/collection/grid/selector.dart @@ -29,7 +29,7 @@ class GridSelectionGestureDetector extends StatefulWidget { } class _GridSelectionGestureDetectorState extends State { - bool _pressing, _selecting; + bool _pressing = false, _selecting; int _fromIndex, _lastToIndex; Offset _localPosition; EdgeInsets _scrollableInsets; @@ -136,7 +136,7 @@ class _GridSelectionGestureDetectorState extends State>(); - return sectionedListLayout.getEntryAt(offset); + return sectionedListLayout.getItemAt(offset); } void _toggleSelectionToIndex(int toIndex) { diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index 403533c2f..f5530347b 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -27,6 +27,7 @@ import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; class ThumbnailCollection extends StatelessWidget { @@ -63,17 +64,19 @@ class ThumbnailCollection extends StatelessWidget { // so that view updates on collection filter changes return Consumer( builder: (context, collection, child) { - final scrollView = CollectionScrollView( - scrollableKey: _scrollableKey, - collection: collection, - appBar: CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, + final scrollView = AnimationLimiter( + child: CollectionScrollView( + scrollableKey: _scrollableKey, collection: collection, + appBar: CollectionAppBar( + appBarHeightNotifier: _appBarHeightNotifier, + collection: collection, + ), + appBarHeightNotifier: _appBarHeightNotifier, + isScrollingNotifier: _isScrollingNotifier, + scrollController: scrollController, + cacheExtent: cacheExtent, ), - appBarHeightNotifier: _appBarHeightNotifier, - isScrollingNotifier: _isScrollingNotifier, - scrollController: scrollController, - cacheExtent: cacheExtent, ); final scaler = GridScaleGestureDetector( diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 8b4ad2b1d..0bc1db5ad 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -12,6 +12,7 @@ class SectionHeader extends StatelessWidget { final SectionKey sectionKey; final Widget leading, trailing; final String title; + final bool selectable; const SectionHeader({ Key key, @@ -19,6 +20,7 @@ class SectionHeader extends StatelessWidget { this.leading, @required this.title, this.trailing, + this.selectable = true, }) : super(key: key); static const leadingDimension = 32.0; @@ -41,6 +43,7 @@ class SectionHeader extends StatelessWidget { WidgetSpan( alignment: widgetSpanAlignment, child: _SectionSelectableLeading( + selectable: selectable, sectionKey: sectionKey, browsingBuilder: leading != null ? (context) => Container( @@ -118,12 +121,14 @@ class SectionHeader extends StatelessWidget { } class _SectionSelectableLeading extends StatelessWidget { + final bool selectable; final SectionKey sectionKey; final WidgetBuilder browsingBuilder; final VoidCallback onPressed; const _SectionSelectableLeading({ Key key, + this.selectable = true, @required this.sectionKey, @required this.browsingBuilder, @required this.onPressed, @@ -133,6 +138,8 @@ class _SectionSelectableLeading extends StatelessWidget { @override Widget build(BuildContext context) { + if (!selectable) return _buildBrowsing(context); + final collection = Provider.of(context); return ValueListenableBuilder( valueListenable: collection.activityNotifier, @@ -173,7 +180,7 @@ class _SectionSelectableLeading extends StatelessWidget { ); }, ) - : browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension); + : _buildBrowsing(context); return AnimatedSwitcher( duration: Durations.sectionHeaderAnimation, switchInCurve: Curves.easeInOut, @@ -199,4 +206,6 @@ class _SectionSelectableLeading extends StatelessWidget { }, ); } + + Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension); } diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index ed1292495..a49a2e241 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -1,20 +1,23 @@ import 'dart:math'; import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/theme/durations.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; abstract class SectionedListLayoutProvider extends StatelessWidget { final double scrollableWidth; final int columnCount; - final double tileExtent; - final Widget Function(T entry) tileBuilder; + final double spacing, tileExtent; + final Widget Function(T item) tileBuilder; final Widget child; const SectionedListLayoutProvider({ @required this.scrollableWidth, @required this.columnCount, + this.spacing = 0, @required this.tileExtent, @required this.tileBuilder, @required this.child, @@ -29,25 +32,26 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { } SectionedListLayout _updateLayouts(BuildContext context) { - final showHeaders = needHeaders(); - final sections = getSections(); - final sectionKeys = sections.keys.toList(); + final _showHeaders = showHeaders; + final _sections = sections; + final sectionKeys = _sections.keys.toList(); final sectionLayouts = []; var currentIndex = 0, currentOffset = 0.0; sectionKeys.forEach((sectionKey) { - final section = sections[sectionKey]; - final sectionEntryCount = section.length; - final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil(); + final section = _sections[sectionKey]; + final sectionItemCount = section.length; + final rowCount = (sectionItemCount / columnCount).ceil(); + final sectionChildCount = 1 + rowCount; - final headerExtent = showHeaders ? getHeaderExtent(context, sectionKey) : 0.0; + final headerExtent = _showHeaders ? getHeaderExtent(context, sectionKey) : 0.0; final sectionFirstIndex = currentIndex; currentIndex += sectionChildCount; final sectionLastIndex = currentIndex - 1; final sectionMinOffset = currentOffset; - currentOffset += headerExtent + tileExtent * (sectionChildCount - 1); + currentOffset += headerExtent + tileExtent * rowCount + spacing * (rowCount - 1); final sectionMaxOffset = currentOffset; sectionLayouts.add( @@ -59,9 +63,11 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { maxOffset: sectionMaxOffset, headerExtent: headerExtent, tileExtent: tileExtent, + spacing: spacing, builder: (context, listIndex) => _buildInSection( context, section, + listIndex * columnCount, listIndex - sectionFirstIndex, sectionKey, headerExtent, @@ -70,28 +76,39 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { ); }); return SectionedListLayout( - sections: sections, - showHeaders: showHeaders, + sections: _sections, + showHeaders: _showHeaders, columnCount: columnCount, tileExtent: tileExtent, + spacing: spacing, sectionLayouts: sectionLayouts, ); } - Widget _buildInSection(BuildContext context, List section, int sectionChildIndex, SectionKey sectionKey, double headerExtent) { + Widget _buildInSection( + BuildContext context, + List section, + int sectionGridIndex, + int sectionChildIndex, + SectionKey sectionKey, + double headerExtent, + ) { if (sectionChildIndex == 0) { - return headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink(); + final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink(); + return _buildAnimation(sectionGridIndex, header); } sectionChildIndex--; - final sectionEntryCount = section.length; + final sectionItemCount = section.length; - final minEntryIndex = sectionChildIndex * columnCount; - final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount); + final minItemIndex = sectionChildIndex * columnCount; + final maxItemIndex = min(sectionItemCount, minItemIndex + columnCount); final children = []; - for (var i = minEntryIndex; i < maxEntryIndex; i++) { - final entry = section[i]; - children.add(tileBuilder(entry)); + for (var i = minItemIndex; i < maxItemIndex; i++) { + final itemGridIndex = sectionGridIndex + i - minItemIndex; + final item = tileBuilder(section[i]); + if (i != minItemIndex) children.add(SizedBox(width: spacing)); + children.add(_buildAnimation(itemGridIndex, item)); } return Row( mainAxisSize: MainAxisSize.min, @@ -99,9 +116,24 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { ); } - bool needHeaders(); + Widget _buildAnimation(int index, Widget child) { + return AnimationConfiguration.staggeredGrid( + position: index, + columnCount: columnCount, + duration: Durations.staggeredAnimation, + delay: Durations.staggeredAnimationDelay, + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), + ), + ); + } - Map> getSections(); + bool get showHeaders; + + Map> get sections; double getHeaderExtent(BuildContext context, SectionKey sectionKey); @@ -112,7 +144,7 @@ class SectionedListLayout { final Map> sections; final bool showHeaders; final int columnCount; - final double tileExtent; + final double tileExtent, spacing; final List sectionLayouts; const SectionedListLayout({ @@ -120,28 +152,29 @@ class SectionedListLayout { @required this.showHeaders, @required this.columnCount, @required this.tileExtent, + @required this.spacing, @required this.sectionLayouts, }); - Rect getTileRect(T entry) { - final section = sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null); + Rect getTileRect(T item) { + final section = sections.entries.firstWhere((kv) => kv.value.contains(item), orElse: () => null); if (section == null) return null; final sectionKey = section.key; final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null); if (sectionLayout == null) return null; - final sectionEntryIndex = section.value.indexOf(entry); - final column = sectionEntryIndex % columnCount; - final row = (sectionEntryIndex / columnCount).floor(); + final sectionItemIndex = section.value.indexOf(item); + final column = sectionItemIndex % columnCount; + final row = (sectionItemIndex / columnCount).floor(); final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row; - final left = tileExtent * column; + final left = tileExtent * column + spacing * (column - 1); final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } - T getEntryAt(Offset position) { + T getItemAt(Offset position) { var dy = position.dy; final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null); if (sectionLayout == null) return null; @@ -152,8 +185,8 @@ class SectionedListLayout { dy -= sectionLayout.minOffset + sectionLayout.headerExtent; if (dy < 0) return null; - final row = dy ~/ tileExtent; - final column = position.dx ~/ tileExtent; + final row = dy ~/ (tileExtent + spacing); + final column = position.dx ~/ (tileExtent + spacing); final index = row * columnCount + column; if (index >= section.length) return null; @@ -163,9 +196,9 @@ class SectionedListLayout { class SectionLayout { final SectionKey sectionKey; - final int firstIndex, lastIndex; - final double minOffset, maxOffset; - final double headerExtent, tileExtent; + final int firstIndex, lastIndex, bodyFirstIndex; + final double minOffset, maxOffset, bodyMinOffset; + final double headerExtent, tileExtent, spacing, mainAxisStride; final IndexedWidgetBuilder builder; const SectionLayout({ @@ -176,31 +209,34 @@ class SectionLayout { @required this.maxOffset, @required this.headerExtent, @required this.tileExtent, + @required this.spacing, @required this.builder, - }); + }) : bodyFirstIndex = firstIndex + 1, + bodyMinOffset = minOffset + headerExtent, + mainAxisStride = tileExtent + spacing; bool hasChild(int index) => firstIndex <= index && index <= lastIndex; bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset; double indexToLayoutOffset(int index) { - return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent); - } - - double indexToMaxScrollOffset(int index) { - return minOffset + headerExtent + (index - firstIndex) * tileExtent; + index -= bodyFirstIndex; + if (index < 0) return minOffset; + return bodyMinOffset + index * mainAxisStride; } int getMinChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= minOffset + headerExtent; - return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor()); + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + return bodyFirstIndex + scrollOffset ~/ mainAxisStride; } int getMaxChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= minOffset + headerExtent; - return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1); + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; } @override - String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}'; + String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}'; } diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index e0ab4ebe0..d5effa5f8 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -146,7 +146,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { if (firstChild != null) { final leadingGarbage = _calculateLeadingGarbage(firstIndex); - final trailingGarbage = _calculateTrailingGarbage(targetLastIndex); + final trailingGarbage = targetLastIndex != null ? _calculateTrailingGarbage(targetLastIndex) : 0; collectGarbage(leadingGarbage, trailingGarbage); } else { collectGarbage(0, 0); @@ -191,7 +191,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { // Reset the scroll offset to offset all items prior and up to the // missing item. Let parent re-layout everything. final layout = sectionAtIndex(index) ?? sectionLayouts.first; - geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToMaxScrollOffset(index)); + geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToLayoutOffset(index)); return; } final childParentData = child.parentData as SliverMultiBoxAdaptorParentData; @@ -215,7 +215,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { if (child == null) { // We have run out of children. final layout = sectionAtIndex(index) ?? sectionLayouts.last; - estimatedMaxScrollOffset = layout.indexToMaxScrollOffset(index); + estimatedMaxScrollOffset = layout.indexToLayoutOffset(index); break; } } else { @@ -250,7 +250,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { final paintExtent = calculatePaintOffset( constraints, - from: leadingScrollOffset, + from: math.min(constraints.scrollOffset, leadingScrollOffset), to: trailingScrollOffset, ); diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 965d9841e..6b33d36d9 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -49,11 +49,12 @@ class _AlbumPickPageState extends State { return FilterGridPage( source: source, appBar: appBar, - filterEntries: AlbumListPage.getAlbumEntries(source), + filterSections: AlbumListPage.getAlbumEntries(source), + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, applyQuery: (filters, query) { if (query == null || query.isEmpty) return filters; query = query.toUpperCase(); - return filters.where((filter) => filter.uniqueName.toUpperCase().contains(query)).toList(); + return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList(); }, queryNotifier: _queryNotifier, emptyBuilder: () => EmptyContent( diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 4c78eac83..a50328f3f 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -12,6 +11,7 @@ import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -26,8 +26,8 @@ class AlbumListPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector>>( - selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters), + return Selector>>( + selector: (context, s) => Tuple3(s.albumGroupFactor, s.albumSortFactor, s.pinnedFilters), builder: (context, s, child) { return AnimatedBuilder( animation: androidFileUtils.appNameChangeNotifier, @@ -36,6 +36,8 @@ class AlbumListPage extends StatelessWidget { builder: (context, snapshot) => FilterNavigationPage( source: source, title: 'Albums', + groupable: true, + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, chipSetActionDelegate: AlbumChipSetActionDelegate(source: source), chipActionDelegate: AlbumChipActionDelegate(source: source), chipActionsBuilder: (filter) => [ @@ -43,7 +45,7 @@ class AlbumListPage extends StatelessWidget { ChipAction.rename, ChipAction.delete, ], - filterEntries: getAlbumEntries(source), + filterSections: getAlbumEntries(source), emptyBuilder: () => EmptyContent( icon: AIcons.album, text: 'No albums', @@ -57,61 +59,61 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static Map getAlbumEntries(CollectionSource source) { - final pinned = settings.pinnedFilters.whereType(); - final entriesByDate = source.sortedEntriesForFilterList; - - AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album)); - + static Map>> getAlbumEntries(CollectionSource source) { // albums are initially sorted by name at the source level - var sortedFilters = source.sortedAlbums.map(_buildFilter); + final filters = source.sortedAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album))); - if (settings.albumSortFactor == ChipSortFactor.name) { - final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; - for (var filter in sortedFilters) { - if (pinned.contains(filter)) { - pinnedAlbums.add(filter); - } else { - switch (androidFileUtils.getAlbumType(filter.album)) { - case AlbumType.regular: - regularAlbums.add(filter); - break; - case AlbumType.app: - appAlbums.add(filter); - break; - default: - specialAlbums.add(filter); - break; - } - } - } - return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((filter) { - return MapEntry( - filter, - entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null), - ); - })); - } + final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); + return _group(sorted); + } - if (settings.albumSortFactor == ChipSortFactor.count) { - final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); - filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); - } - - final allMapEntries = sortedFilters.map((filter) => MapEntry( - filter, - entriesByDate.firstWhere((entry) => entry.directory == filter.album, orElse: () => null), - )); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); - if (settings.albumSortFactor == ChipSortFactor.date) { - pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); + var sections = >>{}; + switch (settings.albumGroupFactor) { + case AlbumChipGroupFactor.importance: + sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { + switch (androidFileUtils.getAlbumType(kv.filter.album)) { + case AlbumType.regular: + return AlbumImportanceSectionKey.regular; + case AlbumType.app: + return AlbumImportanceSectionKey.apps; + default: + return AlbumImportanceSectionKey.special; + } + }); + sections = { + AlbumImportanceSectionKey.special: sections[AlbumImportanceSectionKey.special], + AlbumImportanceSectionKey.apps: sections[AlbumImportanceSectionKey.apps], + AlbumImportanceSectionKey.regular: sections[AlbumImportanceSectionKey.regular], + }..removeWhere((key, value) => value == null); + break; + case AlbumChipGroupFactor.volume: + sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { + return StorageVolumeSectionKey(androidFileUtils.getStorageVolume(kv.filter.album)); + }); + break; + case AlbumChipGroupFactor.none: + return { + if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty) + ChipSectionKey(): [ + ...pinnedMapEntries, + ...unpinnedMapEntries, + ], + }; } - return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); + if (pinnedMapEntries.isNotEmpty) { + sections = Map.fromEntries([ + MapEntry(AlbumImportanceSectionKey.pinned, pinnedMapEntries), + ...sections.entries, + ]); + } + + return sections; } } diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 4e2db7de1..08e9e32d0 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -24,6 +24,8 @@ abstract class ChipSetActionDelegate { case ChipSetAction.stats: _goToStats(context); break; + default: + break; } } @@ -71,6 +73,36 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { @override set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; + + @override + void onActionSelected(BuildContext context, ChipSetAction action) { + switch (action) { + case ChipSetAction.group: + _showGroupDialog(context); + break; + default: + break; + } + super.onActionSelected(context, action); + } + + Future _showGroupDialog(BuildContext context) async { + final factor = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: settings.albumGroupFactor, + options: { + AlbumChipGroupFactor.importance: 'By importance', + AlbumChipGroupFactor.volume: 'By storage volume', + AlbumChipGroupFactor.none: 'Do not group', + }, + title: 'Group', + ), + ); + if (factor != null) { + settings.albumGroupFactor = factor; + } + } } class CountryChipSetActionDelegate extends ChipSetActionDelegate { diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index 17ff41cdd..5842967ee 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -78,6 +78,12 @@ class DecoratedFilterChip extends StatelessWidget { ], ); + child = SizedBox( + width: extent, + height: extent, + child: child, + ); + return child; } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index c5d28b40e..b03997e9b 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -2,13 +2,13 @@ import 'dart:ui'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/gesture_area_protector.dart'; +import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; @@ -17,6 +17,8 @@ import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/tile_extent_manager.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; +import 'package:aves/widgets/filter_grids/common/section_layout.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -26,11 +28,12 @@ import 'package:provider/provider.dart'; class FilterGridPage extends StatelessWidget { final CollectionSource source; final Widget appBar; - final Map filterEntries; + final Map>> filterSections; + final bool showHeaders; final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; final String settingsRouteKey; - final Iterable Function(Iterable filters, String query) applyQuery; + final Iterable> Function(Iterable> filters, String query) applyQuery; final FilterCallback onTap; final OffsetFilterCallback onLongPress; @@ -46,7 +49,8 @@ class FilterGridPage extends StatelessWidget { Key key, @required this.source, @required this.appBar, - @required this.filterEntries, + @required this.filterSections, + this.showHeaders = false, @required this.queryNotifier, this.applyQuery, @required this.emptyBuilder, @@ -82,63 +86,90 @@ class FilterGridPage extends StatelessWidget { spacing: spacing, )..applyTileExtent(viewportSize: viewportSize); - return ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) { - final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent); + final pinnedFilters = settings.pinnedFilters; + return ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, child) { + Map>> visibleFilterSections; + if (applyQuery == null) { + visibleFilterSections = filterSections; + } else { + visibleFilterSections = {}; + filterSections.forEach((sectionKey, sectionFilters) { + final visibleFilters = applyQuery(sectionFilters, query); + if (visibleFilters.isNotEmpty) { + visibleFilterSections[sectionKey] = visibleFilters.toList(); + } + }); + } - return ValueListenableBuilder( - valueListenable: queryNotifier, - builder: (context, query, child) { - final allFilters = filterEntries.keys; - final visibleFilters = (applyQuery != null ? applyQuery(allFilters, query) : allFilters).toList(); + final scrollView = AnimationLimiter( + child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)), + ); - final scrollView = AnimationLimiter( - child: _buildDraggableScrollView(_buildScrollView(context, columnCount, visibleFilters)), - ); - - return GridScaleGestureDetector( - tileExtentManager: tileExtentManager, - scrollableKey: _scrollableKey, - appBarHeightNotifier: _appBarHeightNotifier, - viewportSize: viewportSize, - gridBuilder: (center, extent, child) => CustomPaint( - painter: GridPainter( - center: center, - extent: extent, - spacing: tileExtentManager.spacing, - color: Colors.grey.shade700, - ), - child: child, - ), - scaledBuilder: (item, extent) { - final filter = item.filter; - return SizedBox( - width: extent, - height: extent, - child: DecoratedFilterChip( - source: source, - filter: filter, - entry: item.entry, - extent: extent, - pinned: settings.pinnedFilters.contains(filter), - highlightable: false, - ), - ); - }, - getScaledItemTileRect: (context, item) { - final index = visibleFilters.indexOf(item.filter); - final column = index % columnCount; - final row = (index / columnCount).floor(); - final left = tileExtent * column + spacing * (column - 1); - final top = tileExtent * row + spacing * (row - 1); - return Rect.fromLTWH(left, top, tileExtent, tileExtent); - }, - onScaled: (item) => Provider.of(context, listen: false).add(item.filter), - child: scrollView, + final scaler = GridScaleGestureDetector>( + tileExtentManager: tileExtentManager, + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + viewportSize: viewportSize, + gridBuilder: (center, extent, child) => CustomPaint( + painter: GridPainter( + center: center, + extent: extent, + spacing: tileExtentManager.spacing, + color: Colors.grey.shade700, + ), + child: child, + ), + scaledBuilder: (item, extent) { + final filter = item.filter; + return DecoratedFilterChip( + source: source, + filter: filter, + entry: item.entry, + extent: extent, + pinned: pinnedFilters.contains(filter), + highlightable: false, ); }, + getScaledItemTileRect: (context, item) { + final sectionedListLayout = context.read>>(); + return sectionedListLayout.getTileRect(item) ?? Rect.zero; + }, + onScaled: (item) => Provider.of(context, listen: false).add(item.filter), + child: scrollView, ); + + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: _tileExtentNotifier, + builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider( + sections: visibleFilterSections, + showHeaders: showHeaders, + scrollableWidth: viewportSize.width, + tileExtent: tileExtent, + columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), + spacing: spacing, + tileBuilder: (gridItem) { + final filter = gridItem.filter; + final entry = gridItem.entry; + return MetaData( + metaData: ScalerMetadata(FilterGridItem(filter, entry)), + child: DecoratedFilterChip( + key: Key(filter.key), + source: source, + filter: filter, + entry: entry, + extent: _tileExtentNotifier.value, + pinned: pinnedFilters.contains(filter), + onTap: onTap, + onLongPress: onLongPress, + ), + ); + }, + child: scaler, + ), + ); + return sectionedListLayoutProvider; }, ); }, @@ -176,81 +207,42 @@ class FilterGridPage extends StatelessWidget { ); } - ScrollView _buildScrollView(BuildContext context, int columnCount, List visibleFilters) { - final pinnedFilters = settings.pinnedFilters; + ScrollView _buildScrollView(BuildContext context, bool empty) { + Widget content; + if (empty) { + content = SliverFillRemaining( + child: Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) { + return Padding( + padding: EdgeInsets.only(bottom: mqViewInsetsBottom), + child: emptyBuilder(), + ); + }, + ), + hasScrollBody: false, + ); + } else { + content = SectionedListSliver>(); + } + + final padding = SliverToBoxAdapter( + child: Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) { + return SizedBox(height: mqViewInsetsBottom); + }, + ), + ); + return CustomScrollView( key: _scrollableKey, controller: PrimaryScrollController.of(context), slivers: [ appBar, - visibleFilters.isEmpty - ? SliverFillRemaining( - child: Selector( - selector: (context, mq) => mq.viewInsets.bottom, - builder: (context, mqViewInsetsBottom, child) { - return Padding( - padding: EdgeInsets.only(bottom: mqViewInsetsBottom), - child: emptyBuilder(), - ); - }, - ), - hasScrollBody: false, - ) - : SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, i) { - final filter = visibleFilters[i]; - final entry = filterEntries[filter]; - final child = MetaData( - metaData: ScalerMetadata(FilterGridItem(filter, entry)), - child: DecoratedFilterChip( - key: Key(filter.key), - source: source, - filter: filter, - entry: entry, - extent: _tileExtentNotifier.value, - pinned: pinnedFilters.contains(filter), - onTap: onTap, - onLongPress: onLongPress, - ), - ); - return AnimationConfiguration.staggeredGrid( - position: i, - columnCount: columnCount, - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), - ), - ); - }, - childCount: visibleFilters.length, - ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columnCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - ), - ), - SliverToBoxAdapter( - child: Selector( - selector: (context, mq) => mq.viewInsets.bottom, - builder: (context, mqViewInsetsBottom, child) { - return SizedBox(height: mqViewInsetsBottom); - }, - ), - ), + content, + padding, ], ); } } - -class FilterGridItem { - final T filter; - final ImageEntry entry; - - const FilterGridItem(this.filter, this.entry); -} diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 525a5bc5d..64f3a5656 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -3,10 +3,10 @@ import 'dart:ui'; import 'package:aves/main.dart'; import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -16,6 +16,7 @@ import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; @@ -26,18 +27,21 @@ class FilterNavigationPage extends StatelessWidget { final CollectionSource source; final String title; final ChipSetActionDelegate chipSetActionDelegate; + final bool groupable, showHeaders; final ChipActionDelegate chipActionDelegate; - final Map filterEntries; - final Widget Function() emptyBuilder; final List Function(T filter) chipActionsBuilder; + final Map>> filterSections; + final Widget Function() emptyBuilder; const FilterNavigationPage({ @required this.source, @required this.title, + this.groupable = false, + this.showHeaders = false, @required this.chipSetActionDelegate, @required this.chipActionDelegate, @required this.chipActionsBuilder, - @required this.filterEntries, + @required this.filterSections, @required this.emptyBuilder, }); @@ -58,7 +62,8 @@ class FilterNavigationPage extends StatelessWidget { titleSpacing: 0, floating: true, ), - filterEntries: filterEntries, + filterSections: filterSections, + showHeaders: showHeaders, queryNotifier: ValueNotifier(''), emptyBuilder: () => ValueListenableBuilder( valueListenable: source.stateNotifier, @@ -114,6 +119,11 @@ class FilterNavigationPage extends StatelessWidget { value: ChipSetAction.sort, child: MenuRow(text: 'Sort…', icon: AIcons.sort), ), + if (groupable) + PopupMenuItem( + value: ChipSetAction.group, + child: MenuRow(text: 'Group…', icon: AIcons.group), + ), if (kDebugMode) PopupMenuItem( value: ChipSetAction.refresh, @@ -143,13 +153,40 @@ class FilterNavigationPage extends StatelessWidget { )); } - static int compareChipsByDate(MapEntry a, MapEntry b) { - final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; - return c != 0 ? c : a.key.compareTo(b.key); + static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { + final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1; + return c != 0 ? c : a.filter.compareTo(b.filter); } - static int compareChipsByEntryCount(MapEntry a, MapEntry b) { + static int compareFiltersByEntryCount(MapEntry a, MapEntry b) { final c = b.value.compareTo(a.value) ?? -1; return c != 0 ? c : a.key.compareTo(b.key); } + + static Iterable> sort(ChipSortFactor sortFactor, CollectionSource source, Iterable filters) { + Iterable> toGridItem(CollectionSource source, Iterable filters) { + final entriesByDate = source.sortedEntriesForFilterList; + return filters.map((filter) => FilterGridItem( + filter, + entriesByDate.firstWhere(filter.filter, orElse: () => null), + )); + } + + Iterable> allMapEntries; + switch (sortFactor) { + case ChipSortFactor.name: + allMapEntries = toGridItem(source, filters); + break; + case ChipSortFactor.date: + allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate); + break; + 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(); + allMapEntries = toGridItem(source, filters); + break; + } + return allMapEntries; + } } diff --git a/lib/widgets/filter_grids/common/section_header.dart b/lib/widgets/filter_grids/common/section_header.dart new file mode 100644 index 000000000..90dab210f --- /dev/null +++ b/lib/widgets/filter_grids/common/section_header.dart @@ -0,0 +1,27 @@ +import 'package:aves/widgets/common/grid/header.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; +import 'package:flutter/material.dart'; + +class FilterChipSectionHeader extends StatelessWidget { + final ChipSectionKey sectionKey; + + const FilterChipSectionHeader({ + Key key, + @required this.sectionKey, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SectionHeader( + sectionKey: sectionKey, + leading: sectionKey.leading, + title: sectionKey.title, + selectable: false, + ); + } + + static double getPreferredHeight(BuildContext context) { + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + return SectionHeader.leadingDimension * textScaleFactor + SectionHeader.padding.vertical; + } +} diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart new file mode 100644 index 000000000..9828bf51b --- /dev/null +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -0,0 +1,82 @@ +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ChipSectionKey extends SectionKey { + final String title; + + const ChipSectionKey({ + this.title = '', + }); + + Widget get leading => null; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is ChipSectionKey && other.title == title; + } + + @override + int get hashCode => title.hashCode; + + @override + String toString() => '$runtimeType#${shortHash(this)}{title=$title}'; +} + +class AlbumImportanceSectionKey extends ChipSectionKey { + final AlbumImportance importance; + + AlbumImportanceSectionKey._private(this.importance) : super(title: importance.getText()); + + static AlbumImportanceSectionKey pinned = AlbumImportanceSectionKey._private(AlbumImportance.pinned); + static AlbumImportanceSectionKey special = AlbumImportanceSectionKey._private(AlbumImportance.special); + static AlbumImportanceSectionKey apps = AlbumImportanceSectionKey._private(AlbumImportance.apps); + static AlbumImportanceSectionKey regular = AlbumImportanceSectionKey._private(AlbumImportance.regular); + + @override + Widget get leading => Icon(importance.getIcon()); +} + +enum AlbumImportance { pinned, special, apps, regular } + +extension ExtraAlbumImportance on AlbumImportance { + String getText() { + switch (this) { + case AlbumImportance.pinned: + return 'Pinned'; + case AlbumImportance.special: + return 'Common'; + case AlbumImportance.apps: + return 'Apps'; + case AlbumImportance.regular: + return 'Others'; + } + return null; + } + + IconData getIcon() { + switch (this) { + case AlbumImportance.pinned: + return AIcons.pin; + case AlbumImportance.special: + return Icons.label_important_outline; + case AlbumImportance.apps: + return Icons.apps_outlined; + case AlbumImportance.regular: + return AIcons.album; + } + return null; + } +} + +class StorageVolumeSectionKey extends ChipSectionKey { + final StorageVolume volume; + + StorageVolumeSectionKey(this.volume) : super(title: volume.description); + + @override + Widget get leading => volume.isRemovable ? Icon(AIcons.removableStorage) : null; +} diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart new file mode 100644 index 000000000..4c1d690a8 --- /dev/null +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -0,0 +1,44 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/filter_grids/common/section_header.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class SectionedFilterListLayoutProvider extends SectionedListLayoutProvider> { + const SectionedFilterListLayoutProvider({ + @required this.sections, + @required this.showHeaders, + @required double scrollableWidth, + @required int columnCount, + double spacing = 0, + @required double tileExtent, + @required Widget Function(FilterGridItem gridItem) tileBuilder, + @required Widget child, + }) : super( + scrollableWidth: scrollableWidth, + columnCount: columnCount, + spacing: spacing, + tileExtent: tileExtent, + tileBuilder: tileBuilder, + child: child, + ); + + @override + final Map>> sections; + + @override + final bool showHeaders; + + @override + double getHeaderExtent(BuildContext context, SectionKey sectionKey) { + return FilterChipSectionHeader.getPreferredHeight(context); + } + + @override + Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) { + return FilterChipSectionHeader( + sectionKey: sectionKey, + ); + } +} diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 5f3dd6ce4..64da80407 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -11,6 +10,7 @@ import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -30,7 +30,7 @@ class CountryListPage extends StatelessWidget { builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( + builder: (context, snapshot) => FilterNavigationPage( source: source, title: 'Countries', chipSetActionDelegate: CountryChipSetActionDelegate(source: source), @@ -38,7 +38,7 @@ class CountryListPage extends StatelessWidget { chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], - filterEntries: _getCountryEntries(), + filterSections: _getCountryEntries(), emptyBuilder: () => EmptyContent( icon: AIcons.location, text: 'No countries', @@ -49,37 +49,26 @@ class CountryListPage extends StatelessWidget { ); } - Map _getCountryEntries() { - final pinned = settings.pinnedFilters.whereType(); - - final entriesByDate = source.sortedEntriesForFilterList; + Map>> _getCountryEntries() { // countries are initially sorted by name at the source level - var sortedFilters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)); - if (settings.countrySortFactor == ChipSortFactor.count) { - final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); - filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); - } + final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)); - final locatedEntries = entriesByDate.where((entry) => entry.isLocated); - final allMapEntries = sortedFilters.map((filter) { - final split = filter.countryNameAndCode.split(LocationFilter.locationSeparator); - ImageEntry entry; - if (split.length > 1) { - final countryCode = split[1]; - entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null); - } - return MapEntry(filter, entry); - }); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); + return _group(sorted); + } + + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); - if (settings.countrySortFactor == ChipSortFactor.date) { - pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - } - - return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); + return { + if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty) + ChipSectionKey(): [ + ...pinnedMapEntries, + ...unpinnedMapEntries, + ], + }; } } diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 7d29f6f7c..122a2c4b9 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -11,6 +10,7 @@ import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; +import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -30,7 +30,7 @@ class TagListPage extends StatelessWidget { builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( + builder: (context, snapshot) => FilterNavigationPage( source: source, title: 'Tags', chipSetActionDelegate: TagChipSetActionDelegate(source: source), @@ -38,7 +38,7 @@ class TagListPage extends StatelessWidget { chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], - filterEntries: _getTagEntries(), + filterSections: _getTagEntries(), emptyBuilder: () => EmptyContent( icon: AIcons.tag, text: 'No tags', @@ -49,31 +49,26 @@ class TagListPage extends StatelessWidget { ); } - Map _getTagEntries() { - final pinned = settings.pinnedFilters.whereType(); - - final entriesByDate = source.sortedEntriesForFilterList; + Map>> _getTagEntries() { // tags are initially sorted by name at the source level - var sortedFilters = source.sortedTags.map((tag) => TagFilter(tag)); - if (settings.tagSortFactor == ChipSortFactor.count) { - final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter)))); - filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); - sortedFilters = filtersWithCount.map((kv) => kv.key).toList(); - } + final filters = source.sortedTags.map((tag) => TagFilter(tag)); - final allMapEntries = sortedFilters.map((filter) => MapEntry( - filter, - entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(filter.tag), orElse: () => null), - )); - final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); + return _group(sorted); + } + + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); - if (settings.tagSortFactor == ChipSortFactor.date) { - pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - } - - return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); + return { + if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty) + ChipSectionKey(): [ + ...pinnedMapEntries, + ...unpinnedMapEntries, + ], + }; } } diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 5ba8cce42..9863c3793 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -88,7 +88,7 @@ class _ViewerBottomOverlayState extends State { final availableWidth = mqWidth - viewPadding.horizontal; return Container( - color: hasEdgeContent ? kOverlayBackgroundColor: Colors.transparent, + color: hasEdgeContent ? kOverlayBackgroundColor : Colors.transparent, padding: viewInsets + viewPadding.copyWith(top: 0), child: FutureBuilder( future: _detailLoader, diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index e56ea9eaf..a99b8c64f 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -186,6 +186,7 @@ class _WelcomePageState extends State { child = AnimationConfiguration.staggeredList( position: index, duration: duration, + delay: delay, child: childAnimationBuilder(child), ); child = widget is Flexible ? Flexible(child: child) : child;