#143 rating: sort/group/filter

This commit is contained in:
Thibault Deckers 2021-12-29 18:27:32 +09:00
parent 039983b8f7
commit 713ef3d782
24 changed files with 203 additions and 58 deletions

View file

@ -480,15 +480,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it }
xmpMeta.getSafeInt(XMP.XMP_SCHEMA_NS, XMP.XMP_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.MICROSOFTPHOTO_SCHEMA_NS, XMP.MS_RATING_PROP_NAME) { percentRating ->
// values of 1,25,50,75,99% correspond to 1,2,3,4,5 stars
val standardRating = (percentRating / 25f).roundToInt() + 1
if (standardRating in RATING_RANGE) metadataMap[KEY_RATING] = standardRating
metadataMap[KEY_RATING] = standardRating
}
if (!metadataMap.containsKey(KEY_RATING)) {
xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { if (it in RATING_RANGE) metadataMap[KEY_RATING] = it }
xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it }
}
}
@ -991,7 +991,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val MASK_IS_360 = 1 shl 3
private const val MASK_IS_MULTIPAGE = 1 shl 4
private const val XMP_SUBJECTS_SEPARATOR = ";"
private val RATING_RANGE = 1..5
// overlay metadata
private const val KEY_APERTURE = "aperture"

View file

@ -94,6 +94,8 @@
"filterFavouriteLabel": "Favourite",
"filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged",
"filterRatingUnratedLabel": "Unrated",
"filterRatingRejectedLabel": "Rejected",
"filterTypeAnimatedLabel": "Animated",
"filterTypeMotionPhotoLabel": "Motion Photo",
"filterTypePanoramaLabel": "Panorama",
@ -381,6 +383,7 @@
"collectionSortDate": "By date",
"collectionSortSize": "By size",
"collectionSortName": "By album & file name",
"collectionSortRating": "By rating",
"collectionGroupAlbum": "By album",
"collectionGroupMonth": "By month",
@ -494,6 +497,7 @@
"searchSectionCountries": "Countries",
"searchSectionPlaces": "Places",
"searchSectionTags": "Tags",
"searchSectionRating": "Ratings",
"settingsPageTitle": "Settings",
"settingsSystemDefault": "System",

View file

@ -79,6 +79,8 @@
"filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
"filterRatingUnratedLabel": "Sans notation",
"filterRatingRejectedLabel": "Rejeté",
"filterTypeAnimatedLabel": "Animation",
"filterTypeMotionPhotoLabel": "Photo animée",
"filterTypePanoramaLabel": "Panorama",
@ -273,6 +275,7 @@
"collectionSortDate": "par date",
"collectionSortSize": "par taille",
"collectionSortName": "alphabétique",
"collectionSortRating": "par notation",
"collectionGroupAlbum": "par album",
"collectionGroupMonth": "par mois",
@ -346,6 +349,7 @@
"searchSectionCountries": "Pays",
"searchSectionPlaces": "Lieux",
"searchSectionTags": "Libellés",
"searchSectionRating": "Notations",
"settingsPageTitle": "Réglages",
"settingsSystemDefault": "Système",

View file

@ -79,6 +79,8 @@
"filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
"filterRatingUnratedLabel": "별점 없음",
"filterRatingRejectedLabel": "거부됨",
"filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토",
"filterTypePanoramaLabel": "파노라마",
@ -273,6 +275,7 @@
"collectionSortDate": "날짜",
"collectionSortSize": "크기",
"collectionSortName": "이름",
"collectionSortRating": "별점",
"collectionGroupAlbum": "앨범별로",
"collectionGroupMonth": "월별로",
@ -346,6 +349,7 @@
"searchSectionCountries": "국가",
"searchSectionPlaces": "장소",
"searchSectionTags": "태그",
"searchSectionRating": "별점",
"settingsPageTitle": "설정",
"settingsSystemDefault": "시스템",

View file

