added favourites

This commit is contained in:
Thibault Deckers 2020-03-28 11:01:50 +09:00
parent 5df815e5c1
commit edd410d854
24 changed files with 524 additions and 266 deletions

View file

@ -1,239 +0,0 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path/path.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> {
static const List<String> collectionFilterOrder = [
VideoFilter.type,
GifFilter.type,
AlbumFilter.type,
CountryFilter.type,
TagFilter.type,
QueryFilter.type,
];
const CollectionFilter();
bool filter(ImageEntry entry);
String get label;
String get tooltip => label;
Widget iconBuilder(BuildContext context, double size);
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(label));
String get typeKey;
int get displayPriority => collectionFilterOrder.indexOf(typeKey);
@override
int compareTo(CollectionFilter other) {
final c = displayPriority.compareTo(other.displayPriority);
return c != 0 ? c : compareAsciiUpperCase(label, other.label);
}
}
class AlbumFilter extends CollectionFilter {
static const type = 'album';
static Map<String, Color> _appColors = Map();
final String album;
const AlbumFilter(this.album);
@override
bool filter(ImageEntry entry) => entry.directory == album;
@override
String get label => album.split(separator).last;
@override
String get tooltip => album;
@override
Widget iconBuilder(context, size) {
return IconUtils.getAlbumIcon(context: context, album: album, size: size) ?? Icon(OMIcons.photoAlbum, size: size);
}
@override
Future<Color> color(BuildContext context) {
// do not use async/await and rely on `SynchronousFuture`
// to prevent rebuilding of the `FutureBuilder` listening on this future
if (androidFileUtils.getAlbumType(album) == AlbumType.App) {
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]);
return PaletteGenerator.fromImageProvider(
AppIconImage(
packageName: androidFileUtils.getAlbumAppPackageName(album),
size: 24,
),
).then((palette) {
final color = palette.dominantColor?.color ?? super.color(context);
_appColors[album] = color;
return color;
});
} else {
return super.color(context);
}
}
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is AlbumFilter && other.album == album;
}
@override
int get hashCode => hashValues('AlbumFilter', album);
}
class TagFilter extends CollectionFilter {
static const type = 'tag';
final String tag;
const TagFilter(this.tag);
@override
bool filter(ImageEntry entry) => entry.xmpSubjects.contains(tag);
@override
String get label => tag;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.localOffer, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is TagFilter && other.tag == tag;
}
@override
int get hashCode => hashValues('TagFilter', tag);
}
class CountryFilter extends CollectionFilter {
static const type = 'country';
final String country;
const CountryFilter(this.country);
@override
bool filter(ImageEntry entry) => entry.isLocated && entry.addressDetails.countryName == country;
@override
String get label => country;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.place, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is CountryFilter && other.country == country;
}
@override
int get hashCode => hashValues('CountryFilter', country);
}
class VideoFilter extends CollectionFilter {
static const type = 'video';
@override
bool filter(ImageEntry entry) => entry.isVideo;
@override
String get label => 'Video';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.movie, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is VideoFilter;
}
@override
int get hashCode => 'VideoFilter'.hashCode;
}
class GifFilter extends CollectionFilter {
static const type = 'gif';
@override
bool filter(ImageEntry entry) => entry.isGif;
@override
String get label => 'GIF';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.gif, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is GifFilter;
}
@override
int get hashCode => 'GifFilter'.hashCode;
}
class QueryFilter extends CollectionFilter {
static const type = 'query';
final String query;
const QueryFilter(this.query);
@override
bool filter(ImageEntry entry) => entry.search(query);
@override
String get label => '${query}';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.formatQuote, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is QueryFilter && other.query == query;
}
@override
int get hashCode => hashValues('MetadataFilter', query);
}

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:collection/collection.dart';

View file

