diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 52d7cdbf..246e6aad 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -9,6 +9,8 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/rx_extension.dart'; import 'package:nc_photos/use_case/collection/create_collection.dart'; import 'package:nc_photos/use_case/collection/edit_collection.dart'; @@ -157,6 +159,7 @@ class CollectionsController { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { try { final c = await _mutex.protect(() async { @@ -166,6 +169,7 @@ class CollectionsController { name: name, items: items, itemSort: itemSort, + cover: cover, ); }); _updateCollection(c, items); diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index e85835a1..629c03b3 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -94,6 +94,13 @@ class Album with EquatableMixin { return null; } } + if (jsonVersion < 9) { + result = upgraderFactory?.buildV8()?.call(result); + if (result == null) { + _log.info("[fromJson] Version $jsonVersion not compatible"); + return null; + } + } if (jsonVersion > version) { _log.warning( "[fromJson] Reading album with newer version: $jsonVersion > $version"); @@ -217,7 +224,7 @@ class Album with EquatableMixin { final int savedVersion; /// versioning of this class, use to upgrade old persisted album - static const version = 8; + static const version = 9; static final _log = _$AlbumNpLog.log; } diff --git a/app/lib/entity/album/cover_provider.dart b/app/lib/entity/album/cover_provider.dart index 55e05d73..6afd7645 100644 --- a/app/lib/entity/album/cover_provider.dart +++ b/app/lib/entity/album/cover_provider.dart @@ -122,13 +122,14 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider { /// Cover picked by user @toString class AlbumManualCoverProvider extends AlbumCoverProvider { - AlbumManualCoverProvider({ + const AlbumManualCoverProvider({ required this.coverFile, }); factory AlbumManualCoverProvider.fromJson(JsonObj json) { return AlbumManualCoverProvider( - coverFile: File.fromJson(json["coverFile"].cast()), + coverFile: + FileDescriptor.fromJson(json["coverFile"].cast()), ); } @@ -136,21 +137,21 @@ class AlbumManualCoverProvider extends AlbumCoverProvider { String toString() => _$toString(); @override - getCover(Album album) => coverFile; + FileDescriptor? getCover(Album album) => coverFile; @override - get props => [ + List get props => [ coverFile, ]; @override - _toContentJson() { + JsonObj _toContentJson() { return { "coverFile": coverFile.toJson(), }; } - final File coverFile; + final FileDescriptor coverFile; static const _type = "manual"; } diff --git a/app/lib/entity/album/cover_provider.g.dart b/app/lib/entity/album/cover_provider.g.dart index a3adc55a..dcfd9a76 100644 --- a/app/lib/entity/album/cover_provider.g.dart +++ b/app/lib/entity/album/cover_provider.g.dart @@ -27,7 +27,7 @@ extension _$AlbumAutoCoverProviderToString on AlbumAutoCoverProvider { extension _$AlbumManualCoverProviderToString on AlbumManualCoverProvider { String _$toString() { // ignore: unnecessary_string_interpolations - return "AlbumManualCoverProvider {coverFile: ${coverFile.path}}"; + return "AlbumManualCoverProvider {coverFile: ${coverFile.fdPath}}"; } } diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index 92ebd5ff..f98af981 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -1,8 +1,10 @@ +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/object_extension.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; @@ -238,6 +240,49 @@ class AlbumUpgraderV7 implements AlbumUpgrader { final String? logFilePath; } +/// Upgrade v8 Album to v9 +@npLog +class AlbumUpgraderV8 implements AlbumUpgrader { + const AlbumUpgraderV8({ + this.logFilePath, + }); + + @override + JsonObj? call(JsonObj json) { + _log.fine("[call] Upgrade v8 Album for file: $logFilePath"); + final result = JsonObj.from(json); + try { + if (result["coverProvider"]["type"] != "manual") { + return result; + } + final content = (result["coverProvider"]["content"]["coverFile"] as Map) + .cast(); + final fd = { + "fdPath": content["path"], + "fdId": content["fileId"], + "fdMime": content["contentType"], + "fdIsArchived": content["isArchived"] ?? false, + "fdIsFavorite": content["isFavorite"] ?? false, + "fdDateTime": content["overrideDateTime"] ?? + (content["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run( + (d) => + Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ?? + content["lastModified"] ?? + clock.now().toUtc().toIso8601String(), + }; + result["coverProvider"]["content"]["coverFile"] = fd; + } catch (e, stackTrace) { + // this upgrade is not a must, if it failed then just leave it and it'll + // be upgraded the next time the album is saved + _log.shout("[call] Failed while upgrade", e, stackTrace); + } + return result; + } + + /// File path for logging only + final String? logFilePath; +} + abstract class AlbumUpgraderFactory { const AlbumUpgraderFactory(); @@ -248,6 +293,7 @@ abstract class AlbumUpgraderFactory { AlbumUpgraderV5? buildV5(); AlbumUpgraderV6? buildV6(); AlbumUpgraderV7? buildV7(); + AlbumUpgraderV8? buildV8(); } class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @@ -282,6 +328,9 @@ class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @override buildV7() => AlbumUpgraderV7(logFilePath: logFilePath); + @override + AlbumUpgraderV8? buildV8() => AlbumUpgraderV8(logFilePath: logFilePath); + final Account account; final File? albumFile; diff --git a/app/lib/entity/album/upgrader.g.dart b/app/lib/entity/album/upgrader.g.dart index 29011f64..bcc45112 100644 --- a/app/lib/entity/album/upgrader.g.dart +++ b/app/lib/entity/album/upgrader.g.dart @@ -54,3 +54,10 @@ extension _$AlbumUpgraderV7NpLog on AlbumUpgraderV7 { static final log = Logger("entity.album.upgrader.AlbumUpgraderV7"); } + +extension _$AlbumUpgraderV8NpLog on AlbumUpgraderV8 { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.album.upgrader.AlbumUpgraderV8"); +} diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 4c205053..ddec018e 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -60,6 +60,8 @@ enum CollectionCapability { rename, // text labels labelItem, + // set the cover image + manualCover, } /// Provide the actual content of a collection @@ -80,6 +82,10 @@ abstract class CollectionContentProvider { DateTime get lastModified; /// Return the capabilities of the collection + /// + /// Notice that the capabilities returned here represent all the capabilities + /// that this implementation supports. In practice there may be extra runtime + /// requirements that mask some of them (e.g., user permissions) List get capabilities; /// Return the sort type diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index 343291da..5adc9596 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -16,6 +16,7 @@ import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/new_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:np_common/type.dart'; abstract class CollectionAdapter { @@ -51,13 +52,11 @@ abstract class CollectionAdapter { }); /// Edit this collection - /// - /// [name] and [items] are optional params and if not null, set the value to - /// this collection Future edit({ String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }); /// Remove [items] from this collection and return the removed count @@ -70,10 +69,16 @@ abstract class CollectionAdapter { /// Convert a [NewCollectionItem] to an adapted one Future adaptToNewItem(NewCollectionItem original); - bool isItemsRemovable(List items); + bool isItemRemovable(CollectionItem item); /// Remove this collection Future remove(); + + /// Return if this capability is allowed + bool isPermitted(CollectionCapability capability); + + /// Return if the cover of this collection has been manually set + bool isManualCover(); } abstract class CollectionItemAdapter { diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 26cc76c6..94d5c16e 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/collection.dart'; @@ -17,6 +18,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/use_case/album/add_file_to_album.dart'; import 'package:nc_photos/use_case/album/edit_album.dart'; import 'package:nc_photos/use_case/album/remove_album.dart'; @@ -75,8 +77,9 @@ class CollectionAlbumAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { - assert(name != null || items != null || itemSort != null); + assert(name != null || items != null || itemSort != null || cover != null); final newItems = items?.run((items) => items .map((e) { if (e is AlbumAdaptedCollectionItem) { @@ -101,6 +104,7 @@ class CollectionAlbumAdapter implements CollectionAdapter { name: name, items: newItems, itemSort: itemSort, + cover: cover, ); return collection.copyWith( name: name, @@ -185,18 +189,39 @@ class CollectionAlbumAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - if (_provider.album.albumFile!.isOwned(account.userId)) { + bool isItemRemovable(CollectionItem item) { + if (_provider.album.provider is! AlbumStaticProvider) { + return false; + } + if (_provider.album.albumFile?.isOwned(account.userId) == true) { return true; } - return items - .whereType() - .any((e) => e.albumItem.addedBy == account.userId); + if (item is! AlbumAdaptedCollectionItem) { + _log.warning("[isItemRemovable] Unknown item type: ${item.runtimeType}"); + return true; + } + return item.albumItem.addedBy == account.userId; } @override Future remove() => RemoveAlbum(_c)(account, _provider.album); + @override + bool isPermitted(CollectionCapability capability) { + if (!_provider.capabilities.contains(capability)) { + return false; + } + if (_provider.album.albumFile?.isOwned(account.userId) == true) { + return true; + } else { + return _provider.guestCapabilities.contains(capability); + } + } + + @override + bool isManualCover() => + _provider.album.coverProvider is AlbumManualCoverProvider; + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/location_group.dart b/app/lib/entity/collection/adapter/location_group.dart index b06fd8cf..3f012101 100644 --- a/app/lib/entity/collection/adapter/location_group.dart +++ b/app/lib/entity/collection/adapter/location_group.dart @@ -48,6 +48,10 @@ class CollectionLocationGroupAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index dde6bdae..33f92524 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -13,6 +13,7 @@ import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/use_case/find_file_descriptor.dart'; import 'package:nc_photos/use_case/nc_album/add_file_to_nc_album.dart'; import 'package:nc_photos/use_case/nc_album/edit_nc_album.dart'; @@ -75,9 +76,10 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { assert(name != null); - if (items != null || itemSort != null) { + if (items != null || itemSort != null || cover != null) { _log.warning( "[edit] Nextcloud album does not support editing item or sort"); } @@ -131,13 +133,18 @@ class CollectionNcAlbumAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - return true; - } + bool isItemRemovable(CollectionItem item) => true; @override Future remove() => RemoveNcAlbum(_c)(account, _provider.album); + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + + @override + bool isManualCover() => false; + Future _syncRemote() async { final remote = await ListNcAlbum(_c)(account).last; return remote.firstWhere((e) => e.compareIdentity(_provider.album)); diff --git a/app/lib/entity/collection/adapter/person.dart b/app/lib/entity/collection/adapter/person.dart index 0d9ee9c0..926b202d 100644 --- a/app/lib/entity/collection/adapter/person.dart +++ b/app/lib/entity/collection/adapter/person.dart @@ -50,6 +50,10 @@ class CollectionPersonAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/read_only_adapter.dart index af68d849..80b1f96d 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/read_only_adapter.dart @@ -4,6 +4,7 @@ import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:np_common/type.dart'; /// A read-only collection that does not support modifying its items @@ -22,6 +23,7 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) { throw UnsupportedError("Operation not supported"); } @@ -36,7 +38,8 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { } @override - bool isItemsRemovable(List items) { - return false; - } + bool isItemRemovable(CollectionItem item) => false; + + @override + bool isManualCover() => false; } diff --git a/app/lib/entity/collection/adapter/tag.dart b/app/lib/entity/collection/adapter/tag.dart index e7a85432..29135ac5 100644 --- a/app/lib/entity/collection/adapter/tag.dart +++ b/app/lib/entity/collection/adapter/tag.dart @@ -37,6 +37,10 @@ class CollectionTagAdapter throw UnsupportedError("Operation not supported"); } + @override + bool isPermitted(CollectionCapability capability) => + _provider.capabilities.contains(capability); + final DiContainer _c; final Account account; final Collection collection; diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index f27845be..0578dd70 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -44,6 +44,7 @@ class CollectionAlbumProvider implements CollectionContentProvider { List get capabilities => [ CollectionCapability.sort, CollectionCapability.rename, + CollectionCapability.manualCover, if (album.provider is AlbumStaticProvider) ...[ CollectionCapability.manualItem, CollectionCapability.manualSort, @@ -51,6 +52,14 @@ class CollectionAlbumProvider implements CollectionContentProvider { ], ]; + /// Capabilities when this album is shared to this user by someone else + List get guestCapabilities => [ + if (album.provider is AlbumStaticProvider) ...[ + CollectionCapability.manualItem, + CollectionCapability.labelItem, + ], + ]; + @override CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); diff --git a/app/lib/or_null.dart b/app/lib/or_null.dart index 2f1d4d4b..fb3dc552 100644 --- a/app/lib/or_null.dart +++ b/app/lib/or_null.dart @@ -1,4 +1,9 @@ +import 'package:to_string/to_string.dart'; + +part 'or_null.g.dart'; + /// To hold optional arguments that themselves could be null +@toString class OrNull { OrNull(this.obj); @@ -6,5 +11,8 @@ class OrNull { /// null, false will still be returned static bool isSetNull(OrNull? x) => x != null && x.obj == null; + @override + String toString() => _$toString(); + final T? obj; } diff --git a/app/lib/or_null.g.dart b/app/lib/or_null.g.dart new file mode 100644 index 00000000..4f251ca3 --- /dev/null +++ b/app/lib/or_null.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'or_null.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$OrNullToString on OrNull { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "OrNull {obj: $obj}"; + } +} diff --git a/app/lib/use_case/album/edit_album.dart b/app/lib/use_case/album/edit_album.dart index 2ce141e0..7f250087 100644 --- a/app/lib/use_case/album/edit_album.dart +++ b/app/lib/use_case/album/edit_album.dart @@ -2,10 +2,13 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -22,9 +25,10 @@ class EditAlbum { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) async { _log.info( - "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort"); + "[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort, cover: $cover"); var newAlbum = album; if (name != null) { newAlbum = newAlbum.copyWith(name: name); @@ -43,6 +47,17 @@ class EditAlbum { sortProvider: AlbumSortProvider.fromCollectionItemSort(itemSort), ); } + if (cover != null) { + if (cover.obj == null) { + newAlbum = newAlbum.copyWith( + coverProvider: AlbumAutoCoverProvider(), + ); + } else { + newAlbum = newAlbum.copyWith( + coverProvider: AlbumManualCoverProvider(coverFile: cover.obj!), + ); + } + } if (identical(newAlbum, album)) { return album; } diff --git a/app/lib/use_case/collection/edit_collection.dart b/app/lib/use_case/collection/edit_collection.dart index 19d584b7..15d11a07 100644 --- a/app/lib/use_case/collection/edit_collection.dart +++ b/app/lib/use_case/collection/edit_collection.dart @@ -4,6 +4,8 @@ import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/or_null.dart'; class EditCollection { const EditCollection(this._c); @@ -15,6 +17,7 @@ class EditCollection { /// - Rename (set [name]) /// - Add text label(s) (set [items]) /// - Sort [items] (set [items] and/or [itemSort]) + /// - Set album [cover] /// /// \* To add files to a collection, use [AddFileToCollection] instead Future call( @@ -23,11 +26,13 @@ class EditCollection { String? name, List? items, CollectionItemSort? itemSort, + OrNull? cover, }) => CollectionAdapter.of(_c, account, collection).edit( name: name, items: items, itemSort: itemSort, + cover: cover, ); final DiContainer _c; diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index 13cb10c9..0d1227c3 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -353,8 +353,7 @@ class _AlbumBrowserState extends State } } Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex, - album: _album)); + arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); } Future _onSharePressed(BuildContext context) async { diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 48929709..8698aa17 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -4,7 +4,6 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:clock/clock.dart'; -import 'package:collection/collection.dart'; import 'package:copy_with/copy_with.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -37,6 +36,7 @@ import 'package:nc_photos/flutter_util.dart' as flutter_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/archive_file.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; @@ -227,8 +227,10 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> if (!state.isEditMode) { return const _ContentList(); } else { - if (state.collection.capabilities - .contains(CollectionCapability.manualSort)) { + if (context + .read<_Bloc>() + .isCollectionCapabilityPermitted( + CollectionCapability.manualSort)) { return const _EditContentList(); } else { return const _UnmodifiableEditContentList(); @@ -314,16 +316,18 @@ class _ContentList extends StatelessWidget { @override Widget build(BuildContext context) { + final bloc = context.read<_Bloc>(); return StreamBuilder( stream: context.read().albumBrowserZoomLevel, initialData: context.read().albumBrowserZoomLevel.value, builder: (_, zoomLevel) { if (zoomLevel.hasError) { - context.read<_Bloc>().add( + bloc.add( _SetMessage(L10n.global().writePreferenceFailureNotification)); } return _BlocBuilder( buildWhen: (previous, current) => + previous.collection != current.collection || previous.transformedItems != current.transformedItems || previous.selectedItems != current.selectedItems, builder: (context, state) { @@ -336,9 +340,7 @@ class _ContentList extends StatelessWidget { staggeredTileBuilder: (_, item) => item.staggeredTile, selectedItems: state.selectedItems, onSelectionChange: (_, selected) { - context - .read<_Bloc>() - .add(_SetSelectedItems(items: selected.cast())); + bloc.add(_SetSelectedItems(items: selected.cast())); }, onItemTap: (context, index, _) { final actualIndex = index - @@ -349,12 +351,19 @@ class _ContentList extends StatelessWidget { Navigator.of(context).pushNamed( Viewer.routeName, arguments: ViewerArguments( - context.read<_Bloc>().account, + bloc.account, state.transformedItems .whereType<_FileItem>() .map((e) => e.file) .toList(), actualIndex, + fromCollection: ViewerCollectionData( + state.collection, + state.transformedItems + .whereType<_ActualItem>() + .map((e) => e.original) + .toList(), + ), ), ); }, @@ -383,8 +392,8 @@ class _EditContentList extends StatelessWidget { buildWhen: (previous, current) => previous.editTransformedItems != current.editTransformedItems, builder: (context, state) { - if (state.collection.capabilities - .contains(CollectionCapability.manualSort)) { + if (context.read<_Bloc>().isCollectionCapabilityPermitted( + CollectionCapability.manualSort)) { return DraggableItemList<_Item>( maxCrossAxisExtent: photo_list_util .getThumbSize(zoomLevel.requireData) diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 75e7b253..46e3f96d 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -216,6 +216,13 @@ extension _$_CancelEditToString on _CancelEdit { } } +extension _$_UnsetCoverToString on _UnsetCover { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UnsetCover {}"; + } +} + extension _$_SetSelectedItemsToString on _SetSelectedItems { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 938d3098..b69d46d2 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -5,58 +5,69 @@ class _AppBar extends StatelessWidget { @override Widget build(BuildContext context) { - // capability can't be changed once the collection is created - final capabilities = context.read<_Bloc>().state.collection.capabilities; - return SliverAppBar( - floating: true, - expandedHeight: 160, - flexibleSpace: FlexibleSpaceBar( - background: const _AppBarCover(), - title: _BlocBuilder( - buildWhen: (previous, current) => - previous.collection.name != current.collection.name, - builder: (context, state) => Text( - state.collection.name, - style: TextStyle( - color: Theme.of(context).appBarTheme.foregroundColor, + final c = KiwiContainer().resolve(); + return _BlocBuilder( + buildWhen: (previous, current) => + previous.items != current.items || + previous.collection != current.collection, + builder: (context, state) { + final bloc = context.read<_Bloc>(); + final adapter = CollectionAdapter.of(c, bloc.account, state.collection); + final canRename = adapter.isPermitted(CollectionCapability.rename); + final canManualCover = + adapter.isPermitted(CollectionCapability.manualCover); + + final actions = [ + ZoomMenuButton( + initialZoom: 0, + minZoom: 0, + maxZoom: 2, + onZoomChanged: (value) { + context.read().setAlbumBrowserZoomLevel(value); + }, + ), + ]; + if (state.items.isNotEmpty || canRename) { + actions.add(PopupMenuButton<_MenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (_) => [ + if (canRename) + PopupMenuItem( + value: _MenuOption.edit, + child: Text(L10n.global().editTooltip), + ), + if (canManualCover && adapter.isManualCover()) + PopupMenuItem( + value: _MenuOption.unsetCover, + child: Text(L10n.global().unsetAlbumCoverTooltip), + ), + if (state.items.isNotEmpty) + PopupMenuItem( + value: _MenuOption.download, + child: Text(L10n.global().downloadTooltip), + ), + ], + onSelected: (option) { + _onMenuSelected(context, option); + }, + )); + } + + return SliverAppBar( + floating: true, + expandedHeight: 160, + flexibleSpace: FlexibleSpaceBar( + background: const _AppBarCover(), + title: Text( + state.collection.name, + style: TextStyle( + color: Theme.of(context).appBarTheme.foregroundColor, + ), ), ), - ), - ), - actions: [ - ZoomMenuButton( - initialZoom: 0, - minZoom: 0, - maxZoom: 2, - onZoomChanged: (value) { - context.read().setAlbumBrowserZoomLevel(value); - }, - ), - if (capabilities.contains(CollectionCapability.rename)) - _BlocBuilder( - buildWhen: (previous, current) => previous.items != current.items, - builder: (context, state) => PopupMenuButton<_MenuOption>( - tooltip: MaterialLocalizations.of(context).moreButtonTooltip, - itemBuilder: (context) { - return [ - if (capabilities.contains(CollectionCapability.rename)) - PopupMenuItem( - value: _MenuOption.edit, - child: Text(L10n.global().editTooltip), - ), - if (state.items.isNotEmpty) - PopupMenuItem( - value: _MenuOption.download, - child: Text(L10n.global().downloadTooltip), - ), - ]; - }, - onSelected: (option) { - _onMenuSelected(context, option); - }, - ), - ), - ], + actions: actions, + ); + }, ); } @@ -65,6 +76,9 @@ class _AppBar extends StatelessWidget { case _MenuOption.edit: context.read<_Bloc>().add(const _BeginEdit()); break; + case _MenuOption.unsetCover: + context.read<_Bloc>().add(const _UnsetCover()); + break; case _MenuOption.download: context.read<_Bloc>().add(const _Download()); break; @@ -145,8 +159,8 @@ class _SelectionAppBar extends StatelessWidget { PopupMenuButton<_SelectionMenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ - if (state.collection.capabilities - .contains(CollectionCapability.manualItem) && + if (context.read<_Bloc>().isCollectionCapabilityPermitted( + CollectionCapability.manualItem) && state.isSelectionRemovable) PopupMenuItem( value: _SelectionMenuOption.removeFromAlbum, @@ -237,7 +251,11 @@ class _EditAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - final capabilities = context.read<_Bloc>().state.collection.capabilities; + final capabilitiesAdapter = CollectionAdapter.of( + KiwiContainer().resolve(), + context.read<_Bloc>().account, + context.read<_Bloc>().state.collection, + ); return SliverAppBar( floating: true, expandedHeight: 160, @@ -274,13 +292,13 @@ class _EditAppBar extends StatelessWidget { }, ), actions: [ - if (capabilities.contains(CollectionCapability.labelItem)) + if (capabilitiesAdapter.isPermitted(CollectionCapability.labelItem)) IconButton( icon: const Icon(Icons.text_fields), tooltip: L10n.global().albumAddTextTooltip, onPressed: () => _onAddTextPressed(context), ), - if (capabilities.contains(CollectionCapability.sort)) + if (capabilitiesAdapter.isPermitted(CollectionCapability.sort)) IconButton( icon: const Icon(Icons.sort_by_alpha), tooltip: L10n.global().sortTooltip, @@ -354,6 +372,7 @@ class _EditAppBar extends StatelessWidget { enum _MenuOption { edit, + unsetCover, download, } diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index e101948e..e6a6eb40 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -29,6 +29,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { on<_DoneEdit>(_onDoneEdit, transformer: concurrent()); on<_CancelEdit>(_onCancelEdit); + on<_UnsetCover>(_onUnsetCover); + on<_SetSelectedItems>(_onSetSelectedItems); on<_DownloadSelectedItems>(_onDownloadSelectedItems); on<_AddSelectedItemsToCollection>(_onAddSelectedItemsToCollection); @@ -66,12 +68,18 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { return super.close(); } + bool isCollectionCapabilityPermitted(CollectionCapability capability) { + return CollectionAdapter.of(_c, account, state.collection) + .isPermitted(capability); + } + @override String get tag => _log.fullName; void _onUpdateCollection(_UpdateCollection ev, Emitter<_State> emit) { _log.info("$ev"); emit(state.copyWith(collection: ev.collection)); + _updateCover(emit); } Future _onLoad(_LoadItems ev, Emitter<_State> emit) { @@ -94,15 +102,8 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { void _onTransformItems(_TransformItems ev, Emitter<_State> emit) { _log.info("$ev"); final result = _transformItems(ev.items, state.collection.itemSort); - var newState = state.copyWith(transformedItems: result.transformed); - if (state.coverUrl == null) { - // if cover is not managed by the collection, use the first item - final cover = _getCoverUrlOnNewItem(result.sorted); - if (cover != null) { - newState = newState.copyWith(coverUrl: cover); - } - } - emit(newState); + emit(state.copyWith(transformedItems: result.transformed)); + _updateCover(emit); } void _onDownload(_Download ev, Emitter<_State> emit) { @@ -128,8 +129,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { void _onAddLabelToCollection(_AddLabelToCollection ev, Emitter<_State> emit) { _log.info("$ev"); - assert( - state.collection.capabilities.contains(CollectionCapability.labelItem)); + assert(isCollectionCapabilityPermitted(CollectionCapability.labelItem)); emit(state.copyWith( editItems: [ NewCollectionLabelItem(ev.label, clock.now().toUtc()), @@ -149,8 +149,7 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { void _onEditManualSort(_EditManualSort ev, Emitter<_State> emit) { _log.info("$ev"); - assert(state.collection.capabilities - .contains(CollectionCapability.manualSort)); + assert(isCollectionCapabilityPermitted(CollectionCapability.manualSort)); emit(state.copyWith( editSort: CollectionItemSort.manual, editItems: @@ -204,15 +203,20 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { )); } + void _onUnsetCover(_UnsetCover ev, Emitter<_State> emit) { + _log.info("$ev"); + collectionsController.edit(state.collection, cover: OrNull(null)); + } + void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) { _log.info("$ev"); + final adapter = CollectionAdapter.of(_c, account, state.collection); emit(state.copyWith( selectedItems: ev.items, - isSelectionRemovable: CollectionAdapter.of(_c, account, state.collection) - .isItemsRemovable(ev.items - .whereType<_ActualItem>() - .map((e) => e.original) - .toList()), + isSelectionRemovable: ev.items + .whereType<_ActualItem>() + .map((e) => e.original) + .any(adapter.isItemRemovable), )); } @@ -403,23 +407,20 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { ); } - String? _getCoverUrlOnNewItem(List sortedItems) { + String? _getCoverUrlByItems() { try { final firstFile = - (sortedItems.firstWhereOrNull((i) => i is CollectionFileItem) - as CollectionFileItem?) - ?.file; - if (firstFile != null) { - return api_util.getFilePreviewUrlByFileId( - account, - firstFile.fdId, - width: k.coverSize, - height: k.coverSize, - isKeepAspectRatio: false, - ); - } - } catch (_) {} - return null; + state.transformedItems.whereType<_FileItem>().first.file; + return api_util.getFilePreviewUrlByFileId( + account, + firstFile.fdId, + width: k.coverSize, + height: k.coverSize, + isKeepAspectRatio: false, + ); + } catch (_) { + return null; + } } static String? _getCoverUrl(Collection collection) { @@ -430,6 +431,14 @@ class _Bloc extends Bloc<_Event, _State> implements BlocTag { } } + void _updateCover(Emitter<_State> emit) { + var coverUrl = _getCoverUrl(state.collection); + coverUrl ??= _getCoverUrlByItems(); + if (coverUrl != state.coverUrl) { + emit(state.copyWith(coverUrl: coverUrl)); + } + } + final DiContainer _c; final Account account; final CollectionsController collectionsController; diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 4fbf463f..c92b9ada 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -188,6 +188,14 @@ class _CancelEdit implements _Event { String toString() => _$toString(); } +@toString +class _UnsetCover implements _Event { + const _UnsetCover(); + + @override + String toString() => _$toString(); +} + /// Set the currently selected items @toString class _SetSelectedItems implements _Event { diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart index 4c071b63..4ddf18be 100644 --- a/app/lib/widget/smart_album_browser.dart +++ b/app/lib/widget/smart_album_browser.dart @@ -206,8 +206,7 @@ class _SmartAlbumBrowserState extends State } } Navigator.pushNamed(context, Viewer.routeName, - arguments: ViewerArguments(widget.account, _backingFiles, fileIndex, - album: widget.album)); + arguments: ViewerArguments(widget.account, _backingFiles, fileIndex)); } void _onDownloadPressed() { diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 9c1b02bf..5e0ccbfb 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -5,25 +5,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/collection_items_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/flutter_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/notified_action.dart'; +import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; -import 'package:nc_photos/use_case/album/remove_from_album.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/update_property.dart'; import 'package:nc_photos/widget/disposable.dart'; @@ -44,18 +48,25 @@ import 'package:np_codegen/np_codegen.dart'; part 'viewer.g.dart'; +class ViewerCollectionData { + const ViewerCollectionData(this.collection, this.items); + + final Collection collection; + final List items; +} + class ViewerArguments { - ViewerArguments( + const ViewerArguments( this.account, this.streamFiles, this.startIndex, { - this.album, + this.fromCollection, }); final Account account; final List streamFiles; final int startIndex; - final Album? album; + final ViewerCollectionData? fromCollection; } class Viewer extends StatefulWidget { @@ -73,7 +84,7 @@ class Viewer extends StatefulWidget { required this.account, required this.streamFiles, required this.startIndex, - this.album, + this.fromCollection, }) : super(key: key); Viewer.fromArgs(ViewerArguments args, {Key? key}) @@ -82,7 +93,7 @@ class Viewer extends StatefulWidget { account: args.account, streamFiles: args.streamFiles, startIndex: args.startIndex, - album: args.album, + fromCollection: args.fromCollection, ); @override @@ -92,8 +103,8 @@ class Viewer extends StatefulWidget { final List streamFiles; final int startIndex; - /// The album these files belongs to, or null - final Album? album; + /// Data of the collection these files belongs to, or null + final ViewerCollectionData? fromCollection; } @npLog @@ -304,9 +315,11 @@ class _ViewerState extends State child: ViewerDetailPane( account: widget.account, fd: _streamFilesView[index], - album: widget.album, - onRemoveFromAlbumPressed: - _onRemoveFromAlbumPressed, + fromCollection: widget.fromCollection?.run( + (d) => ViewerSingleCollectionData( + d.collection, d.items[index])), + onRemoveFromCollectionPressed: + _onRemoveFromCollectionPressed, onArchivePressed: _onArchivePressed, onUnarchivePressed: _onUnarchivePressed, onSlideshowPressed: _onSlideshowPressed, @@ -666,30 +679,27 @@ class _ViewerState extends State _removeCurrentItemFromStream(context, index); } - void _onRemoveFromAlbumPressed(BuildContext context) { - assert(widget.album!.provider is AlbumStaticProvider); + Future _onRemoveFromCollectionPressed(BuildContext context) async { + assert(CollectionAdapter.of(KiwiContainer().resolve(), + widget.account, widget.fromCollection!.collection) + .isPermitted(CollectionCapability.manualItem)); final index = _viewerController.currentPage; - final c = KiwiContainer().resolve(); final file = _streamFilesView[index]; - _log.info("[_onRemoveFromAlbumPressed] Remove file: ${file.fdPath}"); - NotifiedAction( - () async { - final selectedFile = - (await InflateFileDescriptor(c)(widget.account, [file])).first; - final thisItem = AlbumStaticProvider.of(widget.album!) - .items - .whereType() - .firstWhere((e) => e.file.compareServerIdentity(selectedFile)); - await RemoveFromAlbum(KiwiContainer().resolve())( - widget.account, widget.album!, [thisItem]); - }, - null, - L10n.global().removeSelectedFromAlbumSuccessNotification(1), - failureText: L10n.global().removeSelectedFromAlbumFailureNotification, - ).call().catchError((e, stackTrace) { - _log.shout("[_onRemoveFromAlbumPressed] Failed while updating album", e, - stackTrace); - }); + _log.info("[_onRemoveFromCollectionPressed] Remove file: ${file.fdPath}"); + try { + final itemsController = _findCollectionItemsController(context); + final item = itemsController.stream.value.items + .whereType() + .firstWhere((i) => i.file.compareServerIdentity(file)); + await itemsController.removeItems([item]); + } catch (e, stackTrace) { + _log.shout("[_onRemoveFromCollectionPressed] Failed while updating album", + e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().removeSelectedFromAlbumFailureNotification), + duration: k.snackBarDurationNormal, + )); + } _removeCurrentItemFromStream(context, index); } @@ -829,6 +839,19 @@ class _ViewerState extends State _isClosingDetailPane = false; } + CollectionItemsController _findCollectionItemsController( + BuildContext context) { + return context + .read() + .collectionsController + .stream + .value + .data + .firstWhere((d) => + d.collection.compareIdentity(widget.fromCollection!.collection)) + .controller; + } + bool _canSwitchPage() => !_isZoomed; bool _canOpenDetailPane() => !_isZoomed; bool _canZoom() => !_isDetailPaneActive; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index f5f9867c..761234a2 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -2,32 +2,32 @@ import 'dart:async'; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/double_extension.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/exif_extension.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/location_util.dart' as location_util; -import 'package:nc_photos/notified_action.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/list_file_tag.dart'; -import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_property.dart'; import 'package:nc_photos/widget/about_geocoding_dialog.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; @@ -41,13 +41,20 @@ import 'package:tuple/tuple.dart'; part 'viewer_detail_pane.g.dart'; +class ViewerSingleCollectionData { + const ViewerSingleCollectionData(this.collection, this.item); + + final Collection collection; + final CollectionItem item; +} + class ViewerDetailPane extends StatefulWidget { const ViewerDetailPane({ Key? key, required this.account, required this.fd, - this.album, - required this.onRemoveFromAlbumPressed, + this.fromCollection, + required this.onRemoveFromCollectionPressed, required this.onArchivePressed, required this.onUnarchivePressed, this.onSlideshowPressed, @@ -59,10 +66,10 @@ class ViewerDetailPane extends StatefulWidget { final Account account; final FileDescriptor fd; - /// The album this file belongs to, or null - final Album? album; + /// Data of the collection this file belongs to, or null + final ViewerSingleCollectionData? fromCollection; - final void Function(BuildContext context) onRemoveFromAlbumPressed; + final void Function(BuildContext context) onRemoveFromCollectionPressed; final void Function(BuildContext context) onArchivePressed; final void Function(BuildContext context) onUnarchivePressed; final VoidCallback? onSlideshowPressed; @@ -148,11 +155,10 @@ class _ViewerDetailPaneState extends State { _DetailPaneButton( icon: Icons.remove_outlined, label: L10n.global().removeFromAlbumTooltip, - onPressed: () => widget.onRemoveFromAlbumPressed(context), + onPressed: () => + widget.onRemoveFromCollectionPressed(context), ), - if (widget.album != null && - widget.album!.albumFile?.isOwned(widget.account.userId) == - true) + if (_canSetCover) _DetailPaneButton( icon: Icons.photo_album_outlined, label: L10n.global().useAsAlbumCoverTooltip, @@ -384,27 +390,21 @@ class _ViewerDetailPaneState extends State { Future _onSetAlbumCoverPressed(BuildContext context) async { assert(_file != null); - assert(widget.album != null); + assert(widget.fromCollection != null); _log.info( - "[_onSetAlbumCoverPressed] Set '${widget.fd.fdPath}' as album cover for '${widget.album!.name}'"); + "[_onSetAlbumCoverPressed] Set '${widget.fd.fdPath}' as album cover for '${widget.fromCollection!.collection.name}'"); try { - await NotifiedAction( - () async { - await UpdateAlbum(_c.albumRepo)( - widget.account, - widget.album!.copyWith( - coverProvider: AlbumManualCoverProvider( - coverFile: _file!, - ), - )); - }, - L10n.global().setAlbumCoverProcessingNotification, - L10n.global().setAlbumCoverSuccessNotification, - failureText: L10n.global().setAlbumCoverFailureNotification, - )(); + await context.read().collectionsController.edit( + widget.fromCollection!.collection, + cover: OrNull(_file!), + ); } catch (e, stackTrace) { _log.shout("[_onSetAlbumCoverPressed] Failed while updating album", e, stackTrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().setAlbumCoverFailureNotification), + duration: k.snackBarDurationNormal, + )); } } @@ -459,27 +459,6 @@ class _ViewerDetailPaneState extends State { }); } - bool _checkCanRemoveFromAlbum() { - if (widget.album == null || - widget.album!.provider is! AlbumStaticProvider) { - return false; - } - if (widget.album!.albumFile?.isOwned(widget.account.userId) == true) { - return true; - } - try { - final thisItem = AlbumStaticProvider.of(widget.album!) - .items - .whereType() - .firstWhere( - (element) => element.file.compareServerIdentity(widget.fd)); - if (thisItem.addedBy == widget.account.userId) { - return true; - } - } catch (_) {} - return false; - } - late final DiContainer _c; File? _file; @@ -495,9 +474,17 @@ class _ViewerDetailPaneState extends State { final _tags = []; - late final bool _canRemoveFromAlbum = _checkCanRemoveFromAlbum(); - var _shouldBlockGpsMap = true; + + late final bool _canRemoveFromAlbum = widget.fromCollection?.run((d) => + CollectionAdapter.of(_c, widget.account, d.collection) + .isItemRemovable(widget.fromCollection!.item)) ?? + false; + + late final bool _canSetCover = widget.fromCollection?.run((d) => + CollectionAdapter.of(_c, widget.account, d.collection) + .isPermitted(CollectionCapability.manualCover)) ?? + false; } class _DetailPaneButton extends StatelessWidget { diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index d21c9771..5ef6cb85 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:intl/intl.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; @@ -1758,6 +1759,12 @@ void main() { }); }); }); + + group("AlbumUpgraderV8", () { + test("non manual cover", _upgradeV8NonManualCover); + test("manual cover", _upgradeV8ManualCover); + test("manual cover (exif time)", _upgradeV8ManualExifTime); + }); }); } @@ -1883,6 +1890,195 @@ void _toAppDbJsonShares() { }); } +void _upgradeV8NonManualCover() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "memory", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + +void _upgradeV8ManualCover() { + withClock(Clock.fixed(DateTime.utc(2020, 1, 2, 3, 4, 5)), () { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + "fdDateTime": "2020-01-02T03:04:05.000Z", + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); +} + +void _upgradeV8ManualExifTime() { + final json = { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "fileId": 1, + "metadata": { + "exif": { + "DateTimeOriginal": "2020:01:02 03:04:05", + }, + }, + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(const AlbumUpgraderV8()(json), { + "version": 8, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "manual", + "content": { + "coverFile": { + "fdPath": "remote.php/dav/files/admin/test1.jpg", + "fdId": 1, + "fdMime": null, + "fdIsArchived": false, + "fdIsFavorite": false, + // dart does not provide a way to mock timezone + "fdDateTime": DateTime(2020, 1, 2, 3, 4, 5).toUtc().toIso8601String(), + }, + }, + }, + "sortProvider": { + "type": "null", + "content": {}, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); +} + class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { const _NullAlbumUpgraderFactory(); @@ -1900,4 +2096,6 @@ class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { buildV6() => null; @override buildV7() => null; + @override + AlbumUpgraderV8? buildV8() => null; }