@ -361,7 +361,7 @@ class AvesEntry {
return _bestDate;
}
int? get rating => _catalogMetadata?.rating;
int get rating => _catalogMetadata?.rating ?? 0;
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
@ -861,14 +861,6 @@ class AvesEntry {
return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
}
// compare by:
// 1) size descending
// 2) name ascending
static int compareBySize(AvesEntry a, AvesEntry b) {
final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
return c != 0 ? c : compareByName(a, b);
}
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
// compare by:
@ -879,4 +871,20 @@ class AvesEntry {
if (c != 0) return c;
return compareByName(b, a);
}
// compare by:
// 1) rating descending
// 2) date descending
static int compareByRating(AvesEntry a, AvesEntry b) {
final c = b.rating.compareTo(a.rating);
return c != 0 ? c : compareByDate(a, b);
}
// compare by:
// 1) size descending
// 2) date descending
static int compareBySize(AvesEntry a, AvesEntry b) {
final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
return c != 0 ? c : compareByDate(a, b);
}
}

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart';
@ -26,6 +27,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
AlbumFilter.type,
LocationFilter.type,
CoordinateFilter.type,
RatingFilter.type,
TagFilter.type,
PathFilter.type,
];
@ -52,6 +54,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return PathFilter.fromMap(jsonMap);
case QueryFilter.type:
return QueryFilter.fromMap(jsonMap);
case RatingFilter.type:
return RatingFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:

View file

@ -0,0 +1,64 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
class RatingFilter extends CollectionFilter {
static const type = 'rating';
final int rating;
@override
List<Object?> get props => [rating];
const RatingFilter(this.rating);
RatingFilter.fromMap(Map<String, dynamic> json)
: this(
json['rating'] ?? 0,
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'rating': rating,
};
@override
EntryFilter get test => (entry) => entry.rating == rating;
@override
String get universalLabel => '$rating';
@override
String getLabel(BuildContext context) => formatRating(context, rating);
@override
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
switch (rating) {
case -1:
return Icon(AIcons.ratingRejected, size: size);
case 0:
return Icon(AIcons.ratingUnrated, size: size);
default:
return null;
}
}
@override
String get category => type;
@override
String get key => '$type-$rating';
static String formatRating(BuildContext context, int rating) {
switch (rating) {
case -1:
return context.l10n.filterRatingRejectedLabel;
case 0:
return context.l10n.filterRatingUnratedLabel;
default:
return '\u2B50' * rating;
}
}
}

View file

@ -5,10 +5,11 @@ class CatalogMetadata {
final int? contentId, dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped;
int? rating, rotationDegrees;
int? rotationDegrees;
final String? mimeType, xmpSubjects, xmpTitleDescription;
double? latitude, longitude;
Address? address;
int rating;
static const double _precisionErrorTolerance = 1e-9;
static const _isAnimatedMask = 1 << 0;
@ -31,7 +32,7 @@ class CatalogMetadata {
this.xmpTitleDescription,
double? latitude,
double? longitude,
this.rating,
this.rating = 0,
}) {
// Geocoder throws an `IllegalArgumentException` when a coordinate has a funky value like `1.7056881853375E7`
// We also exclude zero coordinates, taking into account precision errors (e.g. {5.952380952380953e-11,-2.7777777777777777e-10}),
@ -89,8 +90,7 @@ class CatalogMetadata {
xmpTitleDescription: map['xmpTitleDescription'] ?? '',
latitude: map['latitude'],
longitude: map['longitude'],
// `rotationDegrees` should default to `null`, not 0
rating: map['rating'],
rating: map['rating'] ?? 0,
);
}

View file

