From a437c2fe9a48197df6420712e90e63810140c29b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 31 May 2020 10:38:24 +0900 Subject: [PATCH] move: update source, DB, lenses --- .../provider/MediaStoreImageProvider.java | 10 ++- lib/model/collection_lens.dart | 22 ++--- lib/model/collection_source.dart | 21 +++++ lib/model/image_entry.dart | 30 +++++-- lib/model/metadata_db.dart | 89 +++++++++++++------ lib/services/image_file_service.dart | 6 +- lib/widgets/about/licenses.dart | 2 +- lib/widgets/album/app_bar.dart | 2 - .../entry_action_delegate.dart | 2 +- .../selection_action_delegate.dart | 43 ++++++--- 10 files changed, 156 insertions(+), 71 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index f01ef4fb5..b2a450247 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -25,6 +25,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; @@ -263,7 +264,14 @@ public class MediaStoreImageProvider extends ImageProvider { DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); source.copyTo(destination); - // TODO TLAD delete source when it is a `move` + if (!copy) { + // delete original entry + try { + delete(activity, sourcePath, sourceUri).get(); + } catch (ExecutionException | InterruptedException e) { + Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); + } + } Map newFields = new HashMap<>(); newFields.put("uri", destinationUri.toString()); diff --git a/lib/model/collection_lens.dart b/lib/model/collection_lens.dart index c3bb1b741..b4929d73d 100644 --- a/lib/model/collection_lens.dart +++ b/lib/model/collection_lens.dart @@ -31,10 +31,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel }) : filters = {if (filters != null) ...filters.where((f) => f != null)}, groupFactor = groupFactor ?? GroupFactor.month, sortFactor = sortFactor ?? SortFactor.date { - _subscriptions.add(source.eventBus.on().listen((e) => onEntryAdded())); + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on().listen((e) => onEntryRemoved(e.entries))); - _subscriptions.add(source.eventBus.on().listen((e) => onMetadataChanged())); - onEntryAdded(); + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _subscriptions.add(source.eventBus.on().listen((e) => _refresh())); + _refresh(); } @override @@ -107,9 +108,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel } void onFilterChanged() { - _applyFilters(); - _applySort(); - _applyGroup(); + _refresh(); filterChangeNotifier.notifyListeners(); } @@ -180,7 +179,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel notifyListeners(); } - void onEntryAdded() { + // metadata change should also trigger a full refresh + // as dates impact sorting and grouping + void _refresh() { _applyFilters(); _applySort(); _applyGroup(); @@ -194,13 +195,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel selection.removeAll(entries); notifyListeners(); } - - void onMetadataChanged() { - _applyFilters(); - // metadata dates impact sorting and grouping - _applySort(); - _applyGroup(); - } } enum SortFactor { date, size, name } diff --git a/lib/model/collection_source.dart b/lib/model/collection_source.dart index bb13c351c..84d4acbaa 100644 --- a/lib/model/collection_source.dart +++ b/lib/model/collection_source.dart @@ -146,9 +146,24 @@ class CollectionSource { void removeEntries(Iterable entries) async { entries.forEach((entry) => entry.removeFromFavourites()); _rawEntries.removeWhere(entries.contains); + cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); eventBus.fire(EntryRemovedEvent(entries)); } + void notifyMovedEntries(Iterable movedEntries) { + eventBus.fire(EntryMovedEvent(entries)); + } + + void cleanEmptyAlbums(Set albums) { + final emptyAlbums = albums.where(_isEmptyAlbum); + if (emptyAlbums.isNotEmpty) { + _folderPaths.removeAll(emptyAlbums); + updateAlbums(); + } + } + + bool _isEmptyAlbum(String album) => !_rawEntries.any((entry) => entry.directory == album); + String getUniqueAlbumName(String album) { final otherAlbums = _folderPaths.where((item) => item != album); final parts = album.split(separator); @@ -231,3 +246,9 @@ class EntryRemovedEvent { const EntryRemovedEvent(this.entries); } + +class EntryMovedEvent { + final Iterable entries; + + const EntryMovedEvent(this.entries); +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index edd3baa15..23c45491d 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -17,8 +17,9 @@ import 'mime_types.dart'; class ImageEntry { String uri; - String path; - String directory; + String _path; + String _directory; + String _filename; int contentId; final String mimeType; int width; @@ -38,7 +39,7 @@ class ImageEntry { ImageEntry({ this.uri, - this.path, + String path, this.contentId, this.mimeType, this.width, @@ -49,7 +50,8 @@ class ImageEntry { this.dateModifiedSecs, this.sourceDateTakenMillis, this.durationMillis, - }) : directory = path != null ? dirname(path) : null { + }) { + this.path = path; isFavouriteNotifier.value = isFavourite; } @@ -126,7 +128,23 @@ class ImageEntry { return 'ImageEntry{uri=$uri, path=$path}'; } - String get filename => basenameWithoutExtension(path); + set path(String path) { + _path = path; + _directory = null; + _filename = null; + } + + String get path => _path; + + String get directory { + _directory ??= path != null ? dirname(path) : null; + return _directory; + } + + String get filenameWithoutExtension { + _filename ??= path != null ? basenameWithoutExtension(path) : null; + return _filename; + } String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*'); @@ -286,7 +304,7 @@ class ImageEntry { } Future rename(String newName) async { - if (newName == filename) return true; + if (newName == filenameWithoutExtension) return true; final newFields = await ImageFileService.rename(this, '$newName${extension(this.path)}'); if (newFields.isEmpty) return false; diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 92994fe76..7ebe80472 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -106,24 +106,35 @@ class MetadataDb { final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); - metadataEntries.where((metadata) => metadata != null).forEach((metadata) { - if (metadata.dateMillis != 0) { - batch.insert( - dateTakenTable, - DateMetadata(contentId: metadata.contentId, dateMillis: metadata.dateMillis).toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - batch.insert( - metadataTable, - metadata.toMap(boolAsInteger: true), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - }); + metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata)); await batch.commit(noResult: true); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); } + Future updateMetadataId(int oldId, CatalogMetadata metadata) async { + final db = await _database; + final batch = db.batch(); + batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]); + batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]); + _batchInsertMetadata(batch, metadata); + await batch.commit(noResult: true); + } + + void _batchInsertMetadata(Batch batch, CatalogMetadata metadata) { + if (metadata.dateMillis != 0) { + batch.insert( + dateTakenTable, + DateMetadata(contentId: metadata.contentId, dateMillis: metadata.dateMillis).toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + batch.insert( + metadataTable, + metadata.toMap(boolAsInteger: true), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + // address Future clearAddresses() async { @@ -146,15 +157,27 @@ class MetadataDb { final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); - addresses.where((address) => address != null).forEach((address) => batch.insert( - addressTable, - address.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - )); + addresses.where((address) => address != null).forEach((address) => _batchInsertAddress(batch, address)); await batch.commit(noResult: true); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); } + Future updateAddressId(int oldId, AddressDetails address) async { + final db = await _database; + final batch = db.batch(); + batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]); + _batchInsertAddress(batch, address); + await batch.commit(noResult: true); + } + + void _batchInsertAddress(Batch batch, AddressDetails address) { + batch.insert( + addressTable, + address.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + // favourites Future clearFavourites() async { @@ -177,15 +200,27 @@ class MetadataDb { // final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); - favouriteRows.where((row) => row != null).forEach((row) => batch.insert( - favouriteTable, - row.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - )); + favouriteRows.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'); } + Future updateFavouriteId(int oldId, FavouriteRow row) async { + final db = await _database; + final batch = db.batch(); + batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]); + _batchInsertFavourite(batch, row); + await batch.commit(noResult: true); + } + + void _batchInsertFavourite(Batch batch, FavouriteRow row) { + batch.insert( + favouriteTable, + row.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + Future removeFavourites(Iterable favouriteRows) async { if (favouriteRows == null || favouriteRows.isEmpty) return; final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); @@ -195,11 +230,7 @@ class MetadataDb { // final stopwatch = Stopwatch()..start(); final db = await _database; final batch = db.batch(); - ids.forEach((id) => batch.delete( - favouriteTable, - where: 'contentId = ?', - whereArgs: [id], - )); + ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); await batch.commit(noResult: true); // debugPrint('$runtimeType removeFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index e6c3cb5b3..73bbc8838 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -84,7 +84,7 @@ class ImageFileService { static Future resumeThumbnail(Object taskKey) => servicePolicy.resume(taskKey, thumbnailPriority); - static Stream delete(List entries) { + static Stream delete(Iterable entries) { try { return opChannel.receiveBroadcastStream({ 'op': 'delete', @@ -96,14 +96,14 @@ class ImageFileService { } } - static Stream move(List entries, {@required bool copy, @required String destinationPath}) { + static Stream move(Iterable entries, {@required bool copy, @required String destinationAlbum}) { debugPrint('move ${entries.length} entries'); try { return opChannel.receiveBroadcastStream({ 'op': 'move', 'entries': entries.map((e) => e.toMap()).toList(), 'copy': copy, - 'destinationPath': destinationPath, + 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e) { debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}'); diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 2f147f3b3..6e5d924bc 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -82,7 +82,7 @@ class _LicensesState extends State { setState(() {}); }, tooltip: 'Sort', - icon: Icon(AIcons.sort), + icon: const Icon(AIcons.sort), ), ], ), diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index a6f4673a6..f442c008e 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -188,8 +188,6 @@ class _CollectionAppBarState extends State with SingleTickerPr ), const PopupMenuItem( value: CollectionAction.move, - // TODO TLAD enable when handled on native side - enabled: false, child: MenuRow(text: 'Move to album'), ), const PopupMenuItem( diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index f22d742e6..99d1302c9 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -167,7 +167,7 @@ class EntryActionDelegate with PermissionAwareMixin { } Future _showRenameDialog(BuildContext context, ImageEntry entry) async { - final currentName = entry.filename ?? entry.sourceTitle; + final currentName = entry.filenameWithoutExtension ?? entry.sourceTitle; final controller = TextEditingController(text: currentName); final newName = await showDialog( context: context, diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 6c1f277d6..9f9ab83bc 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; @@ -87,7 +88,7 @@ class SelectionActionDelegate with PermissionAwareMixin { _showOpReport( context: context, selection: selection, - opStream: ImageFileService.move(selection, copy: copy, destinationPath: filter.album), + opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: filter.album), onDone: (Set processed) { debugPrint('$runtimeType _moveSelection onDone'); final movedOps = processed.where((e) => e.success); @@ -98,30 +99,44 @@ class SelectionActionDelegate with PermissionAwareMixin { _showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}'); } if (movedCount > 0) { + final source = collection.source; if (copy) { - final newEntries = []; - final newFavs = []; - movedOps.forEach((movedOp) { + final newEntries = movedOps.map((movedOp) { final sourceUri = movedOp.uri; final newFields = movedOp.newFields; final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); - final copy = sourceEntry?.copyWith( + return sourceEntry?.copyWith( uri: newFields['uri'] as String, path: newFields['path'] as String, contentId: newFields['contentId'] as int, ); - newEntries.add(copy); - if (sourceEntry.isFavourite) { - newFavs.add(copy); - } - }); - collection.source.addAll(newEntries); + }).toList(); + source.addAll(newEntries); metadataDb.saveMetadata(newEntries.map((entry) => entry.catalogMetadata)); metadataDb.saveAddresses(newEntries.map((entry) => entry.addressDetails)); - newFavs.forEach((entry) => entry.addToFavourites()); } else { - // TODO TLAD update old entries path/dir/ID - // TODO TLAD update DB for catalog/address/fav + final movedEntries = []; + final fromAlbums = {}; + movedOps.forEach((movedOp) { + final sourceUri = movedOp.uri; + final newFields = movedOp.newFields; + final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); + if (entry != null) { + fromAlbums.add(entry.directory); + final oldContentId = entry.contentId; + final newContentId = newFields['contentId'] as int; + entry.uri = newFields['uri'] as String; + entry.path = newFields['path'] as String; + entry.contentId = newContentId; + + metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); + metadataDb.updateAddressId(oldContentId, entry.addressDetails); + metadataDb.updateFavouriteId(oldContentId, FavouriteRow(contentId: entry.contentId, path: entry.path)); + } + movedEntries.add(entry); + }); + source.cleanEmptyAlbums(fromAlbums); + source.notifyMovedEntries(movedEntries); } } collection.clearSelection();