@ -0,0 +1,31 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
final FavouriteRepo favourites = FavouriteRepo._private();
class FavouriteRepo {
List<FavouriteRow> _rows = List();
FavouriteRepo._private();
Future<void> init() async {
_rows = await metadataDb.loadFavourites();
}
int get count => _rows.length;
bool isFavourite(ImageEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
Future<void> add(ImageEntry entry) async {
final newRows = [FavouriteRow(contentId: entry.contentId, path: entry.path)];
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
}
Future<void> remove(ImageEntry entry) async {
final removedRows = [FavouriteRow(contentId: entry.contentId, path: entry.path)];
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
}
}

View file

@ -0,0 +1,68 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path/path.dart';
class AlbumFilter extends CollectionFilter {
static const type = 'album';
static Map<String, Color> _appColors = Map();
final String album;
const AlbumFilter(this.album);
@override
bool filter(ImageEntry entry) => entry.directory == album;
@override
String get label => album.split(separator).last;
@override
String get tooltip => album;
@override
Widget iconBuilder(context, size) {
return IconUtils.getAlbumIcon(context: context, album: album, size: size) ?? Icon(OMIcons.photoAlbum, size: size);
}
@override
Future<Color> color(BuildContext context) {
// do not use async/await and rely on `SynchronousFuture`
// to prevent rebuilding of the `FutureBuilder` listening on this future
if (androidFileUtils.getAlbumType(album) == AlbumType.App) {
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]);
return PaletteGenerator.fromImageProvider(
AppIconImage(
packageName: androidFileUtils.getAlbumAppPackageName(album),
size: 24,
),
).then((palette) {
final color = palette.dominantColor?.color ?? super.color(context);
_appColors[album] = color;
return color;
});
} else {
return super.color(context);
}
}
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is AlbumFilter && other.album == album;
}
@override
int get hashCode => hashValues('AlbumFilter', album);
}

View file

@ -0,0 +1,33 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class CountryFilter extends CollectionFilter {
static const type = 'country';
final String country;
const CountryFilter(this.country);
@override
bool filter(ImageEntry entry) => entry.isLocated && entry.addressDetails.countryName == country;
@override
String get label => country;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.place, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is CountryFilter && other.country == country;
}
@override
int get hashCode => hashValues('CountryFilter', country);
}

View file

@ -0,0 +1,30 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class FavouriteFilter extends CollectionFilter {
static const type = 'favourite';
@override
bool filter(ImageEntry entry) => entry.isFavourite;
@override
String get label => 'Favourite';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.favoriteBorder, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteFilter;
}
@override
int get hashCode => 'FavouriteFilter'.hashCode;
}

View file

@ -0,0 +1,46 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/country.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/gif.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/video.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> {
static const List<String> collectionFilterOrder = [
QueryFilter.type,
FavouriteFilter.type,
VideoFilter.type,
GifFilter.type,
AlbumFilter.type,
CountryFilter.type,
TagFilter.type,
];
const CollectionFilter();
bool filter(ImageEntry entry);
String get label;
String get tooltip => label;
Widget iconBuilder(BuildContext context, double size);
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(label));
String get typeKey;
int get displayPriority => collectionFilterOrder.indexOf(typeKey);
@override
int compareTo(CollectionFilter other) {
final c = displayPriority.compareTo(other.displayPriority);
return c != 0 ? c : compareAsciiUpperCase(label, other.label);
}
}

View file

@ -0,0 +1,29 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class GifFilter extends CollectionFilter {
static const type = 'gif';
@override
bool filter(ImageEntry entry) => entry.isGif;
@override
String get label => 'GIF';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.gif, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is GifFilter;
}
@override
int get hashCode => 'GifFilter'.hashCode;
}

View file

@ -0,0 +1,41 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path/path.dart';
class QueryFilter extends CollectionFilter {
static const type = 'query';
final String query;
const QueryFilter(this.query);
@override
bool filter(ImageEntry entry) => entry.search(query);
@override
String get label => '${query}';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.formatQuote, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is QueryFilter && other.query == query;
}
@override
int get hashCode => hashValues('MetadataFilter', query);
}

View file

@ -0,0 +1,33 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class TagFilter extends CollectionFilter {
static const type = 'tag';
final String tag;
const TagFilter(this.tag);
@override
bool filter(ImageEntry entry) => entry.xmpSubjects.contains(tag);
@override
String get label => tag;
@override
Widget iconBuilder(context, size) => Icon(OMIcons.localOffer, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is TagFilter && other.tag == tag;
}
@override
int get hashCode => hashValues('TagFilter', tag);
}

View file