@ -9,6 +9,7 @@ import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart';
@ -108,15 +109,27 @@ class CollectionLens with ChangeNotifier {
}
bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false;
bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter);
if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false;
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album);
final filterByAlbum = filters.any((f) => f is AlbumFilter);
if (albumSections && filterByAlbum) return false;
return true;
switch (sortFactor) {
case EntrySortFactor.date:
switch (sectionFactor) {
case EntryGroupFactor.none:
return false;
case EntryGroupFactor.album:
return showAlbumHeaders();
case EntryGroupFactor.month:
return true;
case EntryGroupFactor.day:
return true;
}
case EntrySortFactor.name:
return showAlbumHeaders();
case EntrySortFactor.rating:
return !filters.any((f) => f is RatingFilter);
case EntrySortFactor.size:
return false;
}
}
void addFilter(CollectionFilter filter) {
@ -181,12 +194,15 @@ class CollectionLens with ChangeNotifier {
case EntrySortFactor.date:
_filteredSortedEntries.sort(AvesEntry.compareByDate);
break;
case EntrySortFactor.size:
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
case EntrySortFactor.name:
_filteredSortedEntries.sort(AvesEntry.compareByName);
break;
case EntrySortFactor.rating:
_filteredSortedEntries.sort(AvesEntry.compareByRating);
break;
case EntrySortFactor.size:
_filteredSortedEntries.sort(AvesEntry.compareBySize);
break;
}
}
@ -210,15 +226,18 @@ class CollectionLens with ChangeNotifier {
break;
}
break;
case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!));
break;
case EntrySortFactor.rating:
sections = groupBy<AvesEntry, EntryRatingSectionKey>(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating));
break;
case EntrySortFactor.size:
sections = Map.fromEntries([
MapEntry(const SectionKey(), _filteredSortedEntries),
]);
break;
case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory!, b.directory!));
break;
}
sections = Map.unmodifiable(sections);
_sortedEntries = null;

View file

@ -4,7 +4,7 @@ enum ChipSortFactor { date, name, count }
enum AlbumChipGroupFactor { none, importance, volume }
enum EntrySortFactor { date, size, name }
enum EntrySortFactor { date, name, rating, size }
enum EntryGroupFactor { none, album, month, day }

View file

@ -23,3 +23,12 @@ class EntryDateSectionKey extends SectionKey with EquatableMixin {
const EntryDateSectionKey(this.date);
}
class EntryRatingSectionKey extends SectionKey with EquatableMixin {
final int rating;
@override
List<Object?> get props => [rating];
const EntryRatingSectionKey(this.rating);
}

View file

@ -66,7 +66,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'rating': rating in [1,5] (int)
// 'rating': rating in [-1,5] (int)
// 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double)
// 'longitude': longitude (double)

View file

@ -22,6 +22,8 @@ class AIcons {
static const IconData locationOff = Icons.location_off_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData privacy = MdiIcons.shieldAccountOutline;
static const IconData ratingRejected = MdiIcons.starRemoveOutline;
static const IconData ratingUnrated = MdiIcons.starOffOutline;
static const IconData raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined;
static const IconData removableStorage = Icons.sd_storage_outlined;

View file

@ -210,7 +210,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
action,
appMode: appMode,
isSelecting: isSelecting,
sortFactor: collection.sortFactor,
itemCount: collection.entryCount,
selectedItemCount: selectedItemCount,
);
@ -448,6 +447,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
EntrySortFactor.date: l10n.collectionSortDate,
EntrySortFactor.size: l10n.collectionSortSize,
EntrySortFactor.name: l10n.collectionSortName,
EntrySortFactor.rating: l10n.collectionSortRating,
},
groupOptions: {
EntryGroupFactor.album: l10n.collectionGroupAlbum,

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
@ -47,6 +48,11 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
if (_showAlbumName(context, entry)) _getAlbumName(context, entry),
if (entry.bestTitle != null) entry.bestTitle!,
];
case EntrySortFactor.rating:
return [
RatingFilter.formatRating(context, entry.rating),
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
];
case EntrySortFactor.size:
return [
if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),

View file

