#36 set filter cover + service IoC + collection source tests

This commit is contained in:
Thibault Deckers 2021-03-17 17:42:02 +09:00
parent cf8d182cfe
commit 87f1eb6cc7
76 changed files with 1658 additions and 452 deletions

9
lib/app_mode.dart Normal file
View file

@ -0,0 +1,9 @@
enum AppMode { main, pickExternal, pickInternal, view }
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;
}

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -32,7 +32,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await ImageFileService.getRegion(
final bytes = await imageFileService.getRegion(
uri,
mimeType,
key.rotationDegrees,
@ -55,11 +55,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key);
imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => ImageFileService.cancelRegion(key);
void pause() => imageFileService.cancelRegion(key);
}
class RegionProviderKey {

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -33,7 +33,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await ImageFileService.getThumbnail(
final bytes = await imageFileService.getThumbnail(
uri: uri,
mimeType: mimeType,
pageId: pageId,
@ -55,11 +55,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key);
imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => ImageFileService.cancelThumbnail(key);
void pause() => imageFileService.cancelThumbnail(key);
}
class ThumbnailProviderKey {

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart';
@ -46,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
assert(key == this);
try {
final bytes = await ImageFileService.getImage(
final bytes = await imageFileService.getImage(
uri,
mimeType,
rotationDegrees,

View file

@ -1,4 +1,4 @@
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
@ -29,7 +29,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this);
final data = await ImageFileService.getSvg(uri, mimeType);
final data = await imageFileService.getSvg(uri, mimeType);
if (data == null || data.isEmpty) {
return null;
}

View file

@ -49,6 +49,8 @@
"@chipActionUnpin": {},
"chipActionRename": "Rename",
"@chipActionRename": {},
"chipActionSetCover": "Set cover",
"@chipActionSetCover": {},
"entryActionDelete": "Delete",
"@entryActionDelete": {},
@ -210,6 +212,13 @@
}
},
"setCoverDialogTitle": "Set Cover",
"@setCoverDialogTitle": {},
"setCoverDialogLatest": "Latest item",
"@setCoverDialogLatest": {},
"setCoverDialogCustom": "Custom",
"@setCoverDialogCustom": {},
"hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?",
"@hideFilterConfirmationDialogMessage": {},

View file

@ -26,6 +26,7 @@
"chipActionPin": "고정",
"chipActionUnpin": "고정 해제",
"chipActionRename": "이름 변경",
"chipActionSetCover": "대표 이미지 변경",
"entryActionDelete": "삭제",
"entryActionExport": "내보내기",
@ -89,6 +90,10 @@
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
"setCoverDialogTitle": "대표 이미지 변경",
"setCoverDialogLatest": "최근 항목",
"setCoverDialogCustom": "직접 설정",
"newAlbumDialogTitle": "새 앨범 만들기",
"newAlbumDialogNameLabel": "앨범 이름",
"newAlbumDialogNameLabelAlreadyExistsHelper": "사용 중인 이름입니다",

View file

@ -1,11 +1,14 @@
import 'dart:isolate';
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
@ -40,8 +43,6 @@ void main() {
runApp(AvesApp());
}
enum AppMode { main, pick, view }
class AvesApp extends StatefulWidget {
@override
_AvesAppState createState() => _AvesAppState();
@ -61,56 +62,12 @@ class _AvesAppState extends State<AvesApp> {
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
static const accentColor = Colors.indigoAccent;
static final darkTheme = ThemeData(
brightness: Brightness.dark,
accentColor: accentColor,
scaffoldBackgroundColor: Colors.grey[900],
buttonColor: accentColor,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: accentColor,
tooltipTheme: TooltipThemeData(
verticalOffset: 32,
),
appBarTheme: AppBarTheme(
textTheme: TextTheme(
headline6: TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
color: Colors.white,
),
behavior: SnackBarBehavior.floating,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,
),
),
);
Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
@override
void initState() {
super.initState();
initPlatformServices();
_appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
@ -145,7 +102,7 @@ class _AvesAppState extends State<AvesApp> {
home: home,
navigatorObservers: _navigatorObservers,
onGenerateTitle: (context) => context.l10n.appName,
darkTheme: darkTheme,
darkTheme: Themes.darkTheme,
themeMode: ThemeMode.dark,
locale: settingsLocale,
localizationsDelegates: [

View file

@ -14,6 +14,7 @@ enum ChipAction {
pin,
unpin,
rename,
setCover,
goToAlbumPage,
goToCountryPage,
goToTagPage,
@ -38,6 +39,8 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionUnpin;
case ChipAction.rename:
return context.l10n.chipActionRename;
case ChipAction.setCover:
return context.l10n.chipActionSetCover;
}
return null;
}
@ -59,6 +62,8 @@ extension ExtraChipAction on ChipAction {
return AIcons.pin;
case ChipAction.rename:
return AIcons.rename;
case ChipAction.setCover:
return AIcons.setCover;
}
return null;
}

View file

@ -8,17 +8,29 @@ import 'package:google_api_availability/google_api_availability.dart';
import 'package:package_info/package_info.dart';
import 'package:version/version.dart';
final AvesAvailability availability = AvesAvailability._private();
abstract class AvesAvailability {
void onResume();
class AvesAvailability {
Future<bool> get isConnected;
Future<bool> get hasPlayServices;
Future<bool> get canLocatePlaces;
Future<bool> get isNewVersionAvailable;
}
class LiveAvesAvailability implements AvesAvailability {
bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
AvesAvailability._private() {
LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
}
@override
void onResume() => _isConnected = null;
@override
Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity());
@ -34,6 +46,7 @@ class AvesAvailability {
}
}
@override
Future<bool> get hasPlayServices async {
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
@ -43,8 +56,10 @@ class AvesAvailability {
}
// local geocoding with `geocoder` requires Play Services
@override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);

111
lib/model/covers.dart Normal file
View file

@ -0,0 +1,111 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final Covers covers = Covers._private();
class Covers with ChangeNotifier {
Set<CoverRow> _rows = {};
Covers._private();
Future<void> init() async {
_rows = await metadataDb.loadCovers();
}
int get count => _rows.length;
int coverContentId(CollectionFilter filter) => _rows.firstWhere((row) => row.filter == filter, orElse: () => null)?.contentId;
Future<void> set(CollectionFilter filter, int contentId) async {
// erase contextual properties from filters before saving them
if (filter is AlbumFilter) {
filter = AlbumFilter((filter as AlbumFilter).album, null);
}
final row = CoverRow(filter: filter, contentId: contentId);
_rows.removeWhere((row) => row.filter == filter);
if (contentId == null) {
await metadataDb.removeCovers({row});
} else {
_rows.add(row);
await metadataDb.addCovers({row});
}
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet();
if (oldRows.isEmpty) return;
for (final oldRow in oldRows) {
final filter = oldRow.filter;
_rows.remove(oldRow);
if (filter.test(entry)) {
final newRow = CoverRow(filter: filter, contentId: entry.contentId);
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
_rows.add(newRow);
} else {
await metadataDb.removeCovers({oldRow});
}
}
notifyListeners();
}
Future<void> removeEntries(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
await metadataDb.removeCovers(removedRows);
_rows.removeAll(removedRows);
notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearCovers();
_rows.clear();
notifyListeners();
}
}
@immutable
class CoverRow {
final CollectionFilter filter;
final int contentId;
const CoverRow({
@required this.filter,
@required this.contentId,
});
factory CoverRow.fromMap(Map map) {
return CoverRow(
filter: CollectionFilter.fromJson(map['filter']),
contentId: map['contentId'],
);
}
Map<String, dynamic> toMap() => {
'filter': filter.toJson(),
'contentId': contentId,
};
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is CoverRow && other.filter == filter && other.contentId == contentId;
}
@override
int get hashCode => hashValues(filter, contentId);
@override
String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}';
}

View file

@ -3,15 +3,13 @@ import 'dart:async';
import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
@ -34,7 +32,7 @@ class AvesEntry {
int height;
int sourceRotationDegrees;
final int sizeBytes;
String sourceTitle;
String _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode
int _dateModifiedSecs;
@ -59,13 +57,14 @@ class AvesEntry {
@required this.height,
this.sourceRotationDegrees,
this.sizeBytes,
this.sourceTitle,
String sourceTitle,
int dateModifiedSecs,
this.sourceDateTakenMillis,
this.durationMillis,
}) : assert(width != null),
assert(height != null) {
this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs;
}
@ -74,14 +73,14 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({
@required String uri,
@required String path,
@required int contentId,
@required int dateModifiedSecs,
String uri,
String path,
int contentId,
int dateModifiedSecs,
}) {
final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry(
uri: uri ?? uri,
uri: uri ?? this.uri,
path: path ?? this.path,
contentId: copyContentId,
sourceMimeType: sourceMimeType,
@ -90,7 +89,7 @@ class AvesEntry {
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs,
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
)
@ -342,6 +341,13 @@ class AvesEntry {
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
String get sourceTitle => _sourceTitle;
set sourceTitle(String sourceTitle) {
_sourceTitle = sourceTitle;
_bestTitle = null;
}
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
@ -439,7 +445,7 @@ class AvesEntry {
}
catalogMetadata = CatalogMetadata(contentId: contentId);
} else {
catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background);
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
}
}
@ -553,10 +559,7 @@ class AvesEntry {
final contentId = newFields['contentId'];
if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title'];
if (sourceTitle is String) {
this.sourceTitle = sourceTitle;
_bestTitle = null;
}
if (sourceTitle is String) this.sourceTitle = sourceTitle;
final width = newFields['width'];
if (width is int) this.width = width;
@ -576,18 +579,8 @@ class AvesEntry {
metadataChangeNotifier.notifyListeners();
}
Future<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
await _applyNewFields(newFields);
return true;
}
Future<bool> rotate({@required bool clockwise}) async {
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
final newFields = await imageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
@ -599,7 +592,7 @@ class AvesEntry {
}
Future<bool> flip() async {
final newFields = await ImageFileService.flip(this);
final newFields = await imageFileService.flip(this);
if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
@ -612,7 +605,7 @@ class AvesEntry {
Future<bool> delete() {
Completer completer = Completer<bool>();
ImageFileService.delete([this]).listen(
imageFileService.delete([this]).listen(
(event) => completer.complete(event.success),
onError: completer.completeError,
onDone: () {
@ -634,23 +627,23 @@ class AvesEntry {
// favourites
void toggleFavourite() {
Future<void> toggleFavourite() async {
if (isFavourite) {
removeFromFavourites();
await removeFromFavourites();
} else {
addToFavourites();
await addToFavourites();
}
}
void addToFavourites() {
Future<void> addToFavourites() async {
if (!isFavourite) {
favourites.add([this]);
await favourites.add([this]);
}
}
void removeFromFavourites() {
Future<void> removeFromFavourites() async {
if (isFavourite) {
favourites.remove([this]);
await favourites.remove([this]);
}
}

View file

@ -1,60 +0,0 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/utils/change_notifier.dart';
final FavouriteRepo favourites = FavouriteRepo._private();
class FavouriteRepo {
List<FavouriteRow> _rows = [];
final AChangeNotifier changeNotifier = AChangeNotifier();
FavouriteRepo._private();
Future<void> init() async {
_rows = await metadataDb.loadFavourites();
}
int get count => _rows.length;
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
changeNotifier.notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final removedRows = entries.map(_entryToRow);
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
changeNotifier.notifyListeners();
}
Future<void> move(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
changeNotifier.notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
changeNotifier.notifyListeners();
}
}

96
lib/model/favourites.dart Normal file
View file

@ -0,0 +1,96 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
final Favourites favourites = Favourites._private();
class Favourites with ChangeNotifier {
Set<FavouriteRow> _rows = {};
Favourites._private();
Future<void> init() async {
_rows = await metadataDb.loadFavourites();
}
int get count => _rows.length;
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
Future<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
if (oldRow == null) return;
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
notifyListeners();
}
}
@immutable
class FavouriteRow {
final int contentId;
final String path;
const 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() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
}

View file

@ -204,38 +204,3 @@ class AddressDetails {
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
}
@immutable
class FavouriteRow {
final int contentId;
final String path;
const 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() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
}

View file

@ -1,15 +1,85 @@
import 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
final MetadataDb metadataDb = MetadataDb._private();
abstract class MetadataDb {
Future<void> init();
class MetadataDb {
Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly});
// entries
Future<void> clearEntries();
Future<Set<AvesEntry>> loadEntries();
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntryId(int oldId, AvesEntry entry);
// date taken
Future<void> clearDates();
Future<List<DateMetadata>> loadDates();
// catalog metadata
Future<void> clearMetadataEntries();
Future<List<CatalogMetadata>> loadMetadataEntries();
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata);
// address
Future<void> clearAddresses();
Future<List<AddressDetails>> loadAddresses();
Future<void> saveAddresses(Iterable<AddressDetails> addresses);
Future<void> updateAddressId(int oldId, AddressDetails address);
// favourites
Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
// covers
Future<void> clearCovers();
Future<Set<CoverRow>> loadCovers();
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Iterable<CoverRow> rows);
}
class SqfliteMetadataDb implements MetadataDb {
Future<Database> _database;
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
@ -19,9 +89,9 @@ class MetadataDb {
static const metadataTable = 'metadata';
static const addressTable = 'address';
static const favouriteTable = 'favourites';
static const coverTable = 'covers';
MetadataDb._private();
@override
Future<void> init() async {
debugPrint('$runtimeType init');
_database = openDatabase(
@ -68,17 +138,23 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY'
', path TEXT'
')');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
},
onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 3,
version: 4,
);
}
@override
Future<int> dbFileSize() async {
final file = File((await path));
return await file.exists() ? file.length() : 0;
}
@override
Future<void> reset() async {
debugPrint('$runtimeType reset');
await (await _database).close();
@ -86,7 +162,8 @@ class MetadataDb {
await init();
}
void removeIds(Set<int> contentIds, {@required bool updateFavourites}) async {
@override
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) async {
if (contentIds == null || contentIds.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -100,8 +177,9 @@ class MetadataDb {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
batch.delete(addressTable, where: where, whereArgs: whereArgs);
if (updateFavourites) {
if (!metadataOnly) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs);
}
});
await batch.commit(noResult: true);
@ -110,12 +188,14 @@ class MetadataDb {
// entries
@override
Future<void> clearEntries() async {
final db = await _database;
final count = await db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count entries');
}
@override
Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start();
final db = await _database;
@ -125,6 +205,7 @@ class MetadataDb {
return entries;
}
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries == null || entries.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -135,6 +216,7 @@ class MetadataDb {
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
}
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database;
final batch = db.batch();
@ -154,12 +236,14 @@ class MetadataDb {
// date taken
@override
Future<void> clearDates() async {
final db = await _database;
final count = await db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count entries');
}
@override
Future<List<DateMetadata>> loadDates() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -171,12 +255,14 @@ class MetadataDb {
// catalog metadata
@override
Future<void> clearMetadataEntries() async {
final db = await _database;
final count = await db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
}
@override
Future<List<CatalogMetadata>> loadMetadataEntries() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -186,6 +272,7 @@ class MetadataDb {
return metadataEntries;
}
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -200,6 +287,7 @@ class MetadataDb {
}
}
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
final db = await _database;
final batch = db.batch();
@ -227,12 +315,14 @@ class MetadataDb {
// address
@override
Future<void> clearAddresses() async {
final db = await _database;
final count = await db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count entries');
}
@override
Future<List<AddressDetails>> loadAddresses() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -242,6 +332,7 @@ class MetadataDb {
return addresses;
}
@override
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async {
if (addresses == null || addresses.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -252,6 +343,7 @@ class MetadataDb {
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
}
@override
Future<void> updateAddressId(int oldId, AddressDetails address) async {
final db = await _database;
final batch = db.batch();
@ -271,31 +363,31 @@ class MetadataDb {
// favourites
@override
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();
@override
Future<Set<FavouriteRow>> loadFavourites() async {
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 for ${favouriteRows.length} entries');
return favouriteRows;
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
return rows;
}
Future<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
// final stopwatch = Stopwatch()..start();
@override
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
if (rows == null || rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
favouriteRows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row));
rows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row));
await batch.commit(noResult: true);
// debugPrint('$runtimeType addFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries');
}
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database;
final batch = db.batch();
@ -313,9 +405,10 @@ class MetadataDb {
);
}
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId);
@override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
if (rows == null || rows.isEmpty) return;
final ids = rows.where((row) => row != null).map((row) => row.contentId);
if (ids.isEmpty) return;
final db = await _database;
@ -324,4 +417,61 @@ class MetadataDb {
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
await batch.commit(noResult: true);
}
// covers
@override
Future<void> clearCovers() async {
final db = await _database;
final count = await db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count entries');
}
@override
Future<Set<CoverRow>> loadCovers() async {
final db = await _database;
final maps = await db.query(coverTable);
final rows = maps.map((map) => CoverRow.fromMap(map)).toSet();
return rows;
}
@override
Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows == null || rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
rows.where((row) => row != null).forEach((row) => _batchInsertCover(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
_batchInsertCover(batch, row);
await batch.commit(noResult: true);
}
void _batchInsertCover(Batch batch, CoverRow row) {
if (row == null) return;
batch.insert(
coverTable,
row.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
@override
Future<void> removeCovers(Iterable<CoverRow> rows) async {
if (rows == null || rows.isEmpty) return;
final filters = rows.where((row) => row != null).map((row) => row.filter);
if (filters.isEmpty) return;
final db = await _database;
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
await batch.commit(noResult: true);
}
}

View file

@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader {
static const entryTable = MetadataDb.entryTable;
static const metadataTable = MetadataDb.metadataTable;
static const entryTable = SqfliteMetadataDb.entryTable;
static const metadataTable = SqfliteMetadataDb.metadataTable;
static const coverTable = SqfliteMetadataDb.coverTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
@ -17,6 +18,9 @@ class MetadataDbUpgrader {
case 2:
await _upgradeFrom2(db);
break;
case 3:
await _upgradeFrom3(db);
break;
}
oldVersion++;
}
@ -97,4 +101,12 @@ class MetadataDbUpgrader {
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
}
static Future<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}
}

View file

@ -1,19 +1,20 @@
import 'dart:async';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
@ -99,10 +100,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent(entries));
}
void removeEntries(Set<String> uris) {
Future<void> removeEntries(Set<String> uris) async {
if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
entries.forEach((entry) => entry.removeFromFavourites());
await favourites.remove(entries);
await covers.removeEntries(entries);
_rawEntries.removeAll(entries);
_invalidate(entries);
@ -121,30 +123,61 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateTags();
}
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.contentId = newContentId;
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs;
entry.path = newFields['path'] as String;
entry.uri = newFields['uri'] as String;
entry.contentId = newContentId;
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
if (newFields.containsKey('path')) entry.path = newFields['path'] as String;
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
if (isFavourite) {
await favourites.move(oldContentId, entry);
await favourites.moveEntry(oldContentId, entry);
await covers.moveEntry(oldContentId, entry);
}
Future<bool> renameEntry(AvesEntry entry, String newName) async {
if (newName == entry.filenameWithoutExtension) return true;
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false;
await _moveEntry(entry, newFields);
entry.metadataChangeNotifier.notifyListeners();
return true;
}
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
final oldFilter = AlbumFilter(sourceAlbum, null);
final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhere((entry) => entry.contentId == oldCoverContentId, orElse: () => null) : null;
await updateAfterMove(
todoEntries: todoEntries,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// restore pin and cover, as the obsolete album got removed and its associated state cleaned
final newFilter = AlbumFilter(destinationAlbum, null);
if (pinned) {
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
if (coverEntry != null) {
await covers.set(newFilter, coverEntry.contentId);
}
}
Future<void> updateAfterMove({
@required Set<AvesEntry> todoEntries,
@required Set<AvesEntry> favouriteEntries,
@required bool copy,
@required String destinationAlbum,
@required Set<MoveOpEvent> movedOps,
@ -178,10 +211,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
// do not rely on current favourite repo state to assess whether the moved entry is a favourite
// as source monitoring may already have removed the entry from the favourite repo
final isFavourite = favouriteEntries.contains(entry);
await _moveEntry(entry, newFields, isFavourite);
await _moveEntry(entry, newFields);
}
}
});
@ -232,6 +262,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return null;
}
AvesEntry coverEntry(CollectionFilter filter) {
final contentId = covers.coverContentId(filter);
if (contentId != null) {
final entry = visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
if (entry != null) return entry;
}
return recentEntry(filter);
}
void changeFilterVisibility(CollectionFilter filter, bool visible) {
final hiddenFilters = settings.hiddenFilters;
if (visible) {

View file

@ -5,9 +5,9 @@ import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart';

View file

@ -1,15 +1,13 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
@ -27,7 +25,8 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading;
await metadataDb.init();
await favourites.init();
final currentTimeZone = await TimeService.getDefaultTimeZone();
await covers.init();
final currentTimeZone = await timeService.getDefaultTimeZone();
final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone
@ -51,7 +50,7 @@ class MediaStoreSource extends CollectionSource {
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries
@ -61,11 +60,11 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
// verify paths because some apps move files without updating their `last modified date`
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
final movedContentIds = (await MediaStoreService.checkObsoletePaths(knownPathById)).toSet();
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date
knownDateById[contentId] = 0;
@ -82,7 +81,7 @@ class MediaStoreSource extends CollectionSource {
pendingNewEntries.clear();
}
MediaStoreService.getEntries(knownDateById).listen(
mediaStoreService.getEntries(knownDateById).listen(
(entry) {
pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) {
@ -115,6 +114,7 @@ class MediaStoreSource extends CollectionSource {
}
void _reportCollectionDimensions() {
if (!settings.isCrashlyticsEnabled) return;
final analytics = FirebaseAnalytics();
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
@ -142,9 +142,9 @@ class MediaStoreSource extends CollectionSource {
}).where((kv) => kv != null));
// clean up obsolete entries
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
removeEntries(obsoleteUris);
await removeEntries(obsoleteUris);
obsoleteContentIds.forEach(uriByContentId.remove);
// fetch new entries
@ -154,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
for (final kv in uriByContentId.entries) {
final contentId = kv.key;
final uri = kv.value;
final sourceEntry = await ImageFileService.getEntry(uri, null);
final sourceEntry = await imageFileService.getEntry(uri, null);
if (sourceEntry != null) {
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
// compare paths because some apps move files without updating their `last modified date`
@ -189,7 +189,7 @@ class MediaStoreSource extends CollectionSource {
@override
Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false);
metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh();
}
}

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

View file

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -30,7 +30,7 @@ class AppShortcutService {
Uint8List iconBytes;
if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail(
iconBytes = await imageFileService.getThumbnail(
uri: entry.uri,
mimeType: entry.mimeType,
pageId: entry.pageId,

View file

@ -11,7 +11,82 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
class ImageFileService {
abstract class ImageFileService {
Future<AvesEntry> getEntry(String uri, String mimeType);
Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
});
Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped, {
int pageId,
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
});
// `rect`: region to decode, with coordinates in reference to `imageSize`
Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped,
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
int pageId,
Object taskKey,
int priority,
});
Future<Uint8List> getThumbnail({
@required String uri,
@required String mimeType,
@required int rotationDegrees,
@required int pageId,
@required bool isFlipped,
@required int dateModifiedSecs,
@required double extent,
Object taskKey,
int priority,
});
Future<void> clearSizedThumbnailDiskCache();
bool cancelRegion(Object taskKey);
bool cancelThumbnail(Object taskKey);
Future<T> resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
});
Future<Map> rename(AvesEntry entry, String newName);
Future<Map> rotate(AvesEntry entry, {@required bool clockwise});
Future<Map> flip(AvesEntry entry);
}
class PlatformImageFileService implements ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
@ -31,7 +106,8 @@ class ImageFileService {
};
}
static Future<AvesEntry> getEntry(String uri, String mimeType) async {
@override
Future<AvesEntry> getEntry(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri,
@ -44,7 +120,8 @@ class ImageFileService {
return null;
}
static Future<Uint8List> getSvg(
@override
Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
@ -59,7 +136,8 @@ class ImageFileService {
onBytesReceived: onBytesReceived,
);
static Future<Uint8List> getImage(
@override
Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
@ -106,8 +184,8 @@ class ImageFileService {
return Future.sync(() => null);
}
// `rect`: region to decode, with coordinates in reference to `imageSize`
static Future<Uint8List> getRegion(
@override
Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
@ -145,7 +223,8 @@ class ImageFileService {
);
}
static Future<Uint8List> getThumbnail({
@override
Future<Uint8List> getThumbnail({
@required String uri,
@required String mimeType,
@required int rotationDegrees,
@ -184,7 +263,8 @@ class ImageFileService {
);
}
static Future<void> clearSizedThumbnailDiskCache() async {
@override
Future<void> clearSizedThumbnailDiskCache() async {
try {
return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) {
@ -192,13 +272,17 @@ class ImageFileService {
}
}
static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]);
@override
bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]);
static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
@override
bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override
Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
@override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
@ -210,7 +294,8 @@ class ImageFileService {
}
}
static Stream<MoveOpEvent> move(
@override
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
@ -228,7 +313,8 @@ class ImageFileService {
}
}
static Stream<ExportOpEvent> export(
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
@ -246,7 +332,8 @@ class ImageFileService {
}
}
static Future<Map> rename(AvesEntry entry, String newName) async {
@override
Future<Map> rename(AvesEntry entry, String newName) async {
try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -260,7 +347,8 @@ class ImageFileService {
return {};
}
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
@override
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -274,7 +362,8 @@ class ImageFileService {
return {};
}
static Future<Map> flip(AvesEntry entry) async {
@override
Future<Map> flip(AvesEntry entry) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{

View file

@ -5,11 +5,21 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart';
class MediaStoreService {
abstract class MediaStoreService {
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds);
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById);
// knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
}
class PlatformMediaStoreService implements MediaStoreService {
static const platform = MethodChannel('deckers.thibault/aves/mediastore');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
@override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds,
@ -21,7 +31,8 @@ class MediaStoreService {
return [];
}
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
@override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById,
@ -33,8 +44,8 @@ class MediaStoreService {
return [];
}
// knownEntries: map of contentId -> dateModifiedSecs
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,

View file

@ -8,11 +8,32 @@ import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class MetadataService {
abstract class MetadataService {
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
Future<Map> getAllMetadata(AvesEntry entry);
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false});
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry);
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry);
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getEmbeddedPictures(String uri);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformMetadataService implements MetadataService {
static const platform = MethodChannel('deckers.thibault/aves/metadata');
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
static Future<Map> getAllMetadata(AvesEntry entry) async {
@override
Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null;
try {
@ -28,7 +49,8 @@ class MetadataService {
return {};
}
static Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
@override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
if (entry.isSvg) return null;
Future<CatalogMetadata> call() async {
@ -65,7 +87,8 @@ class MetadataService {
: call();
}
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
@override
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null;
try {
@ -82,7 +105,8 @@ class MetadataService {
return null;
}
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
@override
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType,
@ -96,7 +120,8 @@ class MetadataService {
return null;
}
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
@override
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
try {
// returns map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
@ -113,7 +138,8 @@ class MetadataService {
return null;
}
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
@override
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'mimeType': entry.mimeType,
@ -126,7 +152,8 @@ class MetadataService {
return null;
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
@override
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri,
@ -138,7 +165,8 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
@ -152,7 +180,8 @@ class MetadataService {
return [];
}
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,

View file

@ -0,0 +1,27 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/time_service.dart';
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
final timeService = getIt<TimeService>();
void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
}

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -17,7 +17,7 @@ class SvgMetadataService {
static Future<Size> getSize(AvesEntry entry) async {
try {
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement;
@ -59,7 +59,7 @@ class SvgMetadataService {
}
try {
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement;

View file

@ -1,10 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class TimeService {
abstract class TimeService {
Future<String> getDefaultTimeZone();
}
class PlatformTimeService implements TimeService {
static const platform = MethodChannel('deckers.thibault/aves/time');
static Future<String> getDefaultTimeZone() async {
@override
Future<String> getDefaultTimeZone() async {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) {

View file

@ -48,6 +48,7 @@ class AIcons {
static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined;
static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined;
static const IconData stats = Icons.pie_chart_outlined;

51
lib/theme/themes.dart Normal file
View file

@ -0,0 +1,51 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class Themes {
static const _accentColor = Colors.indigoAccent;
static final darkTheme = ThemeData(
brightness: Brightness.dark,
accentColor: _accentColor,
scaffoldBackgroundColor: Colors.grey[900],
buttonColor: _accentColor,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: _accentColor,
tooltipTheme: TooltipThemeData(
verticalOffset: 32,
),
appBarTheme: AppBarTheme(
textTheme: TextTheme(
headline6: TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')],
),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
color: Colors.white,
),
behavior: SnackBarBehavior.floating,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: _accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: _accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,
),
),
);
}

View file

@ -159,6 +159,12 @@ class Constants {
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
),
Dependency(
name: 'Get It',
license: 'MIT',
licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency(
name: 'Github',
license: 'MIT',

View file

@ -1,4 +1,4 @@
import 'package:aves/model/availability.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/about/news_badge.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/filters/filters.dart';
@ -96,24 +96,29 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {
return AnimatedBuilder(
animation: collection.filterChangeNotifier,
builder: (context, child) => SliverAppBar(
leading: _buildAppBarLeading(),
title: _buildAppBarTitle(),
actions: _buildActions(),
bottom: hasFilters
? FilterBar(
filters: collection.filters,
onPressed: collection.removeFilter,
)
: null,
titleSpacing: 0,
floating: true,
),
builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal;
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading() : null,
title: _buildAppBarTitle(),
actions: _buildActions(),
bottom: hasFilters
? FilterBar(
filters: collection.filters,
removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null,
)
: null,
titleSpacing: 0,
floating: true,
);
},
);
},
);
@ -143,7 +148,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarTitle() {
if (collection.isBrowsing) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode == AppMode.pick ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
if (appMode == AppMode.main) {
title = SourceStateAwareAppBarTitle(
title: title,
@ -151,7 +156,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
return InteractiveAppBarTitle(
onTap: _goToSearch,
onTap: appMode.canSearch ? _goToSearch : null,
child: title,
);
} else if (collection.isSelecting) {
@ -167,8 +172,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
List<Widget> _buildActions() {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return [
if (collection.isBrowsing)
if (collection.isBrowsing && appMode.canSearch)
CollectionSearchButton(
source,
parentCollection: collection,
@ -193,7 +199,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
itemBuilder: (context) {
final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty;
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
return [
PopupMenuItem(
key: Key('menu-sort'),
@ -206,19 +211,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
),
if (collection.isBrowsing) ...[
if (isMainMode)
PopupMenuItem(
value: CollectionAction.select,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
),
if (collection.isBrowsing && appMode == AppMode.main) ...[
PopupMenuItem(
value: CollectionAction.select,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.stats,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
),
if (isMainMode && canAddShortcuts)
if (canAddShortcuts)
PopupMenuItem(
value: CollectionAction.addShortcut,
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
@ -34,6 +34,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class CollectionGrid extends StatefulWidget {
final String settingsRouteKey;
const CollectionGrid({
this.settingsRouteKey,
});
@override
_CollectionGridState createState() => _CollectionGridState();
}
@ -44,7 +50,7 @@ class _CollectionGridState extends State<CollectionGrid> {
@override
Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController(
settingsRouteKey: context.currentRouteName,
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName,
columnCountDefault: 4,
extentMin: 46,
spacing: 0,

View file

@ -8,8 +8,8 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -99,19 +99,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final copy = moveType == MoveType.copy;
final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring();
showOpReport<MoveOpEvent>(
context: context,
opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
opStream: imageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
itemCount: todoCount,
onDone: (processed) async {
final movedOps = processed.where((e) => e.success).toSet();
await source.updateAfterMove(
todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
@ -119,13 +115,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
collection.browse();
source.resumeMonitoring();
final l10n = context.l10n;
final movedCount = movedOps.length;
if (movedCount < todoCount) {
final count = todoCount - movedCount;
showFeedback(context, copy ? context.l10n.collectionCopyFailureFeedback(count) : context.l10n.collectionMoveFailureFeedback(count));
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else {
final count = movedCount;
showFeedback(context, copy ? context.l10n.collectionCopySuccessFeedback(count) : context.l10n.collectionMoveSuccessFeedback(count));
showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count));
}
},
);
@ -161,11 +158,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
context: context,
opStream: ImageFileService.delete(selection),
opStream: imageFileService.delete(selection),
itemCount: selectionCount,
onDone: (processed) {
onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
source.removeEntries(deletedUris);
await source.removeEntries(deletedUris);
collection.browse();
source.resumeMonitoring();

View file

@ -8,12 +8,14 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
final List<CollectionFilter> filters;
final FilterCallback onPressed;
final bool removable;
final FilterCallback onTap;
FilterBar({
Key key,
@required Set<CollectionFilter> filters,
@required this.onPressed,
@required this.removable,
this.onTap,
}) : filters = List<CollectionFilter>.from(filters)..sort(),
super(key: key);
@ -26,7 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
class _FilterBarState extends State<FilterBar> {
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
CollectionFilter _userRemovedFilter;
CollectionFilter _userTappedFilter;
FilterCallback get onTap => widget.onTap;
@override
void didUpdateWidget(covariant FilterBar oldWidget) {
@ -41,7 +45,7 @@ class _FilterBarState extends State<FilterBar> {
existing.removeAt(index);
// only animate item removal when triggered by a user interaction with the chip,
// not from automatic chip replacement following chip selection
final animate = _userRemovedFilter == filter;
final animate = _userTappedFilter == filter;
listState.removeItem(
index,
animate
@ -70,7 +74,7 @@ class _FilterBarState extends State<FilterBar> {
duration: Duration.zero,
);
});
_userRemovedFilter = null;
_userTappedFilter = null;
}
@override
@ -106,12 +110,14 @@ class _FilterBarState extends State<FilterBar> {
child: AvesFilterChip(
key: ValueKey(filter),
filter: filter,
removable: true,
removable: widget.removable,
heroType: HeroType.always,
onTap: (filter) {
_userRemovedFilter = filter;
widget.onPressed(filter);
},
onTap: onTap != null
? (filter) {
_userTappedFilter = filter;
onTap(filter);
}
: null,
),
),
);

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart';
@ -29,14 +29,22 @@ class InteractiveThumbnail extends StatelessWidget {
key: ValueKey(entry.uri),
onTap: () {
final appMode = context.read<ValueNotifier<AppMode>>().value;
if (appMode == AppMode.main) {
if (collection.isBrowsing) {
_goToViewer(context);
} else if (collection.isSelecting) {
collection.toggleSelection(entry);
}
} else if (appMode == AppMode.pick) {
ViewerService.pick(entry.uri);
switch (appMode) {
case AppMode.main:
if (collection.isBrowsing) {
_goToViewer(context);
} else if (collection.isSelecting) {
collection.toggleSelection(entry);
}
break;
case AppMode.pickExternal:
ViewerService.pick(entry.uri);
break;
case AppMode.pickInternal:
Navigator.pop(context, entry);
break;
case AppMode.view:
break;
}
},
child: MetaData(

View file

@ -7,7 +7,12 @@ mixin FeedbackMixin {
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
void showFeedback(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
showFeedbackWithMessenger(ScaffoldMessenger.of(context), message);
}
// provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message) {
messenger.showSnackBar(SnackBar(
content: Text(message),
duration: Durations.opToastDisplay,
));

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
@ -24,11 +24,11 @@ class AvesFilterChip extends StatefulWidget {
final bool showGenericIcon;
final Widget background;
final Widget details;
final BorderRadius borderRadius;
final double padding;
final HeroType heroType;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
final BorderRadius borderRadius;
static const Color defaultOutlineColor = Colors.white;
static const double defaultRadius = 32;
@ -100,6 +100,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
double get padding => widget.padding;
FilterCallback get onTap => widget.onTap;
OffsetFilterCallback get onLongPress => widget.onLongPress;
@override
void initState() {
super.initState();
@ -218,14 +222,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: (details) => _tapPosition = details.globalPosition,
onTap: widget.onTap != null
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null
? () {
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter));
WidgetsBinding.instance.addPostFrameCallback((_) => onTap(filter));
setState(() => _tapped = true);
}
: null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
onLongPress: onLongPress != null ? () => onLongPress(context, filter, _tapPosition) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,

View file

@ -1,4 +1,4 @@
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
@ -60,7 +60,7 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
),
SizedBox(width: 8),
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
onPressed: imageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
],

View file

@ -1,7 +1,8 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
@ -17,7 +18,8 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<Set<FavouriteRow>> _dbFavouritesLoader;
Future<Set<CoverRow>> _dbCoversLoader;
@override
void initState() {
@ -141,7 +143,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<List>(
FutureBuilder<Set>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
@ -162,6 +164,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<Set>(
future: _dbCoversLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('cover rows: ${snapshot.data.length} (${covers.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => covers.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
],
),
),
@ -176,6 +199,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
_dbCoversLoader = metadataDb.loadCovers();
setState(() {});
}

View file

@ -0,0 +1,133 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class CoverSelectionDialog extends StatefulWidget {
final CollectionFilter filter;
final AvesEntry customEntry;
const CoverSelectionDialog({
@required this.filter,
@required this.customEntry,
});
@override
_CoverSelectionDialogState createState() => _CoverSelectionDialogState();
}
class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
bool _isCustom;
AvesEntry _customEntry;
CollectionFilter get filter => widget.filter;
@override
void initState() {
super.initState();
_customEntry = widget.customEntry;
_isCustom = _customEntry != null;
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Builder(
builder: (context) {
final l10n = context.l10n;
final shortestSide = context.select<MediaQueryData, double>((mq) => mq.size.shortestSide);
final extent = (shortestSide / 3.0).clamp(60.0, 160.0);
return AvesDialog(
context: context,
title: l10n.setCoverDialogTitle,
scrollableContent: [
...[false, true].map(
(isCustom) {
final title = Text(
isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogLatest,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
return RadioListTile(
value: isCustom,
groupValue: _isCustom,
onChanged: (v) {
if (v && _customEntry == null) {
_pickEntry();
return;
}
_isCustom = v;
setState(() {});
},
title: isCustom
? Row(children: [
title,
Spacer(),
IconButton(
onPressed: _isCustom ? _pickEntry : null,
tooltip: 'Change',
icon: Icon(AIcons.setCover),
),
])
: title,
);
},
),
Container(
alignment: Alignment.center,
padding: EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip(
filter: filter,
extent: extent,
coverEntry: _isCustom ? _customEntry : null,
onTap: (filter) => _pickEntry(),
),
),
],
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, Tuple2<bool, AvesEntry>(_isCustom, _customEntry)),
child: Text(l10n.applyButtonLabel),
),
],
);
},
),
);
}
Future<void> _pickEntry() async {
final entry = await Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: ItemPickDialog.routeName),
builder: (context) => ItemPickDialog(
CollectionLens(
source: context.read<CollectionSource>(),
filters: [filter],
),
),
fullscreenDialog: true,
),
);
if (entry != null) {
_customEntry = entry;
_isCustom = true;
setState(() {});
}
}
}

View file

@ -0,0 +1,51 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ItemPickDialog extends StatefulWidget {
static const routeName = '/item_pick';
final CollectionLens collection;
const ItemPickDialog(this.collection);
@override
_ItemPickDialogState createState() => _ItemPickDialogState();
}
class _ItemPickDialogState extends State<ItemPickDialog> {
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableProvider<ValueNotifier<AppMode>>.value(
value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider(
child: Scaffold(
body: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: CollectionGrid(
settingsRouteKey: CollectionPage.routeName,
),
),
),
),
),
),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:ui';
import 'package:aves/model/availability.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/source/album.dart';
@ -8,6 +7,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';

View file

@ -40,6 +40,7 @@ class AlbumListPage extends StatelessWidget {
chipActionDelegate: AlbumChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.rename,
ChipAction.delete,
ChipAction.hide,

View file

@ -1,19 +1,22 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
@ -21,6 +24,7 @@ import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
@ -34,6 +38,9 @@ class ChipActionDelegate {
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.setCover:
_showCoverSelectionDialog(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage());
break;
@ -74,6 +81,22 @@ class ChipActionDelegate {
source.changeFilterVisibility(filter, false);
}
void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
@ -140,11 +163,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
context: context,
opStream: ImageFileService.delete(selection),
opStream: imageFileService.delete(selection),
itemCount: selectionCount,
onDone: (processed) {
onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
source.removeEntries(deletedUris);
await source.removeEntries(deletedUris);
source.resumeMonitoring();
final deletedCount = deletedUris.length;
@ -182,38 +205,26 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final todoCount = todoEntries.length;
// while the move is ongoing, source monitoring may remove entries from itself and the favourites repo
// so we save favourites beforehand, and will mark the moved entries as such after the move
final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet();
source.pauseMonitoring();
showOpReport<MoveOpEvent>(
context: context,
opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
opStream: imageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum),
itemCount: todoCount,
onDone: (processed) async {
final movedOps = processed.where((e) => e.success).toSet();
final pinned = settings.pinnedFilters.contains(filter);
await source.updateAfterMove(
todoEntries: todoEntries,
favouriteEntries: favouriteEntries,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// repin new album after obsolete album got removed and unpinned
if (pinned) {
final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(context, destinationAlbum));
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps);
source.resumeMonitoring();
final movedCount = movedOps.length;
if (movedCount < todoCount) {
final count = todoCount - movedCount;
showFeedback(context, context.l10n.collectionMoveFailureFeedback(count));
showFeedbackWithMessenger(messenger, l10n.collectionMoveFailureFeedback(count));
} else {
showFeedback(context, context.l10n.genericSuccessFeedback);
showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback);
}
},
);

View file

@ -1,6 +1,7 @@
import 'dart:math';
import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
@ -25,6 +26,7 @@ import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget {
final CollectionFilter filter;
final double extent;
final AvesEntry coverEntry;
final bool pinned, highlightable;
final FilterCallback onTap;
final OffsetFilterCallback onLongPress;
@ -33,6 +35,7 @@ class DecoratedFilterChip extends StatelessWidget {
Key key,
@required this.filter,
@required this.extent,
this.coverEntry,
this.pinned = false,
this.highlightable = true,
this.onTap,
@ -76,7 +79,7 @@ class DecoratedFilterChip extends StatelessWidget {
}
Widget _buildChip(CollectionSource source) {
final entry = source.recentEntry(filter);
final entry = coverEntry ?? source.coverEntry(filter);
final backgroundImage = entry == null
? Container(color: Colors.white)
: entry.isSvg
@ -89,7 +92,7 @@ class DecoratedFilterChip extends StatelessWidget {
extent: extent,
);
final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4);
final titlePadding = min<double>(6.0, extent / 16);
final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(Radius.circular(radius));
Widget child = AvesFilterChip(
filter: filter,

View file

@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
@ -64,17 +65,20 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
filterSections: filterSections,
showHeaders: showHeaders,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
onLongPress: onLongPress,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
filterSections: filterSections,
showHeaders: showHeaders,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
onLongPress: onLongPress,
),
),
),
),

View file

@ -1,6 +1,6 @@
import 'dart:ui';
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';

View file

@ -35,6 +35,7 @@ class CountryListPage extends StatelessWidget {
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getCountryEntries(source),

View file

@ -35,6 +35,7 @@ class TagListPage extends StatelessWidget {
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getTagEntries(source),

View file

@ -1,4 +1,4 @@
import 'package:aves/main.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/home_page.dart';
@ -6,7 +6,7 @@ import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
@ -81,7 +81,7 @@ class _HomePageState extends State<HomePage> {
}
break;
case 'pick':
appMode = AppMode.pick;
appMode = AppMode.pickExternal;
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String pickMimeTypes = intentData['mimeType'];
@ -110,7 +110,7 @@ class _HomePageState extends State<HomePage> {
}
Future<AvesEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
final entry = await ImageFileService.getEntry(uri, mimeType);
final entry = await imageFileService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog();
@ -130,7 +130,7 @@ class _HomePageState extends State<HomePage> {
String routeName;
Iterable<CollectionFilter> filters;
if (appMode == AppMode.pick) {
if (appMode == AppMode.pickExternal) {
routeName = CollectionPage.routeName;
} else {
routeName = _shortcutRouteName ?? settings.homePage.routeName;

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:flutter/material.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/app_mode.dart';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/main.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/theme/icons.dart';

View file

@ -6,9 +6,8 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -141,7 +140,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
showFeedback(context, context.l10n.genericFailureFeedback);
} else {
if (hasCollection) {
collection.source.removeEntries({entry.uri});
await collection.source.removeEntries({entry.uri});
}
EntryDeletedNotification(entry).dispatch(context);
}
@ -170,7 +169,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{};
if (entry.isMultipage) {
final multiPageInfo = await MetadataService.getMultiPageInfo(entry);
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) {
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
@ -184,7 +183,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length;
showOpReport<ExportOpEvent>(
context: context,
opStream: ImageFileService.export(selection, destinationAlbum: destinationAlbum),
opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum),
itemCount: selectionCount,
onDone: (processed) {
final movedOps = processed.where((e) => e.success);
@ -208,7 +207,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkStoragePermission(context, {entry})) return;
if (await entry.rename(newName)) {
final success = await context.read<CollectionSource>().renameEntry(entry, newName);
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback);
} else {
showFeedback(context, context.l10n.genericFailureFeedback);
@ -221,7 +222,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => ImageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
loader: () => imageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
),
),
);

View file

@ -1,11 +1,11 @@
import 'dart:math';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/window_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
@ -8,7 +8,7 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -87,7 +87,7 @@ class BasicSection extends StatelessWidget {
...tags.map((tag) => TagFilter(tag)),
};
return AnimatedBuilder(
animation: favourites.changeNotifier,
animation: favourites,
builder: (context, child) {
final effectiveFilters = [
...filters,
@ -188,20 +188,21 @@ class _OwnerPropState extends State<OwnerProp> {
),
// `com.android.shell` is the package reported
// for images copied to the device by ADB for Test Driver
if (_ownerPackage != 'com.android.shell') WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Image(
image: AppIconImage(
packageName: _ownerPackage,
size: iconSize,
if (_ownerPackage != 'com.android.shell')
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Image(
image: AppIconImage(
packageName: _ownerPackage,
size: iconSize,
),
width: iconSize,
height: iconSize,
),
width: iconSize,
height: iconSize,
),
),
),
TextSpan(
text: appName,
style: InfoRowGroup.baseStyle,
@ -217,7 +218,7 @@ class _OwnerPropState extends State<OwnerProp> {
if (entry == null) return;
if (_loadedUri.value == entry.uri) return;
if (isVisible) {
_ownerPackage = await MetadataService.getContentResolverProp(widget.entry, 'owner_package_name');
_ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
_loadedUri.value = entry.uri;
} else {
_ownerPackage = null;

View file

@ -1,10 +1,10 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -1,8 +1,8 @@
import 'package:aves/model/availability.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -1,7 +1,7 @@
import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -138,7 +138,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (entry == null) return;
if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) {
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)) ?? {};
final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? '';

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -34,10 +34,10 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
super.initState();
switch (widget.source) {
case MetadataThumbnailSource.embedded:
_loader = MetadataService.getEmbeddedPictures(uri);
_loader = metadataService.getEmbeddedPictures(uri);
break;
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(entry);
_loader = metadataService.getExifThumbnails(entry);
break;
}
}

View file

@ -4,7 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/xmp.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
@ -105,7 +105,7 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
}
Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType);
final fields = await metadataService.extractXmpDataProp(entry, propPath, propMimeType);
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
return;

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -11,7 +11,7 @@ class MultiPageController extends ChangeNotifier {
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageController(AvesEntry entry) {
info = MetadataService.getMultiPageInfo(entry).then((value) {
info = metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index;
return value;
});

View file

@ -5,7 +5,7 @@ import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
@ -69,7 +69,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
}
void _initDetailLoader() {
_detailLoader = MetadataService.getOverlayMetadata(entry);
_detailLoader = metadataService.getOverlayMetadata(entry);
}
@override

View file

@ -1,5 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/panorama_page.dart';
@ -25,7 +25,7 @@ class PanoramaOverlay extends StatelessWidget {
scale: scale,
buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel,
onPressed: () async {
final info = await MetadataService.getPanoramaInfo(entry);
final info = await metadataService.getPanoramaInfo(entry);
if (info != null) {
unawaited(Navigator.push(
context,

View file

@ -2,7 +2,7 @@ import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -323,7 +323,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
@override
void initState() {
super.initState();
favourites.changeNotifier.addListener(_onChanged);
favourites.addListener(_onChanged);
_onChanged();
}
@ -335,7 +335,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
@override
void dispose() {
favourites.changeNotifier.removeListener(_onChanged);
favourites.removeListener(_onChanged);
super.dispose();
}

View file

@ -3,8 +3,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
@ -49,7 +48,7 @@ class EntryPrinter with FeedbackMixin {
}
if (entry.isMultipage) {
final multiPageInfo = await MetadataService.getMultiPageInfo(entry);
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) {
final streamController = StreamController<AvesEntry>.broadcast();
showOpReport<AvesEntry>(
@ -73,7 +72,7 @@ class EntryPrinter with FeedbackMixin {
Future<pdf.Widget> _buildPageImage(AvesEntry entry) async {
if (entry.isSvg) {
final bytes = await ImageFileService.getSvg(entry.uri, entry.mimeType);
final bytes = await imageFileService.getSvg(entry.uri, entry.mimeType);
if (bytes != null && bytes.isNotEmpty) {
return pdf.SvgImage(svg: utf8.decode(bytes));
}

View file

@ -371,6 +371,13 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
get_it:
dependency: "direct main"
description:
name: get_it
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.0"
github:
dependency: "direct main"
description:

View file

@ -49,6 +49,7 @@ dependencies:
flutter_markdown:
flutter_staggered_animations:
flutter_svg:
get_it:
github:
google_api_availability:
google_maps_flutter:

View file

@ -0,0 +1,8 @@
import 'package:aves/model/availability.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeAvesAvailability extends Fake implements AvesAvailability {
@override
Future<bool> get canLocatePlaces => SynchronousFuture(false);
}

View file

@ -0,0 +1,21 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'media_store_service.dart';
class FakeImageFileService extends Fake implements ImageFileService {
@override
Future<Map> rename(AvesEntry entry, String newName) {
final contentId = FakeMediaStoreService.nextContentId;
return SynchronousFuture({
'uri': 'content://media/external/images/media/$contentId',
'contentId': contentId,
'path': '${entry.directory}/$newName',
'displayName': newName,
'title': newName.substring(0, newName.length - entry.extension.length),
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
});
}
}

View file

@ -0,0 +1,62 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMediaStoreService extends Fake implements MediaStoreService {
Set<AvesEntry> entries = {};
@override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) => SynchronousFuture([]);
@override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) => SynchronousFuture([]);
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) => Stream.fromIterable(entries);
static var _lastContentId = 1;
static int get nextContentId => _lastContentId++;
static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000;
static AvesEntry newImage(String album, String filenameWithoutExtension) {
final contentId = nextContentId;
final date = dateSecs;
return AvesEntry(
uri: 'content://media/external/images/media/$contentId',
contentId: contentId,
path: '$album/$filenameWithoutExtension.jpg',
pageId: null,
sourceMimeType: MimeTypes.jpeg,
width: 360,
height: 720,
sourceRotationDegrees: 0,
sizeBytes: 42,
sourceTitle: filenameWithoutExtension,
dateModifiedSecs: date,
sourceDateTakenMillis: date,
durationMillis: null,
);
}
static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) {
final newContentId = nextContentId;
return MoveOpEvent(
success: true,
uri: entry.uri,
newFields: {
'deletedSource': true,
'uri': 'content://media/external/images/media/$newContentId',
'contentId': newContentId,
'path': entry.path.replaceFirst(sourceAlbum, destinationAlbum),
'displayName': '${entry.filenameWithoutExtension}${entry.extension}',
'title': entry.filenameWithoutExtension,
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
},
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMetadataDb extends Fake implements MetadataDb {
@override
Future<void> init() => null;
@override
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly}) => null;
@override
Future<Set<AvesEntry>> loadEntries() => SynchronousFuture({});
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) => null;
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) => null;
@override
Future<List<DateMetadata>> loadDates() => SynchronousFuture([]);
@override
Future<List<CatalogMetadata>> loadMetadataEntries() => SynchronousFuture([]);
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) => null;
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) => null;
@override
Future<List<AddressDetails>> loadAddresses() => SynchronousFuture([]);
@override
Future<void> updateAddressId(int oldId, AddressDetails address) => null;
@override
Future<Set<FavouriteRow>> loadFavourites() => SynchronousFuture({});
@override
Future<void> addFavourites(Iterable<FavouriteRow> rows) => null;
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) => null;
@override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => null;
@override
Future<Set<CoverRow>> loadCovers() => SynchronousFuture({});
@override
Future<void> addCovers(Iterable<CoverRow> rows) => null;
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) => null;
@override
Future<void> removeCovers(Iterable<CoverRow> rows) => null;
}

View file

@ -0,0 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeMetadataService extends Fake implements MetadataService {
@override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) => null;
}

View file

@ -0,0 +1,8 @@
import 'package:aves/services/time_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeTimeService extends Fake implements TimeService {
@override
Future<String> getDefaultTimeZone() => SynchronousFuture('');
}

View file

@ -0,0 +1,239 @@
import 'dart:async';
import 'package:aves/model/availability.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/time_service.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake/availability.dart';
import '../fake/image_file_service.dart';
import '../fake/media_store_service.dart';
import '../fake/metadata_db.dart';
import '../fake/metadata_service.dart';
import '../fake/time_service.dart';
void main() {
const volume = '/storage/emulated/0/';
const testAlbum = '${volume}Pictures/test';
const sourceAlbum = '${volume}Pictures/source';
const destinationAlbum = '${volume}Pictures/destination';
setUp(() async {
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
getIt.registerLazySingleton<TimeService>(() => FakeTimeService());
await settings.init();
});
tearDown(() async {
await getIt.reset();
});
Future<MediaStoreSource> _initSource() async {
final source = MediaStoreSource();
final readyCompleter = Completer();
source.stateNotifier.addListener(() {
if (source.stateNotifier.value == SourceState.ready) {
readyCompleter.complete();
}
});
await source.init();
await source.refresh();
await readyCompleter.future;
return source;
}
test('add/remove favourite entry', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
await _initSource();
expect(favourites.count, 0);
await image1.toggleFavourite();
expect(favourites.count, 1);
expect(image1.isFavourite, true);
await image1.toggleFavourite();
expect(favourites.count, 0);
expect(image1.isFavourite, false);
});
test('set/unset entry as album cover', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
expect(source.rawAlbums.length, 1);
expect(covers.count, 0);
final albumFilter = AlbumFilter(testAlbum, 'whatever');
expect(albumFilter.test(image1), true);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
await covers.set(albumFilter, image1.contentId);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
await covers.set(albumFilter, null);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
});
test('favourites and covers are kept when renaming entries', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
final albumFilter = AlbumFilter(testAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.renameEntry(image1, 'image1b.jpg');
expect(favourites.count, 1);
expect(image1.isFavourite, true);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
});
test('favourites and covers are cleared when removing entries', () async {
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
final albumFilter = AlbumFilter(image1.directory, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.removeEntries({image1.uri});
expect(source.rawAlbums.length, 0);
expect(favourites.count, 0);
expect(covers.count, 0);
expect(covers.coverContentId(albumFilter), null);
});
test('albums are updated when moving entries', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
expect(source.rawAlbums.contains(sourceAlbum), true);
expect(source.rawAlbums.contains(destinationAlbum), false);
final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
final destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(sourceAlbumFilter.test(image1), true);
expect(destinationAlbumFilter.test(image1), false);
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(source.rawAlbums.contains(sourceAlbum), false);
expect(source.rawAlbums.contains(destinationAlbum), true);
expect(sourceAlbumFilter.test(image1), false);
expect(destinationAlbumFilter.test(image1), true);
});
test('favourites are kept when moving entries', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(favourites.count, 1);
expect(image1.isFavourite, true);
});
test('album cover is reset when moving cover entry', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
FakeMediaStoreService.newImage(sourceAlbum, 'image2'),
};
final source = await _initSource();
expect(source.rawAlbums.length, 1);
final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(sourceAlbumFilter, image1.contentId);
await source.updateAfterMove(
todoEntries: {image1},
copy: false,
destinationAlbum: destinationAlbum,
movedOps: {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
},
);
expect(source.rawAlbums.length, 2);
expect(covers.count, 0);
expect(covers.coverContentId(sourceAlbumFilter), null);
});
test('favourites and covers are kept when renaming albums', () async {
final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = {
image1,
};
final source = await _initSource();
await image1.toggleFavourite();
var albumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.renameAlbum(sourceAlbum, destinationAlbum, {
image1
}, {
FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum),
});
albumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(favourites.count, 1);
expect(image1.isFavourite, true);
expect(covers.count, 1);
expect(covers.coverContentId(albumFilter), image1.contentId);
});
}