@ -0,0 +1,29 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:flutter/widgets.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
class VideoFilter extends CollectionFilter {
static const type = 'video';
@override
bool filter(ImageEntry entry) => entry.isVideo;
@override
String get label => 'Video';
@override
Widget iconBuilder(context, size) => Icon(OMIcons.movie, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is VideoFilter;
}
@override
int get hashCode => 'VideoFilter'.hashCode;
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_service.dart';
@ -29,6 +30,7 @@ class ImageEntry {
AddressDetails addressDetails;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
final isFavouriteNotifier = ValueNotifier(false);
ImageEntry({
this.uri,
@ -44,7 +46,9 @@ class ImageEntry {
this.sourceDateTakenMillis,
this.bucketDisplayName,
this.durationMillis,
}) : directory = path != null ? dirname(path) : null;
}) : directory = path != null ? dirname(path) : null {
isFavouriteNotifier.value = isFavourite;
}
factory ImageEntry.fromMap(Map map) {
return ImageEntry(
@ -86,6 +90,7 @@ class ImageEntry {
imageChangeNotifier.dispose();
metadataChangeNotifier.dispose();
addressChangeNotifier.dispose();
isFavouriteNotifier.dispose();
}
@override
@ -97,6 +102,8 @@ class ImageEntry {
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
bool get isFavourite => favourites.isFavourite(this);
bool get isGif => mimeType == MimeTypes.MIME_GIF;
bool get isSvg => mimeType == MimeTypes.MIME_SVG;
@ -238,4 +245,13 @@ class ImageEntry {
}
Future<bool> delete() => ImageFileService.delete(this);
void toggleFavourite() {
if (isFavourite) {
favourites.remove(this);
} else {
favourites.add(this);
}
isFavouriteNotifier.value = !isFavouriteNotifier.value;
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:geocoder/model.dart';
class CatalogMetadata {
@ -106,3 +107,39 @@ class AddressDetails {
return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
}
}
class FavouriteRow {
final int contentId;
final String path;
FavouriteRow({
this.contentId,
this.path,
});
factory FavouriteRow.fromMap(Map map) {
return FavouriteRow(
contentId: map['contentId'],
path: map['path'] ?? '',
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'path': path,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is FavouriteRow && other.contentId == contentId && other.path == path;
}
@override
int get hashCode => hashValues(contentId, path);
@override
String toString() {
return 'FavouriteRow{contentId=$contentId, path=$path}';
}
}

View file

@ -14,6 +14,7 @@ class MetadataDb {
static const metadataTable = 'metadata';
static const addressTable = 'address';
static const favouriteTable = 'favourites';
MetadataDb._private();
@ -24,6 +25,7 @@ class MetadataDb {
onCreate: (db, version) async {
await db.execute('CREATE TABLE $metadataTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, latitude REAL, longitude REAL)');
await db.execute('CREATE TABLE $addressTable(contentId INTEGER PRIMARY KEY, addressLine TEXT, countryName TEXT, adminArea TEXT, locality TEXT)');
await db.execute('CREATE TABLE $favouriteTable(contentId INTEGER PRIMARY KEY, path TEXT)');
},
version: 1,
);
@ -98,4 +100,53 @@ class MetadataDb {
await batch.commit(noResult: true);
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms with ${addresses.length} entries');
}
// favourites
Future<void> clearFavourites() async {
final db = await _database;
final count = await db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count entries');
}
Future<List<FavouriteRow>> loadFavourites() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
final maps = await db.query(favouriteTable);
final favouriteRows = maps.map((map) => FavouriteRow.fromMap(map)).toList();
// debugPrint('$runtimeType loadFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms with ${favouriteRows.length} entries');
return favouriteRows;
}
Future<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
// final stopwatch = Stopwatch()..start();
final db = await _database;
final batch = db.batch();
favouriteRows.where((row) => row != null).forEach((row) => batch.insert(
favouriteTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
));
await batch.commit(noResult: true);
// debugPrint('$runtimeType addFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms with ${favouriteRows.length} entries');
}
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId);
if (ids.isEmpty) return;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
// final stopwatch = Stopwatch()..start();
final db = await _database;
final batch = db.batch();
ids.forEach((id) => batch.delete(
favouriteTable,
where: 'contentId = ?',
whereArgs: [id],
));
await batch.commit(noResult: true);
// debugPrint('$runtimeType removeFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms with ${favouriteRows.length} entries');
}
}

View file

@ -1,8 +1,14 @@
import 'dart:ui';
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/country.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/gif.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/video.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/color_utils.dart';
@ -81,6 +87,12 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
title: 'GIFs',
filter: GifFilter(),
);
final favouriteEntry = _FilteredCollectionNavTile(
source: source,
leading: const Icon(OMIcons.favoriteBorder),
title: 'Favourites',
filter: FavouriteFilter(),
);
final buildAlbumEntry = (album) => _FilteredCollectionNavTile(
source: source,
leading: IconUtils.getAlbumIcon(context: context, album: album),
@ -131,6 +143,7 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
allMediaEntry,
videoEntry,
gifEntry,
favouriteEntry,
if (specialAlbums.isNotEmpty) ...[
const Divider(),
...specialAlbums.map(buildAlbumEntry),

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/thumbnail_collection.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';

View file

@ -1,4 +1,4 @@
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';

View file

@ -1,5 +1,6 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/metadata_db.dart';
@ -39,6 +40,7 @@ class _MediaStoreCollectionProviderState extends State<MediaStoreCollectionProvi
);
await metadataDb.init(); // <20ms
await favourites.init();
final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone(); // <20ms
final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) {

View file

@ -1,4 +1,5 @@
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
@ -22,6 +23,7 @@ class DebugPageState extends State<DebugPage> {
Future<int> _dbFileSizeLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
List<ImageEntry> get entries => widget.source.entries;
@ -86,6 +88,14 @@ class DebugPageState extends State<DebugPage> {
return Text('DB address rows: ${snapshot.data.length}');
},
),
FutureBuilder(
future: _dbFavouritesLoader,
builder: (context, AsyncSnapshot<List<FavouriteRow>> snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
return Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)');
},
),
const Divider(),
Text('Image cache: ${imageCache.currentSize} items, ${formatFilesize(imageCache.currentSizeBytes)}'),
Text('SVG cache: ${PictureProvider.cacheCount} items'),
@ -110,6 +120,7 @@ class DebugPageState extends State<DebugPage> {
_dbFileSizeLoader = metadataDb.dbFileSize();
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
}

View file

@ -10,7 +10,7 @@ import 'package:pdf/widgets.dart' as pdf;
import 'package:pedantic/pedantic.dart';
import 'package:printing/printing.dart';
enum FullscreenAction { delete, edit, info, open, openMap, print, rename, rotateCCW, rotateCW, setAs, share }
enum FullscreenAction { delete, edit, info, open, openMap, print, rename, rotateCCW, rotateCW, setAs, share, toggleFavourite }
class FullscreenActionDelegate {
final CollectionLens collection;
@ -25,6 +25,9 @@ class FullscreenActionDelegate {
void onActionSelected(BuildContext context, ImageEntry entry, FullscreenAction action) {
switch (action) {
case FullscreenAction.toggleFavourite:
entry.toggleFavourite();
break;
case FullscreenAction.delete:
_showDeleteDialog(context, entry);
break;

View file

@ -1,4 +1,8 @@
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/gif.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/video.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
@ -25,11 +29,6 @@ class BasicSection extends StatelessWidget {
final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final filters = [
if (entry.directory != null) AlbumFilter(entry.directory),
...tags.map((tag) => TagFilter(tag)),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -42,19 +41,31 @@ class BasicSection extends StatelessWidget {
'URI': entry.uri ?? '?',
if (entry.path != null) 'Path': entry.path,
}),
if (filters.isNotEmpty != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.buttonBorderWidth / 2) + const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
children: filters
.map((filter) => AvesFilterChip(
filter: filter,
onPressed: onFilter,
))
.toList(),
),
),
ValueListenableBuilder(
valueListenable: entry.isFavouriteNotifier,
builder: (context, isFavourite, child) {
final filters = [
if (entry.isVideo) VideoFilter(),
if (entry.isGif) GifFilter(),
if (isFavourite) FavouriteFilter(),
if (entry.directory != null) AlbumFilter(entry.directory),
...tags.map((tag) => TagFilter(tag)),
]..sort();
if (filters.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.buttonBorderWidth / 2) + const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
children: filters
.map((filter) => AvesFilterChip(
filter: filter,
onPressed: onFilter,
))
.toList(),
),
);
},
),
],
);
}

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_filters.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/country.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/utils/android_app_service.dart';

View file

@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
@ -37,6 +38,18 @@ class FullscreenTopOverlay extends StatelessWidget {
child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(),
),
const Spacer(),
OverlayButton(
scale: scale,
child: ValueListenableBuilder(
valueListenable: entry.isFavouriteNotifier,
builder: (context, isFavourite, child) => IconButton(
icon: Icon(isFavourite ? Icons.favorite : Icons.favorite_border),
onPressed: () => onActionSelected?.call(FullscreenAction.toggleFavourite),
tooltip: isFavourite ? 'Remove favourite' : 'Add favourite',
),
),
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(