@ -15,7 +15,6 @@ import 'package:aves/model/selection.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
@ -44,7 +43,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, {
required AppMode appMode,
required bool isSelecting,
required EntrySortFactor sortFactor,
required int itemCount,
required int selectedItemCount,
}) {

View file

@ -7,6 +7,7 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/collection/grid/headers/album.dart';
import 'package:aves/widgets/collection/grid/headers/date.dart';
import 'package:aves/widgets/collection/grid/headers/rating.dart';
import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
@ -49,6 +50,8 @@ class CollectionSectionHeader extends StatelessWidget {
break;
case EntrySortFactor.name:
return _buildAlbumHeader(context);
case EntrySortFactor.rating:
return RatingSectionHeader<AvesEntry>(key: ValueKey(sectionKey), rating: (sectionKey as EntryRatingSectionKey).rating);
case EntrySortFactor.size:
break;
}

View file

@ -0,0 +1,21 @@
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
class RatingSectionHeader<T> extends StatelessWidget {
final int rating;
const RatingSectionHeader({
Key? key,
required this.rating,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SectionHeader<T>(
sectionKey: EntryRatingSectionKey(rating),
title: RatingFilter.formatRating(context, rating),
);
}
}

View file

@ -316,7 +316,6 @@ class _ScaleOverlayState extends State<_ScaleOverlay> {
colors: const [
Colors.black,
Colors.black54,
// Colors.amber,
],
),
)

View file

@ -25,7 +25,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
else if (entry.isAnimated)
const AnimatedImageIcon()
else ...[
if (entry.rating != null && context.select<GridThemeData, bool>((t) => t.showRating)) RatingIcon(entry: entry),
if (entry.rating != 0 && context.select<GridThemeData, bool>((t) => t.showRating)) RatingIcon(entry: entry),
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(),

View file

@ -4,6 +4,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.dart';
@ -179,6 +180,11 @@ class CollectionSearchDelegate {
],
);
}),
_buildFilterRow(
context: context,
title: context.l10n.searchSectionRating,
filters: [0, -1, 5, 4, 3, 2, 1].map((rating) => RatingFilter(rating)).toList(),
),
],
);
});

View file

@ -4,6 +4,7 @@ import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -75,31 +76,12 @@ class BasicSection extends StatelessWidget {
},
),
OwnerProp(entry: entry),
_buildRatingRow(),
_buildChips(context),
],
);
});
}
Widget _buildRatingRow() {
final rating = entry.rating;
return rating != null
? Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: List.generate(
5,
(i) => Icon(
Icons.star,
color: rating > i ? Colors.amber : Colors.grey[800],
),
),
),
)
: const SizedBox();
}
Widget _buildChips(BuildContext context) {
final tags = entry.tags.toList()..sort(compareAsciiUpperCase);
final album = entry.directory;
@ -113,6 +95,7 @@ class BasicSection extends StatelessWidget {
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
if (entry.isVideo && !entry.is360) MimeFilter.video,
if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)),
if (entry.rating != 0) RatingFilter(entry.rating),
...tags.map((tag) => TagFilter(tag)),
};
return AnimatedBuilder(

View file

@ -6,6 +6,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/services/common/services.dart';
@ -50,6 +51,9 @@ void main() {
final query = QueryFilter('some query');
expect(query, jsonRoundTrip(query));
const rating = RatingFilter(3);
expect(rating, jsonRoundTrip(rating));
final tag = TagFilter('some tag');
expect(tag, jsonRoundTrip(tag));

View file

@ -1,10 +1,14 @@
{
"de": [
"filterRatingUnratedLabel",
"filterRatingRejectedLabel",
"editEntryDateDialogSourceFieldLabel",
"editEntryDateDialogSourceCustomDate",
"editEntryDateDialogSourceTitle",
"editEntryDateDialogSourceFileModifiedDate",
"editEntryDateDialogTargetFieldsHeader",
"collectionSortRating",
"searchSectionRating",
"settingsThumbnailShowRatingIcon"
],
@ -17,11 +21,15 @@
],
"ru": [
"filterRatingUnratedLabel",
"filterRatingRejectedLabel",
"editEntryDateDialogSourceFieldLabel",
"editEntryDateDialogSourceCustomDate",
"editEntryDateDialogSourceTitle",
"editEntryDateDialogSourceFileModifiedDate",
"editEntryDateDialogTargetFieldsHeader",
"collectionSortRating",
"searchSectionRating",
"settingsThumbnailShowRatingIcon"
]
}