diff --git a/lib/model/collection_filters.dart b/lib/model/collection_filters.dart deleted file mode 100644 index 595b60e47..000000000 --- a/lib/model/collection_filters.dart +++ /dev/null @@ -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 { - static const List 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(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 _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(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); -} diff --git a/lib/model/collection_lens.dart b/lib/model/collection_lens.dart index 475b41169..c9c54580a 100644 --- a/lib/model/collection_lens.dart +++ b/lib/model/collection_lens.dart @@ -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'; diff --git a/lib/model/favourite_repo.dart b/lib/model/favourite_repo.dart new file mode 100644 index 000000000..00e9a69c5 --- /dev/null +++ b/lib/model/favourite_repo.dart @@ -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 _rows = List(); + + FavouriteRepo._private(); + + Future init() async { + _rows = await metadataDb.loadFavourites(); + } + + int get count => _rows.length; + + bool isFavourite(ImageEntry entry) => _rows.any((row) => row.contentId == entry.contentId); + + Future add(ImageEntry entry) async { + final newRows = [FavouriteRow(contentId: entry.contentId, path: entry.path)]; + await metadataDb.addFavourites(newRows); + _rows.addAll(newRows); + } + + Future remove(ImageEntry entry) async { + final removedRows = [FavouriteRow(contentId: entry.contentId, path: entry.path)]; + await metadataDb.removeFavourites(removedRows); + removedRows.forEach(_rows.remove); + } +} diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart new file mode 100644 index 000000000..9e31fa23d --- /dev/null +++ b/lib/model/filters/album.dart @@ -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 _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(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); +} diff --git a/lib/model/filters/country.dart b/lib/model/filters/country.dart new file mode 100644 index 000000000..c2a5a6b48 --- /dev/null +++ b/lib/model/filters/country.dart @@ -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); +} diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart new file mode 100644 index 000000000..1b4b17c62 --- /dev/null +++ b/lib/model/filters/favourite.dart @@ -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; +} diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart new file mode 100644 index 000000000..099062f14 --- /dev/null +++ b/lib/model/filters/filters.dart @@ -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 { + static const List 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(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); + } +} diff --git a/lib/model/filters/gif.dart b/lib/model/filters/gif.dart new file mode 100644 index 000000000..f193f52b0 --- /dev/null +++ b/lib/model/filters/gif.dart @@ -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; +} diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart new file mode 100644 index 000000000..e7cfadd2a --- /dev/null +++ b/lib/model/filters/query.dart @@ -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); +} diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart new file mode 100644 index 000000000..7a84c3308 --- /dev/null +++ b/lib/model/filters/tag.dart @@ -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); +} diff --git a/lib/model/filters/video.dart b/lib/model/filters/video.dart new file mode 100644 index 000000000..68b22a079 --- /dev/null +++ b/lib/model/filters/video.dart @@ -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; +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index fb9f6cc02..c672eb617 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -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 delete() => ImageFileService.delete(this); + + void toggleFavourite() { + if (isFavourite) { + favourites.remove(this); + } else { + favourites.add(this); + } + isFavouriteNotifier.value = !isFavouriteNotifier.value; + } } diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index ce96d1e97..c799e1c83 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -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 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}'; + } +} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 7d0cdda26..574ac6a39 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -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 clearFavourites() async { + final db = await _database; + final count = await db.delete(favouriteTable, where: '1'); + debugPrint('$runtimeType clearFavourites deleted $count entries'); + } + + Future> 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 addFavourites(Iterable 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 removeFavourites(Iterable 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'); + } } diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index 34b39144d..9a9c73436 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -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 { 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 { allMediaEntry, videoEntry, gifEntry, + favouriteEntry, if (specialAlbums.isNotEmpty) ...[ const Divider(), ...specialAlbums.map(buildAlbumEntry), diff --git a/lib/widgets/album/search_delegate.dart b/lib/widgets/album/search_delegate.dart index a37ebe750..7475d742f 100644 --- a/lib/widgets/album/search_delegate.dart +++ b/lib/widgets/album/search_delegate.dart @@ -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'; diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index ad37a36d5..5fe875608 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -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'; diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/widgets/common/data_providers/media_store_collection_provider.dart index 6aa2c6d1b..98be5209c 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/widgets/common/data_providers/media_store_collection_provider.dart @@ -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 { Future _dbFileSizeLoader; Future> _dbMetadataLoader; Future> _dbAddressLoader; + Future> _dbFavouritesLoader; List get entries => widget.source.entries; @@ -86,6 +88,14 @@ class DebugPageState extends State { return Text('DB address rows: ${snapshot.data.length}'); }, ), + FutureBuilder( + future: _dbFavouritesLoader, + builder: (context, AsyncSnapshot> 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 { _dbFileSizeLoader = metadataDb.dbFileSize(); _dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbAddressLoader = metadataDb.loadAddresses(); + _dbFavouritesLoader = metadataDb.loadFavourites(); setState(() {}); } } diff --git a/lib/widgets/fullscreen/fullscreen_action_delegate.dart b/lib/widgets/fullscreen/fullscreen_action_delegate.dart index 4a0f42710..6484710b5 100644 --- a/lib/widgets/fullscreen/fullscreen_action_delegate.dart +++ b/lib/widgets/fullscreen/fullscreen_action_delegate.dart @@ -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; diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index b6afb5ebc..39f2b9bbe 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -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(), + ), + ); + }, + ), ], ); } diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 34ab2f606..4e77e6d01 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -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'; diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 032283c6d..1c526765a 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -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'; diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index cf1d038cc..4a06e8979 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -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(