#41 albums: group by importance/volume/none

This commit is contained in:
Thibault Deckers 2021-01-17 15:17:26 +09:00
parent f952deff15
commit cf2961c03a
38 changed files with 640 additions and 353 deletions

View file

@ -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"
}
}

View file

@ -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) }
}
}
}

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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())
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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? {

View file

@ -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

View file

@ -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

View file

@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
refresh,
stats,

View file

@ -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);
}

View file

@ -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());

View file

@ -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);

View file

@ -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 }

View file

@ -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

View file

@ -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,

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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>(

View file

@ -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);
}

View file

@ -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}';
}

View file

@ -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,
);

View file

@ -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(

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -78,6 +78,12 @@ class DecoratedFilterChip extends StatelessWidget {
],
);
child = SizedBox(
width: extent,
height: extent,
child: child,
);
return child;
}

View file

@ -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);
}

View file

@ -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;
}
}

View 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;
}
}

View 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;
}

View 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,
);
}
}

View file

@ -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,
],
};
}
}

View file

@ -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,
],
};
}
}

View file

@ -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,

View file

@ -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;