#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.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
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.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
@ -257,7 +257,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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"
|
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)
|
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
exif.thumbnailBitmap?.let { bitmap ->
|
exif.thumbnailBitmap?.let { bitmap ->
|
||||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
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) {}
|
override fun onCancel(o: Any) {}
|
||||||
|
|
||||||
private fun success(bytes: ByteArray) {
|
private fun success(bytes: ByteArray?) {
|
||||||
handler.post {
|
handler.post {
|
||||||
try {
|
try {
|
||||||
eventSink.success(bytes)
|
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.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
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 deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
@ -145,7 +145,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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"
|
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -83,7 +83,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("null bitmap"))
|
callback.onLoadFailed(Exception("null bitmap"))
|
||||||
} else {
|
} 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 com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.AvesImageEntry
|
import deckers.thibault.aves.model.AvesImageEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
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.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||||
|
@ -195,7 +195,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.AvesImageEntry
|
import deckers.thibault.aves.model.AvesImageEntry
|
||||||
import deckers.thibault.aves.model.SourceImageEntry
|
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
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
@ -312,7 +312,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
private val VIDEO_CONTENT_URI = MediaStore.Video.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.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
object BitmapUtils {
|
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()
|
val stream = ByteArrayOutputStream()
|
||||||
// we compress the bitmap because Flutter cannot decode the raw bytes
|
// we compress the bitmap because Flutter cannot decode the raw bytes
|
||||||
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
||||||
|
@ -19,6 +23,10 @@ object BitmapUtils {
|
||||||
}
|
}
|
||||||
if (recycle) this.recycle()
|
if (recycle) this.recycle()
|
||||||
return stream.toByteArray()
|
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? {
|
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.os.storage.StorageManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
object PermissionManager {
|
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
|
const val VOLUME_ACCESS_REQUEST_CODE = 1
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
|
||||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
@ -23,7 +22,7 @@ import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
object StorageUtils {
|
object StorageUtils {
|
||||||
private val LOG_TAG = createTag(StorageUtils::class.java)
|
private val LOG_TAG = LogUtils.createTag(StorageUtils::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volume paths
|
* Volume paths
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
enum ChipSetAction {
|
enum ChipSetAction {
|
||||||
|
group,
|
||||||
sort,
|
sort,
|
||||||
refresh,
|
refresh,
|
||||||
stats,
|
stats,
|
||||||
|
|
|
@ -74,3 +74,20 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
||||||
return c != 0 ? c : compareAsciiUpperCase(label, other.label);
|
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';
|
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||||
|
|
||||||
// filter grids
|
// filter grids
|
||||||
|
static const albumGroupFactorKey = 'album_group_factor';
|
||||||
static const albumSortFactorKey = 'album_sort_factor';
|
static const albumSortFactorKey = 'album_sort_factor';
|
||||||
static const countrySortFactorKey = 'country_sort_factor';
|
static const countrySortFactorKey = 'country_sort_factor';
|
||||||
static const tagSortFactorKey = 'tag_sort_factor';
|
static const tagSortFactorKey = 'tag_sort_factor';
|
||||||
|
@ -145,6 +146,10 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
// filter grids
|
// 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);
|
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values);
|
||||||
|
|
||||||
set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString());
|
set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString());
|
||||||
|
|
|
@ -139,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
case EntrySortFactor.date:
|
case EntrySortFactor.date:
|
||||||
switch (groupFactor) {
|
switch (groupFactor) {
|
||||||
case EntryGroupFactor.album:
|
case EntryGroupFactor.album:
|
||||||
sections = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.month:
|
case EntryGroupFactor.month:
|
||||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken));
|
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.day:
|
case EntryGroupFactor.day:
|
||||||
sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.dayTaken));
|
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.none:
|
case EntryGroupFactor.none:
|
||||||
sections = Map.fromEntries([
|
sections = Map.fromEntries([
|
||||||
|
@ -160,8 +160,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
case EntrySortFactor.name:
|
||||||
final byAlbum = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
|
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||||
sections = SplayTreeMap<AlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sections = Map.unmodifiable(sections);
|
sections = Map.unmodifiable(sections);
|
||||||
|
|
|
@ -2,6 +2,8 @@ enum Activity { browse, select }
|
||||||
|
|
||||||
enum ChipSortFactor { date, name, count }
|
enum ChipSortFactor { date, name, count }
|
||||||
|
|
||||||
|
enum AlbumChipGroupFactor { none, importance, volume }
|
||||||
|
|
||||||
enum EntrySortFactor { date, size, name }
|
enum EntrySortFactor { date, size, name }
|
||||||
|
|
||||||
enum EntryGroupFactor { none, album, month, day }
|
enum EntryGroupFactor { none, album, month, day }
|
||||||
|
|
|
@ -4,15 +4,15 @@ class SectionKey {
|
||||||
const SectionKey();
|
const SectionKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlbumSectionKey extends SectionKey {
|
class EntryAlbumSectionKey extends SectionKey {
|
||||||
final String folderPath;
|
final String folderPath;
|
||||||
|
|
||||||
const AlbumSectionKey(this.folderPath);
|
const EntryAlbumSectionKey(this.folderPath);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is AlbumSectionKey && other.folderPath == folderPath;
|
return other is EntryAlbumSectionKey && other.folderPath == folderPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -22,15 +22,15 @@ class AlbumSectionKey extends SectionKey {
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
|
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class DateSectionKey extends SectionKey {
|
class EntryDateSectionKey extends SectionKey {
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
||||||
const DateSectionKey(this.date);
|
const EntryDateSectionKey(this.date);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is DateSectionKey && other.date == date;
|
return other is EntryDateSectionKey && other.date == date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
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/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/grid/header.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:aves/widgets/common/identity/aves_icons.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SectionHeader(
|
return SectionHeader(
|
||||||
sectionKey: AlbumSectionKey(folderPath),
|
sectionKey: EntryAlbumSectionKey(folderPath),
|
||||||
leading: albumIcon,
|
leading: albumIcon,
|
||||||
title: albumName,
|
title: albumName,
|
||||||
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
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;
|
final folderPath = sectionKey.folderPath;
|
||||||
return SectionHeader.getPreferredHeight(
|
return SectionHeader.getPreferredHeight(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
||||||
Widget _buildAlbumHeader() => AlbumSectionHeader(
|
Widget _buildAlbumHeader() => AlbumSectionHeader(
|
||||||
key: ValueKey(sectionKey),
|
key: ValueKey(sectionKey),
|
||||||
source: collection.source,
|
source: collection.source,
|
||||||
folderPath: (sectionKey as AlbumSectionKey).folderPath,
|
folderPath: (sectionKey as EntryAlbumSectionKey).folderPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (collection.sortFactor) {
|
switch (collection.sortFactor) {
|
||||||
|
@ -45,9 +45,9 @@ class CollectionSectionHeader extends StatelessWidget {
|
||||||
case EntryGroupFactor.album:
|
case EntryGroupFactor.album:
|
||||||
return _buildAlbumHeader();
|
return _buildAlbumHeader();
|
||||||
case EntryGroupFactor.month:
|
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:
|
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:
|
case EntryGroupFactor.none:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
||||||
|
|
||||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) {
|
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) {
|
||||||
var headerExtent = 0.0;
|
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
|
// 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);
|
headerExtent = AlbumSectionHeader.getPreferredHeight(context, maxWidth, source, sectionKey);
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ class DaySectionHeader extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SectionHeader(
|
return SectionHeader(
|
||||||
sectionKey: DateSectionKey(date),
|
sectionKey: EntryDateSectionKey(date),
|
||||||
title: text,
|
title: text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ class MonthSectionHeader extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SectionHeader(
|
return SectionHeader(
|
||||||
sectionKey: DateSectionKey(date),
|
sectionKey: EntryDateSectionKey(date),
|
||||||
title: text,
|
title: text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,10 +25,10 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool needHeaders() => collection.showHeaders;
|
bool get showHeaders => collection.showHeaders;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<SectionKey, List<ImageEntry>> getSections() => collection.sections;
|
Map<SectionKey, List<ImageEntry>> get sections => collection.sections;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||||
|
|
|
@ -29,7 +29,7 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||||
bool _pressing, _selecting;
|
bool _pressing = false, _selecting;
|
||||||
int _fromIndex, _lastToIndex;
|
int _fromIndex, _lastToIndex;
|
||||||
Offset _localPosition;
|
Offset _localPosition;
|
||||||
EdgeInsets _scrollableInsets;
|
EdgeInsets _scrollableInsets;
|
||||||
|
@ -136,7 +136,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
||||||
// so we use custom layout computation instead to find the entry.
|
// so we use custom layout computation instead to find the entry.
|
||||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
||||||
return sectionedListLayout.getEntryAt(offset);
|
return sectionedListLayout.getItemAt(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleSelectionToIndex(int toIndex) {
|
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:draggable_scrollbar/draggable_scrollbar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ThumbnailCollection extends StatelessWidget {
|
class ThumbnailCollection extends StatelessWidget {
|
||||||
|
@ -63,7 +64,8 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
// so that view updates on collection filter changes
|
// so that view updates on collection filter changes
|
||||||
return Consumer<CollectionLens>(
|
return Consumer<CollectionLens>(
|
||||||
builder: (context, collection, child) {
|
builder: (context, collection, child) {
|
||||||
final scrollView = CollectionScrollView(
|
final scrollView = AnimationLimiter(
|
||||||
|
child: CollectionScrollView(
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: _scrollableKey,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
appBar: CollectionAppBar(
|
appBar: CollectionAppBar(
|
||||||
|
@ -74,6 +76,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
cacheExtent: cacheExtent,
|
cacheExtent: cacheExtent,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||||
|
|
|
@ -12,6 +12,7 @@ class SectionHeader extends StatelessWidget {
|
||||||
final SectionKey sectionKey;
|
final SectionKey sectionKey;
|
||||||
final Widget leading, trailing;
|
final Widget leading, trailing;
|
||||||
final String title;
|
final String title;
|
||||||
|
final bool selectable;
|
||||||
|
|
||||||
const SectionHeader({
|
const SectionHeader({
|
||||||
Key key,
|
Key key,
|
||||||
|
@ -19,6 +20,7 @@ class SectionHeader extends StatelessWidget {
|
||||||
this.leading,
|
this.leading,
|
||||||
@required this.title,
|
@required this.title,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.selectable = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
static const leadingDimension = 32.0;
|
static const leadingDimension = 32.0;
|
||||||
|
@ -41,6 +43,7 @@ class SectionHeader extends StatelessWidget {
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
alignment: widgetSpanAlignment,
|
alignment: widgetSpanAlignment,
|
||||||
child: _SectionSelectableLeading(
|
child: _SectionSelectableLeading(
|
||||||
|
selectable: selectable,
|
||||||
sectionKey: sectionKey,
|
sectionKey: sectionKey,
|
||||||
browsingBuilder: leading != null
|
browsingBuilder: leading != null
|
||||||
? (context) => Container(
|
? (context) => Container(
|
||||||
|
@ -118,12 +121,14 @@ class SectionHeader extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionSelectableLeading extends StatelessWidget {
|
class _SectionSelectableLeading extends StatelessWidget {
|
||||||
|
final bool selectable;
|
||||||
final SectionKey sectionKey;
|
final SectionKey sectionKey;
|
||||||
final WidgetBuilder browsingBuilder;
|
final WidgetBuilder browsingBuilder;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
const _SectionSelectableLeading({
|
const _SectionSelectableLeading({
|
||||||
Key key,
|
Key key,
|
||||||
|
this.selectable = true,
|
||||||
@required this.sectionKey,
|
@required this.sectionKey,
|
||||||
@required this.browsingBuilder,
|
@required this.browsingBuilder,
|
||||||
@required this.onPressed,
|
@required this.onPressed,
|
||||||
|
@ -133,6 +138,8 @@ class _SectionSelectableLeading extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (!selectable) return _buildBrowsing(context);
|
||||||
|
|
||||||
final collection = Provider.of<CollectionLens>(context);
|
final collection = Provider.of<CollectionLens>(context);
|
||||||
return ValueListenableBuilder<Activity>(
|
return ValueListenableBuilder<Activity>(
|
||||||
valueListenable: collection.activityNotifier,
|
valueListenable: collection.activityNotifier,
|
||||||
|
@ -173,7 +180,7 @@ class _SectionSelectableLeading extends StatelessWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension);
|
: _buildBrowsing(context);
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: Durations.sectionHeaderAnimation,
|
duration: Durations.sectionHeaderAnimation,
|
||||||
switchInCurve: Curves.easeInOut,
|
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 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/source/section_keys.dart';
|
import 'package:aves/model/source/section_keys.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
final double scrollableWidth;
|
final double scrollableWidth;
|
||||||
final int columnCount;
|
final int columnCount;
|
||||||
final double tileExtent;
|
final double spacing, tileExtent;
|
||||||
final Widget Function(T entry) tileBuilder;
|
final Widget Function(T item) tileBuilder;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const SectionedListLayoutProvider({
|
const SectionedListLayoutProvider({
|
||||||
@required this.scrollableWidth,
|
@required this.scrollableWidth,
|
||||||
@required this.columnCount,
|
@required this.columnCount,
|
||||||
|
this.spacing = 0,
|
||||||
@required this.tileExtent,
|
@required this.tileExtent,
|
||||||
@required this.tileBuilder,
|
@required this.tileBuilder,
|
||||||
@required this.child,
|
@required this.child,
|
||||||
|
@ -29,25 +32,26 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionedListLayout<T> _updateLayouts(BuildContext context) {
|
SectionedListLayout<T> _updateLayouts(BuildContext context) {
|
||||||
final showHeaders = needHeaders();
|
final _showHeaders = showHeaders;
|
||||||
final sections = getSections();
|
final _sections = sections;
|
||||||
final sectionKeys = sections.keys.toList();
|
final sectionKeys = _sections.keys.toList();
|
||||||
|
|
||||||
final sectionLayouts = <SectionLayout>[];
|
final sectionLayouts = <SectionLayout>[];
|
||||||
var currentIndex = 0, currentOffset = 0.0;
|
var currentIndex = 0, currentOffset = 0.0;
|
||||||
sectionKeys.forEach((sectionKey) {
|
sectionKeys.forEach((sectionKey) {
|
||||||
final section = sections[sectionKey];
|
final section = _sections[sectionKey];
|
||||||
final sectionEntryCount = section.length;
|
final sectionItemCount = section.length;
|
||||||
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
|
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;
|
final sectionFirstIndex = currentIndex;
|
||||||
currentIndex += sectionChildCount;
|
currentIndex += sectionChildCount;
|
||||||
final sectionLastIndex = currentIndex - 1;
|
final sectionLastIndex = currentIndex - 1;
|
||||||
|
|
||||||
final sectionMinOffset = currentOffset;
|
final sectionMinOffset = currentOffset;
|
||||||
currentOffset += headerExtent + tileExtent * (sectionChildCount - 1);
|
currentOffset += headerExtent + tileExtent * rowCount + spacing * (rowCount - 1);
|
||||||
final sectionMaxOffset = currentOffset;
|
final sectionMaxOffset = currentOffset;
|
||||||
|
|
||||||
sectionLayouts.add(
|
sectionLayouts.add(
|
||||||
|
@ -59,9 +63,11 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
maxOffset: sectionMaxOffset,
|
maxOffset: sectionMaxOffset,
|
||||||
headerExtent: headerExtent,
|
headerExtent: headerExtent,
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
|
spacing: spacing,
|
||||||
builder: (context, listIndex) => _buildInSection(
|
builder: (context, listIndex) => _buildInSection(
|
||||||
context,
|
context,
|
||||||
section,
|
section,
|
||||||
|
listIndex * columnCount,
|
||||||
listIndex - sectionFirstIndex,
|
listIndex - sectionFirstIndex,
|
||||||
sectionKey,
|
sectionKey,
|
||||||
headerExtent,
|
headerExtent,
|
||||||
|
@ -70,28 +76,39 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return SectionedListLayout<T>(
|
return SectionedListLayout<T>(
|
||||||
sections: sections,
|
sections: _sections,
|
||||||
showHeaders: showHeaders,
|
showHeaders: _showHeaders,
|
||||||
columnCount: columnCount,
|
columnCount: columnCount,
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
|
spacing: spacing,
|
||||||
sectionLayouts: sectionLayouts,
|
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) {
|
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--;
|
sectionChildIndex--;
|
||||||
|
|
||||||
final sectionEntryCount = section.length;
|
final sectionItemCount = section.length;
|
||||||
|
|
||||||
final minEntryIndex = sectionChildIndex * columnCount;
|
final minItemIndex = sectionChildIndex * columnCount;
|
||||||
final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount);
|
final maxItemIndex = min(sectionItemCount, minItemIndex + columnCount);
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
for (var i = minEntryIndex; i < maxEntryIndex; i++) {
|
for (var i = minItemIndex; i < maxItemIndex; i++) {
|
||||||
final entry = section[i];
|
final itemGridIndex = sectionGridIndex + i - minItemIndex;
|
||||||
children.add(tileBuilder(entry));
|
final item = tileBuilder(section[i]);
|
||||||
|
if (i != minItemIndex) children.add(SizedBox(width: spacing));
|
||||||
|
children.add(_buildAnimation(itemGridIndex, item));
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
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);
|
double getHeaderExtent(BuildContext context, SectionKey sectionKey);
|
||||||
|
|
||||||
|
@ -112,7 +144,7 @@ class SectionedListLayout<T> {
|
||||||
final Map<SectionKey, List<T>> sections;
|
final Map<SectionKey, List<T>> sections;
|
||||||
final bool showHeaders;
|
final bool showHeaders;
|
||||||
final int columnCount;
|
final int columnCount;
|
||||||
final double tileExtent;
|
final double tileExtent, spacing;
|
||||||
final List<SectionLayout> sectionLayouts;
|
final List<SectionLayout> sectionLayouts;
|
||||||
|
|
||||||
const SectionedListLayout({
|
const SectionedListLayout({
|
||||||
|
@ -120,28 +152,29 @@ class SectionedListLayout<T> {
|
||||||
@required this.showHeaders,
|
@required this.showHeaders,
|
||||||
@required this.columnCount,
|
@required this.columnCount,
|
||||||
@required this.tileExtent,
|
@required this.tileExtent,
|
||||||
|
@required this.spacing,
|
||||||
@required this.sectionLayouts,
|
@required this.sectionLayouts,
|
||||||
});
|
});
|
||||||
|
|
||||||
Rect getTileRect(T entry) {
|
Rect getTileRect(T item) {
|
||||||
final section = sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
|
final section = sections.entries.firstWhere((kv) => kv.value.contains(item), orElse: () => null);
|
||||||
if (section == null) return null;
|
if (section == null) return null;
|
||||||
|
|
||||||
final sectionKey = section.key;
|
final sectionKey = section.key;
|
||||||
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
|
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
|
||||||
if (sectionLayout == null) return null;
|
if (sectionLayout == null) return null;
|
||||||
|
|
||||||
final sectionEntryIndex = section.value.indexOf(entry);
|
final sectionItemIndex = section.value.indexOf(item);
|
||||||
final column = sectionEntryIndex % columnCount;
|
final column = sectionItemIndex % columnCount;
|
||||||
final row = (sectionEntryIndex / columnCount).floor();
|
final row = (sectionItemIndex / columnCount).floor();
|
||||||
final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row;
|
final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row;
|
||||||
|
|
||||||
final left = tileExtent * column;
|
final left = tileExtent * column + spacing * (column - 1);
|
||||||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||||
}
|
}
|
||||||
|
|
||||||
T getEntryAt(Offset position) {
|
T getItemAt(Offset position) {
|
||||||
var dy = position.dy;
|
var dy = position.dy;
|
||||||
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
||||||
if (sectionLayout == null) return null;
|
if (sectionLayout == null) return null;
|
||||||
|
@ -152,8 +185,8 @@ class SectionedListLayout<T> {
|
||||||
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
|
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
|
||||||
if (dy < 0) return null;
|
if (dy < 0) return null;
|
||||||
|
|
||||||
final row = dy ~/ tileExtent;
|
final row = dy ~/ (tileExtent + spacing);
|
||||||
final column = position.dx ~/ tileExtent;
|
final column = position.dx ~/ (tileExtent + spacing);
|
||||||
final index = row * columnCount + column;
|
final index = row * columnCount + column;
|
||||||
if (index >= section.length) return null;
|
if (index >= section.length) return null;
|
||||||
|
|
||||||
|
@ -163,9 +196,9 @@ class SectionedListLayout<T> {
|
||||||
|
|
||||||
class SectionLayout {
|
class SectionLayout {
|
||||||
final SectionKey sectionKey;
|
final SectionKey sectionKey;
|
||||||
final int firstIndex, lastIndex;
|
final int firstIndex, lastIndex, bodyFirstIndex;
|
||||||
final double minOffset, maxOffset;
|
final double minOffset, maxOffset, bodyMinOffset;
|
||||||
final double headerExtent, tileExtent;
|
final double headerExtent, tileExtent, spacing, mainAxisStride;
|
||||||
final IndexedWidgetBuilder builder;
|
final IndexedWidgetBuilder builder;
|
||||||
|
|
||||||
const SectionLayout({
|
const SectionLayout({
|
||||||
|
@ -176,31 +209,34 @@ class SectionLayout {
|
||||||
@required this.maxOffset,
|
@required this.maxOffset,
|
||||||
@required this.headerExtent,
|
@required this.headerExtent,
|
||||||
@required this.tileExtent,
|
@required this.tileExtent,
|
||||||
|
@required this.spacing,
|
||||||
@required this.builder,
|
@required this.builder,
|
||||||
});
|
}) : bodyFirstIndex = firstIndex + 1,
|
||||||
|
bodyMinOffset = minOffset + headerExtent,
|
||||||
|
mainAxisStride = tileExtent + spacing;
|
||||||
|
|
||||||
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
bool hasChild(int index) => firstIndex <= index && index <= lastIndex;
|
||||||
|
|
||||||
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
|
bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset;
|
||||||
|
|
||||||
double indexToLayoutOffset(int index) {
|
double indexToLayoutOffset(int index) {
|
||||||
return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent);
|
index -= bodyFirstIndex;
|
||||||
}
|
if (index < 0) return minOffset;
|
||||||
|
return bodyMinOffset + index * mainAxisStride;
|
||||||
double indexToMaxScrollOffset(int index) {
|
|
||||||
return minOffset + headerExtent + (index - firstIndex) * tileExtent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||||
scrollOffset -= minOffset + headerExtent;
|
scrollOffset -= bodyMinOffset;
|
||||||
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor());
|
if (scrollOffset < 0) return firstIndex;
|
||||||
|
return bodyFirstIndex + scrollOffset ~/ mainAxisStride;
|
||||||
}
|
}
|
||||||
|
|
||||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||||
scrollOffset -= minOffset + headerExtent;
|
scrollOffset -= bodyMinOffset;
|
||||||
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
|
if (scrollOffset < 0) return firstIndex;
|
||||||
|
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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) {
|
if (firstChild != null) {
|
||||||
final leadingGarbage = _calculateLeadingGarbage(firstIndex);
|
final leadingGarbage = _calculateLeadingGarbage(firstIndex);
|
||||||
final trailingGarbage = _calculateTrailingGarbage(targetLastIndex);
|
final trailingGarbage = targetLastIndex != null ? _calculateTrailingGarbage(targetLastIndex) : 0;
|
||||||
collectGarbage(leadingGarbage, trailingGarbage);
|
collectGarbage(leadingGarbage, trailingGarbage);
|
||||||
} else {
|
} else {
|
||||||
collectGarbage(0, 0);
|
collectGarbage(0, 0);
|
||||||
|
@ -191,7 +191,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||||
// Reset the scroll offset to offset all items prior and up to the
|
// Reset the scroll offset to offset all items prior and up to the
|
||||||
// missing item. Let parent re-layout everything.
|
// missing item. Let parent re-layout everything.
|
||||||
final layout = sectionAtIndex(index) ?? sectionLayouts.first;
|
final layout = sectionAtIndex(index) ?? sectionLayouts.first;
|
||||||
geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToMaxScrollOffset(index));
|
geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToLayoutOffset(index));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
|
final childParentData = child.parentData as SliverMultiBoxAdaptorParentData;
|
||||||
|
@ -215,7 +215,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
// We have run out of children.
|
// We have run out of children.
|
||||||
final layout = sectionAtIndex(index) ?? sectionLayouts.last;
|
final layout = sectionAtIndex(index) ?? sectionLayouts.last;
|
||||||
estimatedMaxScrollOffset = layout.indexToMaxScrollOffset(index);
|
estimatedMaxScrollOffset = layout.indexToLayoutOffset(index);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -250,7 +250,7 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||||
|
|
||||||
final paintExtent = calculatePaintOffset(
|
final paintExtent = calculatePaintOffset(
|
||||||
constraints,
|
constraints,
|
||||||
from: leadingScrollOffset,
|
from: math.min(constraints.scrollOffset, leadingScrollOffset),
|
||||||
to: trailingScrollOffset,
|
to: trailingScrollOffset,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -49,11 +49,12 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
||||||
return FilterGridPage<AlbumFilter>(
|
return FilterGridPage<AlbumFilter>(
|
||||||
source: source,
|
source: source,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
filterEntries: AlbumListPage.getAlbumEntries(source),
|
filterSections: AlbumListPage.getAlbumEntries(source),
|
||||||
|
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||||
applyQuery: (filters, query) {
|
applyQuery: (filters, query) {
|
||||||
if (query == null || query.isEmpty) return filters;
|
if (query == null || query.isEmpty) return filters;
|
||||||
query = query.toUpperCase();
|
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,
|
queryNotifier: _queryNotifier,
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:aves/model/actions/chip_actions.dart';
|
import 'package:aves/model/actions/chip_actions.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/collection_source.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_action_delegate.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/chip_set_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/filter_nav_page.dart';
|
||||||
|
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -26,8 +26,8 @@ class AlbumListPage extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
|
return Selector<Settings, Tuple3<AlbumChipGroupFactor, ChipSortFactor, Set<CollectionFilter>>>(
|
||||||
selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters),
|
selector: (context, s) => Tuple3(s.albumGroupFactor, s.albumSortFactor, s.pinnedFilters),
|
||||||
builder: (context, s, child) {
|
builder: (context, s, child) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: androidFileUtils.appNameChangeNotifier,
|
animation: androidFileUtils.appNameChangeNotifier,
|
||||||
|
@ -36,6 +36,8 @@ class AlbumListPage extends StatelessWidget {
|
||||||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
||||||
source: source,
|
source: source,
|
||||||
title: 'Albums',
|
title: 'Albums',
|
||||||
|
groupable: true,
|
||||||
|
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||||
chipSetActionDelegate: AlbumChipSetActionDelegate(source: source),
|
chipSetActionDelegate: AlbumChipSetActionDelegate(source: source),
|
||||||
chipActionDelegate: AlbumChipActionDelegate(source: source),
|
chipActionDelegate: AlbumChipActionDelegate(source: source),
|
||||||
chipActionsBuilder: (filter) => [
|
chipActionsBuilder: (filter) => [
|
||||||
|
@ -43,7 +45,7 @@ class AlbumListPage extends StatelessWidget {
|
||||||
ChipAction.rename,
|
ChipAction.rename,
|
||||||
ChipAction.delete,
|
ChipAction.delete,
|
||||||
],
|
],
|
||||||
filterEntries: getAlbumEntries(source),
|
filterSections: getAlbumEntries(source),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.album,
|
icon: AIcons.album,
|
||||||
text: 'No albums',
|
text: 'No albums',
|
||||||
|
@ -57,61 +59,61 @@ class AlbumListPage extends StatelessWidget {
|
||||||
|
|
||||||
// common with album selection page to move/copy entries
|
// common with album selection page to move/copy entries
|
||||||
|
|
||||||
static Map<AlbumFilter, ImageEntry> getAlbumEntries(CollectionSource source) {
|
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) {
|
||||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
|
||||||
|
|
||||||
AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album));
|
|
||||||
|
|
||||||
// albums are initially sorted by name at the source level
|
// 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 sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||||
final pinnedAlbums = <AlbumFilter>[], regularAlbums = <AlbumFilter>[], appAlbums = <AlbumFilter>[], specialAlbums = <AlbumFilter>[];
|
return _group(sorted);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.albumSortFactor == ChipSortFactor.count) {
|
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
|
||||||
final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter))));
|
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||||
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));
|
|
||||||
final pinnedMapEntries = (byPin[true] ?? []);
|
final pinnedMapEntries = (byPin[true] ?? []);
|
||||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||||
|
|
||||||
if (settings.albumSortFactor == ChipSortFactor.date) {
|
var sections = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
|
||||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
switch (settings.albumGroupFactor) {
|
||||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
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:
|
case ChipSetAction.stats:
|
||||||
_goToStats(context);
|
_goToStats(context);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,6 +73,36 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
|
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 {
|
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
|
||||||
|
|
|
@ -78,6 +78,12 @@ class DecoratedFilterChip extends StatelessWidget {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
child = SizedBox(
|
||||||
|
width: extent,
|
||||||
|
height: extent,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,13 @@ import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/highlight.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.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/double_back_pop.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/gesture_area_protector.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/aves_filter_chip.dart';
|
||||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||||
import 'package:aves/widgets/common/providers/highlight_info_provider.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/common/tile_extent_manager.dart';
|
||||||
import 'package:aves/widgets/drawer/app_drawer.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/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:draggable_scrollbar/draggable_scrollbar.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -26,11 +28,12 @@ import 'package:provider/provider.dart';
|
||||||
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final Widget appBar;
|
final Widget appBar;
|
||||||
final Map<T, ImageEntry> filterEntries;
|
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||||
|
final bool showHeaders;
|
||||||
final ValueNotifier<String> queryNotifier;
|
final ValueNotifier<String> queryNotifier;
|
||||||
final Widget Function() emptyBuilder;
|
final Widget Function() emptyBuilder;
|
||||||
final String settingsRouteKey;
|
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 FilterCallback onTap;
|
||||||
final OffsetFilterCallback onLongPress;
|
final OffsetFilterCallback onLongPress;
|
||||||
|
|
||||||
|
@ -46,7 +49,8 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
Key key,
|
Key key,
|
||||||
@required this.source,
|
@required this.source,
|
||||||
@required this.appBar,
|
@required this.appBar,
|
||||||
@required this.filterEntries,
|
@required this.filterSections,
|
||||||
|
this.showHeaders = false,
|
||||||
@required this.queryNotifier,
|
@required this.queryNotifier,
|
||||||
this.applyQuery,
|
this.applyQuery,
|
||||||
@required this.emptyBuilder,
|
@required this.emptyBuilder,
|
||||||
|
@ -82,22 +86,28 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
)..applyTileExtent(viewportSize: viewportSize);
|
)..applyTileExtent(viewportSize: viewportSize);
|
||||||
|
|
||||||
return ValueListenableBuilder<double>(
|
final pinnedFilters = settings.pinnedFilters;
|
||||||
valueListenable: _tileExtentNotifier,
|
|
||||||
builder: (context, tileExtent, child) {
|
|
||||||
final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent);
|
|
||||||
|
|
||||||
return ValueListenableBuilder<String>(
|
return ValueListenableBuilder<String>(
|
||||||
valueListenable: queryNotifier,
|
valueListenable: queryNotifier,
|
||||||
builder: (context, query, child) {
|
builder: (context, query, child) {
|
||||||
final allFilters = filterEntries.keys;
|
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||||
final visibleFilters = (applyQuery != null ? applyQuery(allFilters, query) : allFilters).toList();
|
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(
|
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,
|
tileExtentManager: tileExtentManager,
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: _scrollableKey,
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
|
@ -113,33 +123,54 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
),
|
),
|
||||||
scaledBuilder: (item, extent) {
|
scaledBuilder: (item, extent) {
|
||||||
final filter = item.filter;
|
final filter = item.filter;
|
||||||
return SizedBox(
|
return DecoratedFilterChip(
|
||||||
width: extent,
|
|
||||||
height: extent,
|
|
||||||
child: DecoratedFilterChip(
|
|
||||||
source: source,
|
source: source,
|
||||||
filter: filter,
|
filter: filter,
|
||||||
entry: item.entry,
|
entry: item.entry,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
pinned: settings.pinnedFilters.contains(filter),
|
pinned: pinnedFilters.contains(filter),
|
||||||
highlightable: false,
|
highlightable: false,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getScaledItemTileRect: (context, item) {
|
getScaledItemTileRect: (context, item) {
|
||||||
final index = visibleFilters.indexOf(item.filter);
|
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
|
||||||
final column = index % columnCount;
|
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
|
||||||
final row = (index / columnCount).floor();
|
|
||||||
final left = tileExtent * column + spacing * (column - 1);
|
|
||||||
final top = tileExtent * row + spacing * (row - 1);
|
|
||||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
|
||||||
},
|
},
|
||||||
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
|
onScaled: (item) => Provider.of<HighlightInfo>(context, listen: false).add(item.filter),
|
||||||
child: scrollView,
|
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) {
|
ScrollView _buildScrollView(BuildContext context, bool empty) {
|
||||||
final pinnedFilters = settings.pinnedFilters;
|
Widget content;
|
||||||
return CustomScrollView(
|
if (empty) {
|
||||||
key: _scrollableKey,
|
content = SliverFillRemaining(
|
||||||
controller: PrimaryScrollController.of(context),
|
|
||||||
slivers: [
|
|
||||||
appBar,
|
|
||||||
visibleFilters.isEmpty
|
|
||||||
? SliverFillRemaining(
|
|
||||||
child: Selector<MediaQueryData, double>(
|
child: Selector<MediaQueryData, double>(
|
||||||
selector: (context, mq) => mq.viewInsets.bottom,
|
selector: (context, mq) => mq.viewInsets.bottom,
|
||||||
builder: (context, mqViewInsetsBottom, child) {
|
builder: (context, mqViewInsetsBottom, child) {
|
||||||
|
@ -195,62 +221,28 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
hasScrollBody: false,
|
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(
|
} else {
|
||||||
position: i,
|
content = SectionedListSliver<FilterGridItem<T>>();
|
||||||
columnCount: columnCount,
|
}
|
||||||
duration: Durations.staggeredAnimation,
|
|
||||||
delay: Durations.staggeredAnimationDelay,
|
final padding = SliverToBoxAdapter(
|
||||||
child: SlideAnimation(
|
|
||||||
verticalOffset: 50.0,
|
|
||||||
child: FadeInAnimation(
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
childCount: visibleFilters.length,
|
|
||||||
),
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: columnCount,
|
|
||||||
mainAxisSpacing: spacing,
|
|
||||||
crossAxisSpacing: spacing,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Selector<MediaQueryData, double>(
|
child: Selector<MediaQueryData, double>(
|
||||||
selector: (context, mq) => mq.viewInsets.bottom,
|
selector: (context, mq) => mq.viewInsets.bottom,
|
||||||
builder: (context, mqViewInsetsBottom, child) {
|
builder: (context, mqViewInsetsBottom, child) {
|
||||||
return SizedBox(height: mqViewInsetsBottom);
|
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/main.dart';
|
||||||
import 'package:aves/model/actions/chip_actions.dart';
|
import 'package:aves/model/actions/chip_actions.dart';
|
||||||
import 'package:aves/model/filters/filters.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.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/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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_action_delegate.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/chip_set_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/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_button.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.dart';
|
import 'package:aves/widgets/search/search_delegate.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -26,18 +27,21 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final String title;
|
final String title;
|
||||||
final ChipSetActionDelegate chipSetActionDelegate;
|
final ChipSetActionDelegate chipSetActionDelegate;
|
||||||
|
final bool groupable, showHeaders;
|
||||||
final ChipActionDelegate chipActionDelegate;
|
final ChipActionDelegate chipActionDelegate;
|
||||||
final Map<T, ImageEntry> filterEntries;
|
|
||||||
final Widget Function() emptyBuilder;
|
|
||||||
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
||||||
|
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||||
|
final Widget Function() emptyBuilder;
|
||||||
|
|
||||||
const FilterNavigationPage({
|
const FilterNavigationPage({
|
||||||
@required this.source,
|
@required this.source,
|
||||||
@required this.title,
|
@required this.title,
|
||||||
|
this.groupable = false,
|
||||||
|
this.showHeaders = false,
|
||||||
@required this.chipSetActionDelegate,
|
@required this.chipSetActionDelegate,
|
||||||
@required this.chipActionDelegate,
|
@required this.chipActionDelegate,
|
||||||
@required this.chipActionsBuilder,
|
@required this.chipActionsBuilder,
|
||||||
@required this.filterEntries,
|
@required this.filterSections,
|
||||||
@required this.emptyBuilder,
|
@required this.emptyBuilder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,7 +62,8 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
floating: true,
|
floating: true,
|
||||||
),
|
),
|
||||||
filterEntries: filterEntries,
|
filterSections: filterSections,
|
||||||
|
showHeaders: showHeaders,
|
||||||
queryNotifier: ValueNotifier(''),
|
queryNotifier: ValueNotifier(''),
|
||||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||||
valueListenable: source.stateNotifier,
|
valueListenable: source.stateNotifier,
|
||||||
|
@ -114,6 +119,11 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
value: ChipSetAction.sort,
|
value: ChipSetAction.sort,
|
||||||
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
|
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
|
||||||
),
|
),
|
||||||
|
if (groupable)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ChipSetAction.group,
|
||||||
|
child: MenuRow(text: 'Group…', icon: AIcons.group),
|
||||||
|
),
|
||||||
if (kDebugMode)
|
if (kDebugMode)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: ChipSetAction.refresh,
|
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) {
|
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
||||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1;
|
||||||
return c != 0 ? c : a.key.compareTo(b.key);
|
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;
|
final c = b.value.compareTo(a.value) ?? -1;
|
||||||
return c != 0 ? c : a.key.compareTo(b.key);
|
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/actions/chip_actions.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/location.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.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_action_delegate.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/chip_set_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/filter_nav_page.dart';
|
||||||
|
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -30,7 +30,7 @@ class CountryListPage extends StatelessWidget {
|
||||||
builder: (context, s, child) {
|
builder: (context, s, child) {
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||||
builder: (context, snapshot) => FilterNavigationPage(
|
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
|
||||||
source: source,
|
source: source,
|
||||||
title: 'Countries',
|
title: 'Countries',
|
||||||
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
|
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
|
||||||
|
@ -38,7 +38,7 @@ class CountryListPage extends StatelessWidget {
|
||||||
chipActionsBuilder: (filter) => [
|
chipActionsBuilder: (filter) => [
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
],
|
],
|
||||||
filterEntries: _getCountryEntries(),
|
filterSections: _getCountryEntries(),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.location,
|
icon: AIcons.location,
|
||||||
text: 'No countries',
|
text: 'No countries',
|
||||||
|
@ -49,37 +49,26 @@ class CountryListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<LocationFilter, ImageEntry> _getCountryEntries() {
|
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries() {
|
||||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
|
||||||
|
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
|
||||||
// countries are initially sorted by name at the source level
|
// countries are initially sorted by name at the source level
|
||||||
var sortedFilters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location));
|
final filters = 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))));
|
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
return _group(sorted);
|
||||||
sortedFilters = filtersWithCount.map((kv) => kv.key).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
|
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
|
||||||
final allMapEntries = sortedFilters.map((filter) {
|
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||||
final split = filter.countryNameAndCode.split(LocationFilter.locationSeparator);
|
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||||
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));
|
|
||||||
final pinnedMapEntries = (byPin[true] ?? []);
|
final pinnedMapEntries = (byPin[true] ?? []);
|
||||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||||
|
|
||||||
if (settings.countrySortFactor == ChipSortFactor.date) {
|
return {
|
||||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
ChipSectionKey(): [
|
||||||
}
|
...pinnedMapEntries,
|
||||||
|
...unpinnedMapEntries,
|
||||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:aves/model/actions/chip_actions.dart';
|
import 'package:aves/model/actions/chip_actions.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/tag.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.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_action_delegate.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/chip_set_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/filter_nav_page.dart';
|
||||||
|
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -30,7 +30,7 @@ class TagListPage extends StatelessWidget {
|
||||||
builder: (context, s, child) {
|
builder: (context, s, child) {
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||||
builder: (context, snapshot) => FilterNavigationPage(
|
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
|
||||||
source: source,
|
source: source,
|
||||||
title: 'Tags',
|
title: 'Tags',
|
||||||
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
|
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
|
||||||
|
@ -38,7 +38,7 @@ class TagListPage extends StatelessWidget {
|
||||||
chipActionsBuilder: (filter) => [
|
chipActionsBuilder: (filter) => [
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
],
|
],
|
||||||
filterEntries: _getTagEntries(),
|
filterSections: _getTagEntries(),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.tag,
|
icon: AIcons.tag,
|
||||||
text: 'No tags',
|
text: 'No tags',
|
||||||
|
@ -49,31 +49,26 @@ class TagListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<TagFilter, ImageEntry> _getTagEntries() {
|
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() {
|
||||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
|
||||||
|
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
|
||||||
// tags are initially sorted by name at the source level
|
// tags are initially sorted by name at the source level
|
||||||
var sortedFilters = source.sortedTags.map((tag) => TagFilter(tag));
|
final filters = source.sortedTags.map((tag) => TagFilter(tag));
|
||||||
if (settings.tagSortFactor == ChipSortFactor.count) {
|
|
||||||
final filtersWithCount = List.of(sortedFilters.map((filter) => MapEntry(filter, source.count(filter))));
|
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
return _group(sorted);
|
||||||
sortedFilters = filtersWithCount.map((kv) => kv.key).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final allMapEntries = sortedFilters.map((filter) => MapEntry(
|
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
|
||||||
filter,
|
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(filter.tag), orElse: () => null),
|
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||||
));
|
|
||||||
final byPin = groupBy<MapEntry<TagFilter, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
|
||||||
final pinnedMapEntries = (byPin[true] ?? []);
|
final pinnedMapEntries = (byPin[true] ?? []);
|
||||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||||
|
|
||||||
if (settings.tagSortFactor == ChipSortFactor.date) {
|
return {
|
||||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
ChipSectionKey(): [
|
||||||
}
|
...pinnedMapEntries,
|
||||||
|
...unpinnedMapEntries,
|
||||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
||||||
final availableWidth = mqWidth - viewPadding.horizontal;
|
final availableWidth = mqWidth - viewPadding.horizontal;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: hasEdgeContent ? kOverlayBackgroundColor: Colors.transparent,
|
color: hasEdgeContent ? kOverlayBackgroundColor : Colors.transparent,
|
||||||
padding: viewInsets + viewPadding.copyWith(top: 0),
|
padding: viewInsets + viewPadding.copyWith(top: 0),
|
||||||
child: FutureBuilder<OverlayMetadata>(
|
child: FutureBuilder<OverlayMetadata>(
|
||||||
future: _detailLoader,
|
future: _detailLoader,
|
||||||
|
|
|
@ -186,6 +186,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||||
child = AnimationConfiguration.staggeredList(
|
child = AnimationConfiguration.staggeredList(
|
||||||
position: index,
|
position: index,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
|
delay: delay,
|
||||||
child: childAnimationBuilder(child),
|
child: childAnimationBuilder(child),
|
||||||
);
|
);
|
||||||
child = widget is Flexible ? Flexible(child: child) : child;
|
child = widget is Flexible ? Flexible(child: child) : child;
|
||||||
|
|
Loading…
Reference in a new issue