#41 albums: group by importance/volume/none
This commit is contained in:
parent
f952deff15
commit
cf2961c03a
38 changed files with 640 additions and 353 deletions
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,13 +2,17 @@ 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 {
|
||||
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
|
||||
|
@ -19,6 +23,10 @@ object BitmapUtils {
|
|||
}
|
||||
if (recycle) this.recycle()
|
||||
return stream.toByteArray()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum ChipSetAction {
|
||||
group,
|
||||
sort,
|
||||
refresh,
|
||||
stats,
|
||||
|
|
|
@ -74,3 +74,20 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
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<T extends CollectionFilter> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -139,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
case EntrySortFactor.date:
|
||||
switch (groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
sections = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
||||
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
break;
|
||||
case EntryGroupFactor.month:
|
||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken));
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||
break;
|
||||
case EntryGroupFactor.day:
|
||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.dayTaken));
|
||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_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<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<AlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||
break;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -25,10 +25,10 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
|||
);
|
||||
|
||||
@override
|
||||
bool needHeaders() => collection.showHeaders;
|
||||
bool get showHeaders => collection.showHeaders;
|
||||
|
||||
@override
|
||||
Map<SectionKey, List<ImageEntry>> getSections() => collection.sections;
|
||||
Map<SectionKey, List<ImageEntry>> get sections => collection.sections;
|
||||
|
||||
@override
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||
|
|
|
@ -29,7 +29,7 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||
bool _pressing, _selecting;
|
||||
bool _pressing = false, _selecting;
|
||||
int _fromIndex, _lastToIndex;
|
||||
Offset _localPosition;
|
||||
EdgeInsets _scrollableInsets;
|
||||
|
@ -136,7 +136,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
// so we use custom layout computation instead to find the entry.
|
||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||
return sectionedListLayout.getEntryAt(offset);
|
||||
return sectionedListLayout.getItemAt(offset);
|
||||
}
|
||||
|
||||
void _toggleSelectionToIndex(int toIndex) {
|
||||
|
|
|
@ -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,7 +64,8 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
// so that view updates on collection filter changes
|
||||
return Consumer<CollectionLens>(
|
||||
builder: (context, collection, child) {
|
||||
final scrollView = CollectionScrollView(
|
||||
final scrollView = AnimationLimiter(
|
||||
child: CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
|
@ -74,6 +76,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
cacheExtent: cacheExtent,
|
||||
),
|
||||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||
|
|
|
@ -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<CollectionLens>(context);
|
||||
return ValueListenableBuilder<Activity>(
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<T> 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<T> extends StatelessWidget {
|
|||
}
|
||||
|
||||
SectionedListLayout<T> _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 = <SectionLayout>[];
|
||||
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<T> 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<T> extends StatelessWidget {
|
|||
);
|
||||
});
|
||||
return SectionedListLayout<T>(
|
||||
sections: sections,
|
||||
showHeaders: showHeaders,
|
||||
sections: _sections,
|
||||
showHeaders: _showHeaders,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
spacing: spacing,
|
||||
sectionLayouts: sectionLayouts,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInSection(BuildContext context, List<T> section, int sectionChildIndex, SectionKey sectionKey, double headerExtent) {
|
||||
Widget _buildInSection(
|
||||
BuildContext context,
|
||||
List<T> 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 = <Widget>[];
|
||||
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<T> 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<SectionKey, List<T>> getSections();
|
||||
bool get showHeaders;
|
||||
|
||||
Map<SectionKey, List<T>> get sections;
|
||||
|
||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey);
|
||||
|
||||
|
@ -112,7 +144,7 @@ class SectionedListLayout<T> {
|
|||
final Map<SectionKey, List<T>> sections;
|
||||
final bool showHeaders;
|
||||
final int columnCount;
|
||||
final double tileExtent;
|
||||
final double tileExtent, spacing;
|
||||
final List<SectionLayout> sectionLayouts;
|
||||
|
||||
const SectionedListLayout({
|
||||
|
@ -120,28 +152,29 @@ class SectionedListLayout<T> {
|
|||
@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<T> {
|
|||
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<T> {
|
|||
|
||||
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}';
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
@ -49,11 +49,12 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
|||
return FilterGridPage<AlbumFilter>(
|
||||
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(
|
||||
|
|
|
@ -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<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
||||
selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
|
||||
return Selector<Settings, Tuple3<AlbumChipGroupFactor, ChipSortFactor, Set<CollectionFilter>>>(
|
||||
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<AlbumFilter>(
|
||||
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<AlbumFilter, ImageEntry> getAlbumEntries(CollectionSource source) {
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
|
||||
AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album));
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> 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 = <AlbumFilter>[], regularAlbums = <AlbumFilter>[], appAlbums = <AlbumFilter>[], specialAlbums = <AlbumFilter>[];
|
||||
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<MapEntry<AlbumFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||
final byPin = groupBy<FilterGridItem<AlbumFilter>, 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 = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
|
||||
switch (settings.albumGroupFactor) {
|
||||
case AlbumChipGroupFactor.importance:
|
||||
sections = groupBy<FilterGridItem<AlbumFilter>, 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<FilterGridItem<AlbumFilter>, 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<void> _showGroupDialog(BuildContext context) async {
|
||||
final factor = await showDialog<AlbumChipGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
|
||||
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 {
|
||||
|
|
|
@ -78,6 +78,12 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
],
|
||||
);
|
||||
|
||||
child = SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: child,
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<T extends CollectionFilter> extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final Widget appBar;
|
||||
final Map<T, ImageEntry> filterEntries;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final bool showHeaders;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final Widget Function() emptyBuilder;
|
||||
final String settingsRouteKey;
|
||||
final Iterable<T> Function(Iterable<T> filters, String query) applyQuery;
|
||||
final Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query) applyQuery;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
||||
|
@ -46,7 +49,8 @@ class FilterGridPage<T extends CollectionFilter> 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,22 +86,28 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) {
|
||||
final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent);
|
||||
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: queryNotifier,
|
||||
builder: (context, query, child) {
|
||||
final allFilters = filterEntries.keys;
|
||||
final visibleFilters = (applyQuery != null ? applyQuery(allFilters, query) : allFilters).toList();
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||
if (applyQuery == null) {
|
||||
visibleFilterSections = filterSections;
|
||||
} else {
|
||||
visibleFilterSections = {};
|
||||
filterSections.forEach((sectionKey, sectionFilters) {
|
||||
final visibleFilters = applyQuery(sectionFilters, query);
|
||||
if (visibleFilters.isNotEmpty) {
|
||||
visibleFilterSections[sectionKey] = visibleFilters.toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _buildDraggableScrollView(_buildScrollView(context, columnCount, visibleFilters)),
|
||||
child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)),
|
||||
);
|
||||
|
||||
return GridScaleGestureDetector<FilterGridItem>(
|
||||
final scaler = GridScaleGestureDetector<FilterGridItem<T>>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
|
@ -113,33 +123,54 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
),
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: DecoratedFilterChip(
|
||||
return DecoratedFilterChip(
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: item.entry,
|
||||
extent: extent,
|
||||
pinned: settings.pinnedFilters.contains(filter),
|
||||
pinned: 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);
|
||||
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
|
||||
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
|
||||
child: scrollView,
|
||||
);
|
||||
},
|
||||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider<T>(
|
||||
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<T>(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,15 +207,10 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
ScrollView _buildScrollView(BuildContext context, int columnCount, List<T> visibleFilters) {
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
return CustomScrollView(
|
||||
key: _scrollableKey,
|
||||
controller: PrimaryScrollController.of(context),
|
||||
slivers: [
|
||||
appBar,
|
||||
visibleFilters.isEmpty
|
||||
? SliverFillRemaining(
|
||||
ScrollView _buildScrollView(BuildContext context, bool empty) {
|
||||
Widget content;
|
||||
if (empty) {
|
||||
content = SliverFillRemaining(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
|
@ -195,62 +221,28 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
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(
|
||||
} else {
|
||||
content = SectionedListSliver<FilterGridItem<T>>();
|
||||
}
|
||||
|
||||
final padding = SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
builder: (context, mqViewInsetsBottom, child) {
|
||||
return SizedBox(height: mqViewInsetsBottom);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
key: _scrollableKey,
|
||||
controller: PrimaryScrollController.of(context),
|
||||
slivers: [
|
||||
appBar,
|
||||
content,
|
||||
padding,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridItem<T extends CollectionFilter> {
|
||||
final T filter;
|
||||
final ImageEntry entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
}
|
||||
|
|
|
@ -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<T extends CollectionFilter> extends StatelessWidget {
|
|||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final bool groupable, showHeaders;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final Map<T, ImageEntry> filterEntries;
|
||||
final Widget Function() emptyBuilder;
|
||||
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> 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<T extends CollectionFilter> extends StatelessWidget {
|
|||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: filterEntries,
|
||||
filterSections: filterSections,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: ValueNotifier(''),
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
|
@ -114,6 +119,11 @@ class FilterNavigationPage<T extends CollectionFilter> 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<T extends CollectionFilter> extends StatelessWidget {
|
|||
));
|
||||
}
|
||||
|
||||
static int compareChipsByDate(MapEntry<CollectionFilter, ImageEntry> a, MapEntry<CollectionFilter, ImageEntry> b) {
|
||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : a.key.compareTo(b.key);
|
||||
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
||||
final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1;
|
||||
return c != 0 ? c : a.filter.compareTo(b.filter);
|
||||
}
|
||||
|
||||
static int compareChipsByEntryCount(MapEntry<CollectionFilter, num> a, MapEntry<CollectionFilter, num> b) {
|
||||
static int compareFiltersByEntryCount(MapEntry<CollectionFilter, num> a, MapEntry<CollectionFilter, num> b) {
|
||||
final c = b.value.compareTo(a.value) ?? -1;
|
||||
return c != 0 ? c : a.key.compareTo(b.key);
|
||||
}
|
||||
|
||||
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Iterable<T> filters) {
|
||||
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Iterable<T> filters) {
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
return filters.map((filter) => FilterGridItem(
|
||||
filter,
|
||||
entriesByDate.firstWhere(filter.filter, orElse: () => null),
|
||||
));
|
||||
}
|
||||
|
||||
Iterable<FilterGridItem<T>> 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;
|
||||
}
|
||||
}
|
||||
|
|
27
lib/widgets/filter_grids/common/section_header.dart
Normal file
27
lib/widgets/filter_grids/common/section_header.dart
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
82
lib/widgets/filter_grids/common/section_keys.dart
Normal file
82
lib/widgets/filter_grids/common/section_keys.dart
Normal file
|
@ -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;
|
||||
}
|
44
lib/widgets/filter_grids/common/section_layout.dart
Normal file
44
lib/widgets/filter_grids/common/section_layout.dart
Normal file
|
@ -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<T extends CollectionFilter> extends SectionedListLayoutProvider<FilterGridItem<T>> {
|
||||
const SectionedFilterListLayoutProvider({
|
||||
@required this.sections,
|
||||
@required this.showHeaders,
|
||||
@required double scrollableWidth,
|
||||
@required int columnCount,
|
||||
double spacing = 0,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: spacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
child: child,
|
||||
);
|
||||
|
||||
@override
|
||||
final Map<SectionKey, List<FilterGridItem<T>>> 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<LocationsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
|
||||
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<LocationFilter, ImageEntry> _getCountryEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _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 sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
}
|
||||
|
||||
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<MapEntry<LocationFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||
final byPin = groupBy<FilterGridItem<LocationFilter>, 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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
|
||||
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<TagFilter, ImageEntry> _getTagEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _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 sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
}
|
||||
|
||||
final allMapEntries = sortedFilters.map((filter) => MapEntry(
|
||||
filter,
|
||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(filter.tag), orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<TagFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||
final byPin = groupBy<FilterGridItem<TagFilter>, 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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
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<OverlayMetadata>(
|
||||
future: _detailLoader,
|
||||
|
|
|
@ -186,6 +186,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
child = AnimationConfiguration.staggeredList(
|
||||
position: index,
|
||||
duration: duration,
|
||||
delay: delay,
|
||||
child: childAnimationBuilder(child),
|
||||
);
|
||||
child = widget is Flexible ? Flexible(child: child) : child;
|
||||
|
|
Loading…
Reference in a new issue