added favourites
This commit is contained in:
parent
5df815e5c1
commit
edd410d854
24 changed files with 524 additions and 266 deletions
|
@ -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);
|
||||
}
|
|
@ -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';
|
||||
|
|
31
lib/model/favourite_repo.dart
Normal file
31
lib/model/favourite_repo.dart
Normal 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);
|
||||
}
|
||||
}
|
68
lib/model/filters/album.dart
Normal file
68
lib/model/filters/album.dart
Normal 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);
|
||||
}
|
33
lib/model/filters/country.dart
Normal file
33
lib/model/filters/country.dart
Normal 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);
|
||||
}
|
30
lib/model/filters/favourite.dart
Normal file
30
lib/model/filters/favourite.dart
Normal 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;
|
||||
}
|
46
lib/model/filters/filters.dart
Normal file
46
lib/model/filters/filters.dart
Normal 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);
|
||||
}
|
||||
}
|
29
lib/model/filters/gif.dart
Normal file
29
lib/model/filters/gif.dart
Normal 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;
|
||||
}
|
41
lib/model/filters/query.dart
Normal file
41
lib/model/filters/query.dart
Normal 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);
|
||||
}
|
33
lib/model/filters/tag.dart
Normal file
33
lib/model/filters/tag.dart
Normal 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);
|
||||
}
|
29
lib/model/filters/video.dart
Normal file
29
lib/model/filters/video.dart
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(() {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue