diff --git a/lib/app_mode.dart b/lib/app_mode.dart new file mode 100644 index 000000000..5238cf612 --- /dev/null +++ b/lib/app_mode.dart @@ -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; +} diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index 3563d9419..136bcc626 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -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 { 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 { @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 { diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 0f7e9d063..0578b63e3 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -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 { 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 { @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 { diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 6c3f9615e..c6b1c31fa 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -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 { assert(key == this); try { - final bytes = await ImageFileService.getImage( + final bytes = await imageFileService.getImage( uri, mimeType, rotationDegrees, diff --git a/lib/image_providers/uri_picture_provider.dart b/lib/image_providers/uri_picture_provider.dart index 5ccd4e22a..2f4071c8c 100644 --- a/lib/image_providers/uri_picture_provider.dart +++ b/lib/image_providers/uri_picture_provider.dart @@ -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 { Future _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; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 80435191d..5f069e73b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7b2769521..a40210234 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -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": "사용 중인 이름입니다", diff --git a/lib/main.dart b/lib/main.dart index 82f7f7d8b..13fbebc61 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent'); final GlobalKey _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 { home: home, navigatorObservers: _navigatorObservers, onGenerateTitle: (context) => context.l10n.appName, - darkTheme: darkTheme, + darkTheme: Themes.darkTheme, themeMode: ThemeMode.dark, locale: settingsLocale, localizationsDelegates: [ diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index aada4fc4f..412f745a1 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -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; } diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 303a75884..43c983f23 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -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 get isConnected; + + Future get hasPlayServices; + + Future get canLocatePlaces; + + Future get isNewVersionAvailable; +} + +class LiveAvesAvailability implements AvesAvailability { bool _isConnected, _hasPlayServices, _isNewVersionAvailable; - AvesAvailability._private() { + LiveAvesAvailability() { Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); } + @override void onResume() => _isConnected = null; + @override Future get isConnected async { if (_isConnected != null) return SynchronousFuture(_isConnected); final result = await (Connectivity().checkConnectivity()); @@ -34,6 +46,7 @@ class AvesAvailability { } } + @override Future 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 get canLocatePlaces => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); + @override Future get isNewVersionAvailable async { if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); diff --git a/lib/model/covers.dart b/lib/model/covers.dart new file mode 100644 index 000000000..5a083438a --- /dev/null +++ b/lib/model/covers.dart @@ -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 _rows = {}; + + Covers._private(); + + Future 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 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 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 removeEntries(Set 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 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 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}'; +} diff --git a/lib/model/entry.dart b/lib/model/entry.dart index ce8d0f508..1d0b9a953 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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 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 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 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 delete() { Completer completer = Completer(); - 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 toggleFavourite() async { if (isFavourite) { - removeFromFavourites(); + await removeFromFavourites(); } else { - addToFavourites(); + await addToFavourites(); } } - void addToFavourites() { + Future addToFavourites() async { if (!isFavourite) { - favourites.add([this]); + await favourites.add([this]); } } - void removeFromFavourites() { + Future removeFromFavourites() async { if (isFavourite) { - favourites.remove([this]); + await favourites.remove([this]); } } diff --git a/lib/model/favourite_repo.dart b/lib/model/favourite_repo.dart deleted file mode 100644 index e80eb6fd6..000000000 --- a/lib/model/favourite_repo.dart +++ /dev/null @@ -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 _rows = []; - - final AChangeNotifier changeNotifier = AChangeNotifier(); - - FavouriteRepo._private(); - - Future 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 add(Iterable entries) async { - final newRows = entries.map(_entryToRow); - - await metadataDb.addFavourites(newRows); - _rows.addAll(newRows); - - changeNotifier.notifyListeners(); - } - - Future remove(Iterable entries) async { - final removedRows = entries.map(_entryToRow); - - await metadataDb.removeFavourites(removedRows); - removedRows.forEach(_rows.remove); - - changeNotifier.notifyListeners(); - } - - Future 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 clear() async { - await metadataDb.clearFavourites(); - _rows.clear(); - - changeNotifier.notifyListeners(); - } -} diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart new file mode 100644 index 000000000..1ae6d54b9 --- /dev/null +++ b/lib/model/favourites.dart @@ -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 _rows = {}; + + Favourites._private(); + + Future 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 add(Iterable entries) async { + final newRows = entries.map(_entryToRow); + + await metadataDb.addFavourites(newRows); + _rows.addAll(newRows); + + notifyListeners(); + } + + Future remove(Iterable 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 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 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 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}'; +} diff --git a/lib/model/metadata.dart b/lib/model/metadata.dart index 47a9783da..9a045d36d 100644 --- a/lib/model/metadata.dart +++ b/lib/model/metadata.dart @@ -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 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}'; -} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index d7543ef19..561978da0 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -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 init(); -class MetadataDb { + Future dbFileSize(); + + Future reset(); + + Future removeIds(Set contentIds, {@required bool metadataOnly}); + + // entries + + Future clearEntries(); + + Future> loadEntries(); + + Future saveEntries(Iterable entries); + + Future updateEntryId(int oldId, AvesEntry entry); + + // date taken + + Future clearDates(); + + Future> loadDates(); + + // catalog metadata + + Future clearMetadataEntries(); + + Future> loadMetadataEntries(); + + Future saveMetadata(Iterable metadataEntries); + + Future updateMetadataId(int oldId, CatalogMetadata metadata); + + // address + + Future clearAddresses(); + + Future> loadAddresses(); + + Future saveAddresses(Iterable addresses); + + Future updateAddressId(int oldId, AddressDetails address); + + // favourites + + Future clearFavourites(); + + Future> loadFavourites(); + + Future addFavourites(Iterable rows); + + Future updateFavouriteId(int oldId, FavouriteRow row); + + Future removeFavourites(Iterable rows); + + // covers + + Future clearCovers(); + + Future> loadCovers(); + + Future addCovers(Iterable rows); + + Future updateCoverEntryId(int oldId, CoverRow row); + + Future removeCovers(Iterable rows); +} + +class SqfliteMetadataDb implements MetadataDb { Future _database; Future 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 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 dbFileSize() async { final file = File((await path)); return await file.exists() ? file.length() : 0; } + @override Future reset() async { debugPrint('$runtimeType reset'); await (await _database).close(); @@ -86,7 +162,8 @@ class MetadataDb { await init(); } - void removeIds(Set contentIds, {@required bool updateFavourites}) async { + @override + Future removeIds(Set 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 clearEntries() async { final db = await _database; final count = await db.delete(entryTable, where: '1'); debugPrint('$runtimeType clearEntries deleted $count entries'); } + @override Future> loadEntries() async { final stopwatch = Stopwatch()..start(); final db = await _database; @@ -125,6 +205,7 @@ class MetadataDb { return entries; } + @override Future saveEntries(Iterable 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 updateEntryId(int oldId, AvesEntry entry) async { final db = await _database; final batch = db.batch(); @@ -154,12 +236,14 @@ class MetadataDb { // date taken + @override Future clearDates() async { final db = await _database; final count = await db.delete(dateTakenTable, where: '1'); debugPrint('$runtimeType clearDates deleted $count entries'); } + @override Future> loadDates() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -171,12 +255,14 @@ class MetadataDb { // catalog metadata + @override Future clearMetadataEntries() async { final db = await _database; final count = await db.delete(metadataTable, where: '1'); debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); } + @override Future> loadMetadataEntries() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -186,6 +272,7 @@ class MetadataDb { return metadataEntries; } + @override Future saveMetadata(Iterable metadataEntries) async { if (metadataEntries == null || metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -200,6 +287,7 @@ class MetadataDb { } } + @override Future updateMetadataId(int oldId, CatalogMetadata metadata) async { final db = await _database; final batch = db.batch(); @@ -227,12 +315,14 @@ class MetadataDb { // address + @override Future clearAddresses() async { final db = await _database; final count = await db.delete(addressTable, where: '1'); debugPrint('$runtimeType clearAddresses deleted $count entries'); } + @override Future> loadAddresses() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -242,6 +332,7 @@ class MetadataDb { return addresses; } + @override Future saveAddresses(Iterable 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 updateAddressId(int oldId, AddressDetails address) async { final db = await _database; final batch = db.batch(); @@ -271,31 +363,31 @@ class MetadataDb { // favourites + @override Future clearFavourites() async { final db = await _database; final count = await db.delete(favouriteTable, where: '1'); debugPrint('$runtimeType clearFavourites deleted $count entries'); } - Future> loadFavourites() async { -// final stopwatch = Stopwatch()..start(); + @override + Future> 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 addFavourites(Iterable favouriteRows) async { - if (favouriteRows == null || favouriteRows.isEmpty) return; -// final stopwatch = Stopwatch()..start(); + @override + Future addFavourites(Iterable 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 updateFavouriteId(int oldId, FavouriteRow row) async { final db = await _database; final batch = db.batch(); @@ -313,9 +405,10 @@ class MetadataDb { ); } - Future removeFavourites(Iterable favouriteRows) async { - if (favouriteRows == null || favouriteRows.isEmpty) return; - final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); + @override + Future removeFavourites(Iterable 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 clearCovers() async { + final db = await _database; + final count = await db.delete(coverTable, where: '1'); + debugPrint('$runtimeType clearCovers deleted $count entries'); + } + + @override + Future> 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 addCovers(Iterable 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 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 removeCovers(Iterable 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); + } } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart index b343eaf12..bb74bb11e 100644 --- a/lib/model/metadata_db_upgrade.dart +++ b/lib/model/metadata_db_upgrade.dart @@ -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 _upgradeFrom3(Database db) async { + debugPrint('upgrading DB from v3'); + await db.execute('CREATE TABLE $coverTable(' + 'filter TEXT PRIMARY KEY' + ', contentId INTEGER' + ')'); + } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index cd6e224cf..9339e3c78 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -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 uris) { + Future removeEntries(Set 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 _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { + Future _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 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 renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set 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 updateAfterMove({ @required Set todoEntries, - @required Set favouriteEntries, @required bool copy, @required String destinationAlbum, @required Set 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) { diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 5115f3ae6..96d4f1aae 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -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'; diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index fa164526d..b4e65fd2d 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -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 refreshMetadata(Set entries) { final contentIds = entries.map((entry) => entry.contentId).toSet(); - metadataDb.removeIds(contentIds, updateFavourites: false); + metadataDb.removeIds(contentIds, metadataOnly: true); return refresh(); } } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index fe27a3c8c..dec9b0df3 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -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'; diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 02a3f76f6..9c5a1cb46 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -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, diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 45208ca56..e5d60f95e 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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 getEntry(String uri, String mimeType); + + Future getSvg( + String uri, + String mimeType, { + int expectedContentLength, + BytesReceivedCallback onBytesReceived, + }); + + Future 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 getRegion( + String uri, + String mimeType, + int rotationDegrees, + bool isFlipped, + int sampleSize, + Rectangle regionRect, + Size imageSize, { + int pageId, + Object taskKey, + int priority, + }); + + Future 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 clearSizedThumbnailDiskCache(); + + bool cancelRegion(Object taskKey); + + bool cancelThumbnail(Object taskKey); + + Future resumeLoading(Object taskKey); + + Stream delete(Iterable entries); + + Stream move( + Iterable entries, { + @required bool copy, + @required String destinationAlbum, + }); + + Stream export( + Iterable entries, { + String mimeType = MimeTypes.jpeg, + @required String destinationAlbum, + }); + + Future rename(AvesEntry entry, String newName); + + Future rotate(AvesEntry entry, {@required bool clockwise}); + + Future 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 getEntry(String uri, String mimeType) async { + @override + Future getEntry(String uri, String mimeType) async { try { final result = await platform.invokeMethod('getEntry', { 'uri': uri, @@ -44,7 +120,8 @@ class ImageFileService { return null; } - static Future getSvg( + @override + Future getSvg( String uri, String mimeType, { int expectedContentLength, @@ -59,7 +136,8 @@ class ImageFileService { onBytesReceived: onBytesReceived, ); - static Future getImage( + @override + Future 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 getRegion( + @override + Future getRegion( String uri, String mimeType, int rotationDegrees, @@ -145,7 +223,8 @@ class ImageFileService { ); } - static Future getThumbnail({ + @override + Future getThumbnail({ @required String uri, @required String mimeType, @required int rotationDegrees, @@ -184,7 +263,8 @@ class ImageFileService { ); } - static Future clearSizedThumbnailDiskCache() async { + @override + Future 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 resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); + @override + Future resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); - static Stream delete(Iterable entries) { + @override + Stream delete(Iterable entries) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'delete', @@ -210,7 +294,8 @@ class ImageFileService { } } - static Stream move( + @override + Stream move( Iterable entries, { @required bool copy, @required String destinationAlbum, @@ -228,7 +313,8 @@ class ImageFileService { } } - static Stream export( + @override + Stream export( Iterable entries, { String mimeType = MimeTypes.jpeg, @required String destinationAlbum, @@ -246,7 +332,8 @@ class ImageFileService { } } - static Future rename(AvesEntry entry, String newName) async { + @override + Future rename(AvesEntry entry, String newName) async { try { // returns map with: 'contentId' 'path' 'title' 'uri' (all optional) final result = await platform.invokeMethod('rename', { @@ -260,7 +347,8 @@ class ImageFileService { return {}; } - static Future rotate(AvesEntry entry, {@required bool clockwise}) async { + @override + Future rotate(AvesEntry entry, {@required bool clockwise}) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('rotate', { @@ -274,7 +362,8 @@ class ImageFileService { return {}; } - static Future flip(AvesEntry entry) async { + @override + Future flip(AvesEntry entry) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('flip', { diff --git a/lib/services/media_store_service.dart b/lib/services/media_store_service.dart index 43f380358..edd6e134b 100644 --- a/lib/services/media_store_service.dart +++ b/lib/services/media_store_service.dart @@ -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> checkObsoleteContentIds(List knownContentIds); + + Future> checkObsoletePaths(Map knownPathById); + + // knownEntries: map of contentId -> dateModifiedSecs + Stream getEntries(Map knownEntries); +} + +class PlatformMediaStoreService implements MediaStoreService { static const platform = MethodChannel('deckers.thibault/aves/mediastore'); static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); - static Future> checkObsoleteContentIds(List knownContentIds) async { + @override + Future> checkObsoleteContentIds(List knownContentIds) async { try { final result = await platform.invokeMethod('checkObsoleteContentIds', { 'knownContentIds': knownContentIds, @@ -21,7 +31,8 @@ class MediaStoreService { return []; } - static Future> checkObsoletePaths(Map knownPathById) async { + @override + Future> checkObsoletePaths(Map knownPathById) async { try { final result = await platform.invokeMethod('checkObsoletePaths', { 'knownPathById': knownPathById, @@ -33,8 +44,8 @@ class MediaStoreService { return []; } - // knownEntries: map of contentId -> dateModifiedSecs - static Stream getEntries(Map knownEntries) { + @override + Stream getEntries(Map knownEntries) { try { return _streamChannel.receiveBroadcastStream({ 'knownEntries': knownEntries, diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 7954c1c84..83cedd445 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -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 of directories, each directory being a map of metadata label and value description) + Future getAllMetadata(AvesEntry entry); + + Future getCatalogMetadata(AvesEntry entry, {bool background = false}); + + Future getOverlayMetadata(AvesEntry entry); + + Future getMultiPageInfo(AvesEntry entry); + + Future getPanoramaInfo(AvesEntry entry); + + Future getContentResolverProp(AvesEntry entry, String prop); + + Future> getEmbeddedPictures(String uri); + + Future> getExifThumbnails(AvesEntry entry); + + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType); +} + +class PlatformMetadataService implements MetadataService { static const platform = MethodChannel('deckers.thibault/aves/metadata'); - // returns Map> (map of directories, each directory being a map of metadata label and value description) - static Future getAllMetadata(AvesEntry entry) async { + @override + Future getAllMetadata(AvesEntry entry) async { if (entry.isSvg) return null; try { @@ -28,7 +49,8 @@ class MetadataService { return {}; } - static Future getCatalogMetadata(AvesEntry entry, {bool background = false}) async { + @override + Future getCatalogMetadata(AvesEntry entry, {bool background = false}) async { if (entry.isSvg) return null; Future call() async { @@ -65,7 +87,8 @@ class MetadataService { : call(); } - static Future getOverlayMetadata(AvesEntry entry) async { + @override + Future getOverlayMetadata(AvesEntry entry) async { if (entry.isSvg) return null; try { @@ -82,7 +105,8 @@ class MetadataService { return null; } - static Future getMultiPageInfo(AvesEntry entry) async { + @override + Future getMultiPageInfo(AvesEntry entry) async { try { final result = await platform.invokeMethod('getMultiPageInfo', { 'mimeType': entry.mimeType, @@ -96,7 +120,8 @@ class MetadataService { return null; } - static Future getPanoramaInfo(AvesEntry entry) async { + @override + Future 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 getContentResolverProp(AvesEntry entry, String prop) async { + @override + Future getContentResolverProp(AvesEntry entry, String prop) async { try { return await platform.invokeMethod('getContentResolverProp', { 'mimeType': entry.mimeType, @@ -126,7 +152,8 @@ class MetadataService { return null; } - static Future> getEmbeddedPictures(String uri) async { + @override + Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { 'uri': uri, @@ -138,7 +165,8 @@ class MetadataService { return []; } - static Future> getExifThumbnails(AvesEntry entry) async { + @override + Future> getExifThumbnails(AvesEntry entry) async { try { final result = await platform.invokeMethod('getExifThumbnails', { 'mimeType': entry.mimeType, @@ -152,7 +180,8 @@ class MetadataService { return []; } - static Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { + @override + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { try { final result = await platform.invokeMethod('extractXmpDataProp', { 'mimeType': entry.mimeType, diff --git a/lib/services/services.dart b/lib/services/services.dart new file mode 100644 index 000000000..e817e4cfa --- /dev/null +++ b/lib/services/services.dart @@ -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(); +final metadataDb = getIt(); + +final imageFileService = getIt(); +final mediaStoreService = getIt(); +final metadataService = getIt(); +final timeService = getIt(); + +void initPlatformServices() { + getIt.registerLazySingleton(() => LiveAvesAvailability()); + getIt.registerLazySingleton(() => SqfliteMetadataDb()); + + getIt.registerLazySingleton(() => PlatformImageFileService()); + getIt.registerLazySingleton(() => PlatformMediaStoreService()); + getIt.registerLazySingleton(() => PlatformMetadataService()); + getIt.registerLazySingleton(() => PlatformTimeService()); +} diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart index bf8f79a9f..94fbf36a4 100644 --- a/lib/services/svg_metadata_service.dart +++ b/lib/services/svg_metadata_service.dart @@ -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 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; diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index 07e8fb06e..d9b284bea 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -1,10 +1,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -class TimeService { +abstract class TimeService { + Future getDefaultTimeZone(); +} + +class PlatformTimeService implements TimeService { static const platform = MethodChannel('deckers.thibault/aves/time'); - static Future getDefaultTimeZone() async { + @override + Future getDefaultTimeZone() async { try { return await platform.invokeMethod('getDefaultTimeZone'); } on PlatformException catch (e) { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 4a844c018..13d002866 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart new file mode 100644 index 000000000..84d02ddf3 --- /dev/null +++ b/lib/theme/themes.dart @@ -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, + ), + ), + ); +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 706377147..e6c3248a8 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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', diff --git a/lib/widgets/about/update.dart b/lib/widgets/about/update.dart index 94e4ecef0..02248105d 100644 --- a/lib/widgets/about/update.dart +++ b/lib/widgets/about/update.dart @@ -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'; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 932a3a7cd..860b4b42a 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -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 with SingleTickerPr @override Widget build(BuildContext context) { + final appMode = context.watch>().value; return ValueListenableBuilder( 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 with SingleTickerPr Widget _buildAppBarTitle() { if (collection.isBrowsing) { final appMode = context.watch>().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 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 with SingleTickerPr } List _buildActions() { + final appMode = context.watch>().value; return [ - if (collection.isBrowsing) + if (collection.isBrowsing && appMode.canSearch) CollectionSearchButton( source, parentCollection: collection, @@ -193,7 +199,6 @@ class _CollectionAppBarState extends State with SingleTickerPr itemBuilder: (context) { final isNotEmpty = !collection.isEmpty; final hasSelection = collection.selection.isNotEmpty; - final isMainMode = context.read>().value == AppMode.main; return [ PopupMenuItem( key: Key('menu-sort'), @@ -206,19 +211,18 @@ class _CollectionAppBarState extends State 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), diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 9002ad256..2c0ebb4f7 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -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 { @override Widget build(BuildContext context) { _tileExtentController ??= TileExtentController( - settingsRouteKey: context.currentRouteName, + settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName, columnCountDefault: 4, extentMin: 46, spacing: 0, diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 55d422265..eacaa1810 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -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( 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( 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(); diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 1cde65a07..8fdc8cddd 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -8,12 +8,14 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; final List filters; - final FilterCallback onPressed; + final bool removable; + final FilterCallback onTap; FilterBar({ Key key, @required Set filters, - @required this.onPressed, + @required this.removable, + this.onTap, }) : filters = List.from(filters)..sort(), super(key: key); @@ -26,7 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { class _FilterBarState extends State { final GlobalKey _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 { 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 { duration: Duration.zero, ); }); - _userRemovedFilter = null; + _userTappedFilter = null; } @override @@ -106,12 +110,14 @@ class _FilterBarState extends State { 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, ), ), ); diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 6b9c5aefe..91be8ed35 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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>().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( diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 297f669b9..4600de46c 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -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, )); diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index c8e72f37e..d7a2e7e2a 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -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 { 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 { 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( future: _colorFuture, diff --git a/lib/widgets/debug/cache.dart b/lib/widgets/debug/cache.dart index 09b8e9aa3..ed37531ac 100644 --- a/lib/widgets/debug/cache.dart +++ b/lib/widgets/debug/cache.dart @@ -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 with AutomaticKee ), SizedBox(width: 8), ElevatedButton( - onPressed: ImageFileService.clearSizedThumbnailDiskCache, + onPressed: imageFileService.clearSizedThumbnailDiskCache, child: Text('Clear'), ), ], diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index ea881a82e..9dd19894c 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -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 with Future> _dbDateLoader; Future> _dbMetadataLoader; Future> _dbAddressLoader; - Future> _dbFavouritesLoader; + Future> _dbFavouritesLoader; + Future> _dbCoversLoader; @override void initState() { @@ -141,7 +143,7 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), - FutureBuilder( + FutureBuilder( future: _dbFavouritesLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); @@ -162,6 +164,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + 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 with _dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbAddressLoader = metadataDb.loadAddresses(); _dbFavouritesLoader = metadataDb.loadFavourites(); + _dbCoversLoader = metadataDb.loadCovers(); setState(() {}); } diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/cover_selection_dialog.dart new file mode 100644 index 000000000..f8aa9489a --- /dev/null +++ b/lib/widgets/dialogs/cover_selection_dialog.dart @@ -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 { + 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((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(_isCustom, _customEntry)), + child: Text(l10n.applyButtonLabel), + ), + ], + ); + }, + ), + ); + } + + Future _pickEntry() async { + final entry = await Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: ItemPickDialog.routeName), + builder: (context) => ItemPickDialog( + CollectionLens( + source: context.read(), + filters: [filter], + ), + ), + fullscreenDialog: true, + ), + ); + if (entry != null) { + _customEntry = entry; + _isCustom = true; + setState(() {}); + } + } +} diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart new file mode 100644 index 000000000..013f14fe4 --- /dev/null +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -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 { + CollectionLens get collection => widget.collection; + + @override + void dispose() { + collection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableProvider>.value( + value: ValueNotifier(AppMode.pickInternal), + child: MediaQueryDataProvider( + child: Scaffold( + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: CollectionGrid( + settingsRouteKey: CollectionPage.routeName, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index a69ce8f29..690702f7d 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -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'; diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index baa5e3145..5b4df6a3e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -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, diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 30dba479b..7a95a56b3 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -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().visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + final coverSelection = await showDialog>( + 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( 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( 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); } }, ); diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index da0e5ae3e..8501084e1 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -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(AvesFilterChip.defaultRadius, extent / 4); - final titlePadding = min(6.0, extent / 16); + final titlePadding = min(4.0, extent / 32); final borderRadius = BorderRadius.all(Radius.circular(radius)); Widget child = AvesFilterChip( filter: filter, diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 34f64ef8d..65833b2ba 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -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 extends StatelessWidget { child: GestureAreaProtectorStack( child: SafeArea( bottom: false, - child: FilterGrid( - 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( + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: appBarHeight, + filterSections: filterSections, + showHeaders: showHeaders, + queryNotifier: queryNotifier, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + onTap: onTap, + onLongPress: onLongPress, + ), ), ), ), diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 848283a21..e85d4a88c 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -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'; diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 6cf097d8f..774f4899d 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -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), diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 9ed99e93a..faf4b7cbb 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -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), diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index e895e8d65..9e328365d 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -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 { } 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 { } Future _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 { String routeName; Iterable filters; - if (appMode == AppMode.pick) { + if (appMode == AppMode.pickExternal) { routeName = CollectionPage.routeName; } else { routeName = _shortcutRouteName ?? settings.homePage.routeName; diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index ad5df9358..e586c51d2 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -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'; diff --git a/lib/widgets/viewer/debug_page.dart b/lib/widgets/viewer/debug_page.dart index 3489cdbc1..cd7300585 100644 --- a/lib/widgets/viewer/debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -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'; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index eec0fdecb..232740c53 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -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 = {}; 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( 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().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), ), ), ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 592e7be0e..f4bfe6799 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -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'; diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 3fca3f63d..6e3a2cd38 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -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 { ), // `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 { 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; diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index a700a6471..9682d53b0 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -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'; diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index 9bb6035b3..3a7ccc699 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -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'; diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index d148be209..9a866b422 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -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 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 ?? ''; diff --git a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart index 45ae7744d..6d6bc926c 100644 --- a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart @@ -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 { 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; } } diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 336fa0c79..141cd9c9c 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -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 with FeedbackMixin { } Future _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; diff --git a/lib/widgets/viewer/multipage.dart b/lib/widgets/viewer/multipage.dart index 91de82859..c599f09de 100644 --- a/lib/widgets/viewer/multipage.dart +++ b/lib/widgets/viewer/multipage.dart @@ -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 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; }); diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 15a7a4923..a36837652 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -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 { } void _initDetailLoader() { - _detailLoader = MetadataService.getOverlayMetadata(entry); + _detailLoader = metadataService.getOverlayMetadata(entry); } @override diff --git a/lib/widgets/viewer/overlay/panorama.dart b/lib/widgets/viewer/overlay/panorama.dart index 688e7dcba..4998f86c8 100644 --- a/lib/widgets/viewer/overlay/panorama.dart +++ b/lib/widgets/viewer/overlay/panorama.dart @@ -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, diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index a7c79e95d..8a4b4558f 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -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(); } diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index a13accf63..f8e35323c 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -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.broadcast(); showOpReport( @@ -73,7 +72,7 @@ class EntryPrinter with FeedbackMixin { Future _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)); } diff --git a/pubspec.lock b/pubspec.lock index e3301a163..4fb30fd84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 46431f7e8..11c770ac3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: flutter_markdown: flutter_staggered_animations: flutter_svg: + get_it: github: google_api_availability: google_maps_flutter: diff --git a/test/fake/availability.dart b/test/fake/availability.dart new file mode 100644 index 000000000..cf09187e4 --- /dev/null +++ b/test/fake/availability.dart @@ -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 get canLocatePlaces => SynchronousFuture(false); +} diff --git a/test/fake/image_file_service.dart b/test/fake/image_file_service.dart new file mode 100644 index 000000000..4c4716ae4 --- /dev/null +++ b/test/fake/image_file_service.dart @@ -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 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, + }); + } +} diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart new file mode 100644 index 000000000..fce6490e6 --- /dev/null +++ b/test/fake/media_store_service.dart @@ -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 entries = {}; + + @override + Future> checkObsoleteContentIds(List knownContentIds) => SynchronousFuture([]); + + @override + Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); + + @override + Stream getEntries(Map 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, + }, + ); + } +} diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart new file mode 100644 index 000000000..6b0d8b0a5 --- /dev/null +++ b/test/fake/metadata_db.dart @@ -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 init() => null; + + @override + Future removeIds(Set contentIds, {@required bool metadataOnly}) => null; + + @override + Future> loadEntries() => SynchronousFuture({}); + + @override + Future saveEntries(Iterable entries) => null; + + @override + Future updateEntryId(int oldId, AvesEntry entry) => null; + + @override + Future> loadDates() => SynchronousFuture([]); + + @override + Future> loadMetadataEntries() => SynchronousFuture([]); + + @override + Future saveMetadata(Iterable metadataEntries) => null; + + @override + Future updateMetadataId(int oldId, CatalogMetadata metadata) => null; + + @override + Future> loadAddresses() => SynchronousFuture([]); + + @override + Future updateAddressId(int oldId, AddressDetails address) => null; + + @override + Future> loadFavourites() => SynchronousFuture({}); + + @override + Future addFavourites(Iterable rows) => null; + + @override + Future updateFavouriteId(int oldId, FavouriteRow row) => null; + + @override + Future removeFavourites(Iterable rows) => null; + + @override + Future> loadCovers() => SynchronousFuture({}); + + @override + Future addCovers(Iterable rows) => null; + + @override + Future updateCoverEntryId(int oldId, CoverRow row) => null; + + @override + Future removeCovers(Iterable rows) => null; +} diff --git a/test/fake/metadata_service.dart b/test/fake/metadata_service.dart new file mode 100644 index 000000000..766f27caa --- /dev/null +++ b/test/fake/metadata_service.dart @@ -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 getCatalogMetadata(AvesEntry entry, {bool background = false}) => null; +} diff --git a/test/fake/time_service.dart b/test/fake/time_service.dart new file mode 100644 index 000000000..5e7eddee2 --- /dev/null +++ b/test/fake/time_service.dart @@ -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 getDefaultTimeZone() => SynchronousFuture(''); +} diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart new file mode 100644 index 000000000..e5d39881a --- /dev/null +++ b/test/model/collection_source_test.dart @@ -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(() => FakeAvesAvailability()); + getIt.registerLazySingleton(() => FakeMetadataDb()); + + getIt.registerLazySingleton(() => FakeImageFileService()); + getIt.registerLazySingleton(() => FakeMediaStoreService()); + getIt.registerLazySingleton(() => FakeMetadataService()); + getIt.registerLazySingleton(() => FakeTimeService()); + + await settings.init(); + }); + + tearDown(() async { + await getIt.reset(); + }); + + Future _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); + }); +}