diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 7ec448fa..5777d4a5 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -8,16 +8,22 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/controller/collection_items_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.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/entity/sharee.dart'; +import 'package:nc_photos/exception.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'; import 'package:nc_photos/use_case/collection/list_collection.dart'; import 'package:nc_photos/use_case/collection/remove_collections.dart'; +import 'package:nc_photos/use_case/collection/share_collection.dart'; +import 'package:nc_photos/use_case/collection/unshare_collection.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:rxdart/rxdart.dart'; @@ -183,6 +189,54 @@ class CollectionsController { } } + Future share(Collection collection, Sharee sharee) async { + try { + Collection? newCollection; + final result = await _mutex.protect(() async { + return await ShareCollection(_c)( + account, + collection, + sharee, + onCollectionUpdated: (c) { + newCollection = c; + }, + ); + }); + if (newCollection != null) { + _updateCollection(newCollection!); + } + if (result == CollectionShareResult.partial) { + _dataStreamController.addError(const CollectionPartialShareException()); + } + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + } + } + + Future unshare(Collection collection, CiString userId) async { + try { + Collection? newCollection; + final result = await _mutex.protect(() async { + return await UnshareCollection(_c)( + account, + collection, + userId, + onCollectionUpdated: (c) { + newCollection = c; + }, + ); + }); + if (newCollection != null) { + _updateCollection(newCollection!); + } + if (result == CollectionShareResult.partial) { + _dataStreamController.addError(const CollectionPartialShareException()); + } + } catch (e, stackTrace) { + _dataStreamController.addError(e, stackTrace); + } + } + Future _load() async { var lastData = const CollectionStreamEvent( data: [], diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index a9bdca0f..858860d3 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -1,5 +1,6 @@ import 'package:copy_with/copy_with.dart'; import 'package:equatable/equatable.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/sorter.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:to_string/to_string.dart'; @@ -40,6 +41,9 @@ class Collection with EquatableMixin { /// See [CollectionContentProvider.itemSort] CollectionItemSort get itemSort => contentProvider.itemSort; + /// See [CollectionContentProvider.sharees] + List get shares => contentProvider.shares; + /// See [CollectionContentProvider.getCoverUrl] String? getCoverUrl( int width, @@ -77,6 +81,8 @@ enum CollectionCapability { labelItem, // set the cover image manualCover, + // share the collection with other user on the same server + share, } /// Provide the actual content of a collection @@ -106,6 +112,10 @@ abstract class CollectionContentProvider with EquatableMixin { /// Return the sort type CollectionItemSort get itemSort; + /// Return list of users who have access to this collection, excluding the + /// current user + List get shares; + /// Return the URL of the cover image if available /// /// The [width] and [height] are provided as a hint only, implementations are diff --git a/app/lib/entity/collection/adapter.dart b/app/lib/entity/collection/adapter.dart index db703281..a348e089 100644 --- a/app/lib/entity/collection/adapter.dart +++ b/app/lib/entity/collection/adapter.dart @@ -14,11 +14,14 @@ import 'package:nc_photos/entity/collection/content_provider/memory.dart'; import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; import 'package:nc_photos/entity/collection/content_provider/person.dart'; import 'package:nc_photos/entity/collection/content_provider/tag.dart'; +import 'package:nc_photos/entity/collection/util.dart'; 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/entity/sharee.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; abstract class CollectionAdapter { @@ -71,6 +74,18 @@ abstract class CollectionAdapter { required ValueChanged onCollectionUpdated, }); + /// Share the collection with [sharee] + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }); + + /// Unshare the collection with a user + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }); + /// Convert a [NewCollectionItem] to an adapted one Future adaptToNewItem(NewCollectionItem original); diff --git a/app/lib/entity/collection/adapter/read_only_adapter.dart b/app/lib/entity/collection/adapter/adapter_mixin.dart similarity index 63% rename from app/lib/entity/collection/adapter/read_only_adapter.dart rename to app/lib/entity/collection/adapter/adapter_mixin.dart index e5766f18..8deda148 100644 --- a/app/lib/entity/collection/adapter/read_only_adapter.dart +++ b/app/lib/entity/collection/adapter/adapter_mixin.dart @@ -1,14 +1,17 @@ import 'package:flutter/foundation.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/util.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/entity/sharee.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; /// A read-only collection that does not support modifying its items -mixin CollectionReadOnlyAdapter implements CollectionAdapter { +mixin CollectionAdapterReadOnlyTag implements CollectionAdapter { @override Future addFiles( List files, { @@ -48,3 +51,28 @@ mixin CollectionReadOnlyAdapter implements CollectionAdapter { Future updatePostLoad(List items) => Future.value(null); } + +mixin CollectionAdapterUnremovableTag implements CollectionAdapter { + @override + Future remove() { + throw UnsupportedError("Operation not supported"); + } +} + +mixin CollectionAdapterUnshareableTag implements CollectionAdapter { + @override + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } + + @override + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }) { + throw UnsupportedError("Operation not supported"); + } +} diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index 36486eb5..b4b6352c 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/debug_util.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'; @@ -10,12 +11,14 @@ import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/album_item_adapter.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.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; @@ -23,9 +26,12 @@ 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'; import 'package:nc_photos/use_case/album/remove_from_album.dart'; +import 'package:nc_photos/use_case/album/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; import 'package:np_common/type.dart'; import 'package:tuple/tuple.dart'; @@ -173,6 +179,50 @@ class CollectionAlbumAdapter implements CollectionAdapter { } } + @override + Future share( + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) async { + var fileFailed = false; + final newAlbum = await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)( + account, + _provider.album, + sharee, + onShareFileFailed: (f, e, stackTrace) { + _log.severe("[share] Failed to share file: ${logFilename(f.path)}", e, + stackTrace); + fileFailed = true; + }, + ); + onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum)); + return fileFailed + ? CollectionShareResult.partial + : CollectionShareResult.ok; + } + + @override + Future unshare( + CiString userId, { + required ValueChanged onCollectionUpdated, + }) async { + var fileFailed = false; + final newAlbum = await UnshareAlbumWithUser(_c)( + account, + _provider.album, + userId, + onUnshareFileFailed: (f, e, stackTrace) { + _log.severe("[unshare] Failed to unshare file: ${logFilename(f.path)}", + e, stackTrace); + fileFailed = true; + }, + ); + onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum)); + return fileFailed + ? CollectionShareResult.partial + : CollectionShareResult.ok; + } + @override Future adaptToNewItem(NewCollectionItem original) async { if (original is NewCollectionFileItem) { diff --git a/app/lib/entity/collection/adapter/location_group.dart b/app/lib/entity/collection/adapter/location_group.dart index 3f012101..e287dfe6 100644 --- a/app/lib/entity/collection/adapter/location_group.dart +++ b/app/lib/entity/collection/adapter/location_group.dart @@ -2,7 +2,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; -import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/location_group.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; @@ -11,7 +11,10 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/use_case/list_location_file.dart'; class CollectionLocationGroupAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionLocationGroupAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -43,11 +46,6 @@ class CollectionLocationGroupAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/memory.dart b/app/lib/entity/collection/adapter/memory.dart index 53a16a4d..eea3948f 100644 --- a/app/lib/entity/collection/adapter/memory.dart +++ b/app/lib/entity/collection/adapter/memory.dart @@ -2,7 +2,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; -import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/memory.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; @@ -11,7 +11,10 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/use_case/list_location_file.dart'; class CollectionMemoryAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionMemoryAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -42,11 +45,6 @@ class CollectionMemoryAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index 28072cf7..d3f4cd5c 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; @@ -27,7 +28,9 @@ import 'package:np_common/type.dart'; part 'nc_album.g.dart'; @npLog -class CollectionNcAlbumAdapter implements CollectionAdapter { +class CollectionNcAlbumAdapter + with CollectionAdapterUnshareableTag + implements CollectionAdapter { CollectionNcAlbumAdapter(this._c, this.account, this.collection) : assert(require(_c)), _provider = collection.contentProvider as CollectionNcAlbumProvider; diff --git a/app/lib/entity/collection/adapter/person.dart b/app/lib/entity/collection/adapter/person.dart index 926b202d..a4527dd2 100644 --- a/app/lib/entity/collection/adapter/person.dart +++ b/app/lib/entity/collection/adapter/person.dart @@ -2,7 +2,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; -import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/person.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; @@ -12,7 +12,10 @@ import 'package:nc_photos/use_case/list_face.dart'; import 'package:nc_photos/use_case/populate_person.dart'; class CollectionPersonAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionPersonAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -45,11 +48,6 @@ class CollectionPersonAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/adapter/tag.dart b/app/lib/entity/collection/adapter/tag.dart index 29135ac5..1226c181 100644 --- a/app/lib/entity/collection/adapter/tag.dart +++ b/app/lib/entity/collection/adapter/tag.dart @@ -2,14 +2,17 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; -import 'package:nc_photos/entity/collection/adapter/read_only_adapter.dart'; +import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart'; import 'package:nc_photos/entity/collection/content_provider/tag.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/collection_item/basic_item.dart'; import 'package:nc_photos/use_case/list_tagged_file.dart'; class CollectionTagAdapter - with CollectionReadOnlyAdapter + with + CollectionAdapterReadOnlyTag, + CollectionAdapterUnremovableTag, + CollectionAdapterUnshareableTag implements CollectionAdapter { CollectionTagAdapter(this._c, this.account, this.collection) : assert(require(_c)), @@ -32,11 +35,6 @@ class CollectionTagAdapter } } - @override - Future remove() { - throw UnsupportedError("Operation not supported"); - } - @override bool isPermitted(CollectionCapability capability) => _provider.capabilities.contains(capability); diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 9914fa90..93c62bad 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:to_string/to_string.dart'; @@ -52,6 +53,7 @@ class CollectionAlbumProvider CollectionCapability.manualItem, CollectionCapability.manualSort, CollectionCapability.labelItem, + CollectionCapability.share, ], ]; @@ -66,6 +68,17 @@ class CollectionAlbumProvider @override CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort(); + @override + List get shares => + album.shares + ?.where((s) => s.userId != account.userId) + .map((s) => CollectionShare( + userId: s.userId, + username: s.displayName ?? s.userId.raw, + )) + .toList() ?? + const []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/location_group.dart b/app/lib/entity/collection/content_provider/location_group.dart index 6316eb99..cf809b48 100644 --- a/app/lib/entity/collection/content_provider/location_group.dart +++ b/app/lib/entity/collection/content_provider/location_group.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; @@ -31,6 +32,9 @@ class CollectionLocationGroupProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/memory.dart b/app/lib/entity/collection/content_provider/memory.dart index c8ed13c5..a8fcfdf3 100644 --- a/app/lib/entity/collection/content_provider/memory.dart +++ b/app/lib/entity/collection/content_provider/memory.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/object_extension.dart'; @@ -39,6 +40,9 @@ class CollectionMemoryProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/nc_album.dart b/app/lib/entity/collection/content_provider/nc_album.dart index cd22d7fb..f2b00286 100644 --- a/app/lib/entity/collection/content_provider/nc_album.dart +++ b/app/lib/entity/collection/content_provider/nc_album.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/nc_album.dart'; import 'package:to_string/to_string.dart'; @@ -45,6 +46,9 @@ class CollectionNcAlbumProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/person.dart b/app/lib/entity/collection/content_provider/person.dart index 16e7efae..f9be868c 100644 --- a/app/lib/entity/collection/content_provider/person.dart +++ b/app/lib/entity/collection/content_provider/person.dart @@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/person.dart'; @@ -34,6 +35,9 @@ class CollectionPersonProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/content_provider/tag.dart b/app/lib/entity/collection/content_provider/tag.dart index 23bc98f8..729160b7 100644 --- a/app/lib/entity/collection/content_provider/tag.dart +++ b/app/lib/entity/collection/content_provider/tag.dart @@ -2,6 +2,7 @@ import 'package:clock/clock.dart'; import 'package:equatable/equatable.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/tag.dart'; @@ -31,6 +32,9 @@ class CollectionTagProvider @override CollectionItemSort get itemSort => CollectionItemSort.dateDescending; + @override + List get shares => []; + @override String? getCoverUrl( int width, diff --git a/app/lib/entity/collection/util.dart b/app/lib/entity/collection/util.dart index a8b7f07c..905cec22 100644 --- a/app/lib/entity/collection/util.dart +++ b/app/lib/entity/collection/util.dart @@ -1,7 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:to_string/to_string.dart'; import 'package:tuple/tuple.dart'; +part 'util.g.dart'; + enum CollectionSort { dateDescending, dateAscending, @@ -14,6 +19,28 @@ enum CollectionSort { } } +@toString +class CollectionShare with EquatableMixin { + const CollectionShare({ + required this.userId, + required this.username, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [userId, username]; + + final CiString userId; + final String username; +} + +enum CollectionShareResult { + ok, + partial, +} + extension CollectionListExtension on Iterable { List sortedBy(CollectionSort by) { return map>((e) { diff --git a/app/lib/entity/collection/util.g.dart b/app/lib/entity/collection/util.g.dart new file mode 100644 index 00000000..ededd4c9 --- /dev/null +++ b/app/lib/entity/collection/util.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'util.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$CollectionShareToString on CollectionShare { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionShare {userId: $userId, username: $username}"; + } +} diff --git a/app/lib/exception.dart b/app/lib/exception.dart index 714edf01..5f7444b7 100644 --- a/app/lib/exception.dart +++ b/app/lib/exception.dart @@ -103,3 +103,18 @@ class AlbumItemPermissionException implements Exception { final dynamic message; } + +class CollectionPartialShareException implements Exception { + const CollectionPartialShareException([this.message]); + + @override + String toString() { + if (message == null) { + return "CollectionPartialShareException"; + } else { + return "CollectionPartialShareException: $message"; + } + } + + final dynamic message; +} diff --git a/app/lib/suggester.dart b/app/lib/suggester.dart new file mode 100644 index 00000000..20f0dc07 --- /dev/null +++ b/app/lib/suggester.dart @@ -0,0 +1,57 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:tuple/tuple.dart'; +import 'package:woozy_search/woozy_search.dart'; + +part 'suggester.g.dart'; + +@npLog +class Suggester { + Suggester({ + required this.items, + required this.itemToKeywords, + int maxResult = 5, + }) : _searcher = Woozy(limit: maxResult) { + for (final a in items) { + for (final k in itemToKeywords(a)) { + _searcher.addEntry(k.toCaseInsensitiveString(), value: a); + } + } + } + + List search(CiString phrase) { + final results = _searcher.search(phrase.toCaseInsensitiveString()); + if (kDebugMode) { + final str = results.map((e) => "${e.score}: ${e.text}").join("\n"); + _log.info("[search] Search '$phrase':\n$str"); + } + final matches = results + .where((e) => e.score > 0) + .map((e) { + if (itemToKeywords(e.value as T).any((k) => k.startsWith(phrase))) { + // prefer names that start exactly with the search phrase + return Tuple2(e.score + 1, e.value as T); + } else { + return Tuple2(e.score, e.value as T); + } + }) + .sorted((a, b) => a.item1.compareTo(b.item1)) + .reversed + .distinctIf( + (a, b) => identical(a.item2, b.item2), + (a) => a.item2.hashCode, + ) + .map((e) => e.item2) + .toList(); + return matches; + } + + final List items; + final List Function(T item) itemToKeywords; + + final Woozy _searcher; +} diff --git a/app/lib/suggester.g.dart b/app/lib/suggester.g.dart new file mode 100644 index 00000000..9f60e8d5 --- /dev/null +++ b/app/lib/suggester.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'suggester.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$SuggesterNpLog on Suggester { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("suggester.Suggester"); +} diff --git a/app/lib/use_case/album/remove_album.dart b/app/lib/use_case/album/remove_album.dart index 46f07573..b4c6c23f 100644 --- a/app/lib/use_case/album/remove_album.dart +++ b/app/lib/use_case/album/remove_album.dart @@ -6,10 +6,10 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/use_case/album/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/use_case/remove_share.dart'; -import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; diff --git a/app/lib/use_case/album/remove_from_album.dart b/app/lib/use_case/album/remove_from_album.dart index d9af6ad7..cf8deb56 100644 --- a/app/lib/use_case/album/remove_from_album.dart +++ b/app/lib/use_case/album/remove_from_album.dart @@ -10,8 +10,8 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/use_case/album/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/preprocess_album.dart'; -import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:np_codegen/np_codegen.dart'; diff --git a/app/lib/use_case/share_album_with_user.dart b/app/lib/use_case/album/share_album_with_user.dart similarity index 91% rename from app/lib/use_case/share_album_with_user.dart rename to app/lib/use_case/album/share_album_with_user.dart index 2b02beb2..342367c9 100644 --- a/app/lib/use_case/share_album_with_user.dart +++ b/app/lib/use_case/album/share_album_with_user.dart @@ -13,6 +13,7 @@ import 'package:nc_photos/use_case/create_share.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'share_album_with_user.g.dart'; @@ -24,7 +25,7 @@ class ShareAlbumWithUser { Account account, Album album, Sharee sharee, { - void Function(File)? onShareFileFailed, + ErrorWithValueHandler? onShareFileFailed, }) async { assert(album.provider is AlbumStaticProvider); final newShares = (album.shares ?? []) @@ -55,7 +56,7 @@ class ShareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(File)? onShareFileFailed, + ErrorWithValueHandler? onShareFileFailed, }) async { final files = AlbumStaticProvider.of(album) .items @@ -70,7 +71,7 @@ class ShareAlbumWithUser { "[_createFileShares] Failed sharing album file '${logFilename(album.albumFile?.path)}' with '$shareWith'", e, stackTrace); - onShareFileFailed?.call(album.albumFile!); + onShareFileFailed?.call(album.albumFile!, e, stackTrace); } for (final f in files) { _log.info("[_createFileShares] Sharing '${f.path}' with '$shareWith'"); @@ -81,7 +82,7 @@ class ShareAlbumWithUser { "[_createFileShares] Failed sharing file '${logFilename(f.path)}' with '$shareWith'", e, stackTrace); - onShareFileFailed?.call(f); + onShareFileFailed?.call(f, e, stackTrace); } } } diff --git a/app/lib/use_case/share_album_with_user.g.dart b/app/lib/use_case/album/share_album_with_user.g.dart similarity index 84% rename from app/lib/use_case/share_album_with_user.g.dart rename to app/lib/use_case/album/share_album_with_user.g.dart index 33af6875..3fe70319 100644 --- a/app/lib/use_case/share_album_with_user.g.dart +++ b/app/lib/use_case/album/share_album_with_user.g.dart @@ -11,5 +11,5 @@ extension _$ShareAlbumWithUserNpLog on ShareAlbumWithUser { Logger get _log => log; static final log = - Logger("use_case.share_album_with_user.ShareAlbumWithUser"); + Logger("use_case.album.share_album_with_user.ShareAlbumWithUser"); } diff --git a/app/lib/use_case/unshare_album_with_user.dart b/app/lib/use_case/album/unshare_album_with_user.dart similarity index 90% rename from app/lib/use_case/unshare_album_with_user.dart rename to app/lib/use_case/album/unshare_album_with_user.dart index be9b1e26..58e8d620 100644 --- a/app/lib/use_case/unshare_album_with_user.dart +++ b/app/lib/use_case/album/unshare_album_with_user.dart @@ -7,12 +7,13 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/use_case/album/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove_share.dart'; -import 'package:nc_photos/use_case/unshare_file_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'unshare_album_with_user.g.dart'; @@ -31,7 +32,7 @@ class UnshareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { assert(album.provider is AlbumStaticProvider); // remove the share from album file @@ -59,7 +60,7 @@ class UnshareAlbumWithUser { Account account, Album album, CiString shareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { // remove share from the album file final albumShares = await ListShare(_c)(account, album.albumFile!); @@ -71,7 +72,7 @@ class UnshareAlbumWithUser { "[_deleteFileShares] Failed unsharing album file '${logFilename(album.albumFile?.path)}' with '$shareWith'", e, stackTrace); - onUnshareFileFailed?.call(s); + onUnshareFileFailed?.call(s, e, stackTrace); } } diff --git a/app/lib/use_case/unshare_album_with_user.g.dart b/app/lib/use_case/album/unshare_album_with_user.g.dart similarity index 83% rename from app/lib/use_case/unshare_album_with_user.g.dart rename to app/lib/use_case/album/unshare_album_with_user.g.dart index e80f8bff..852e860f 100644 --- a/app/lib/use_case/unshare_album_with_user.g.dart +++ b/app/lib/use_case/album/unshare_album_with_user.g.dart @@ -11,5 +11,5 @@ extension _$UnshareAlbumWithUserNpLog on UnshareAlbumWithUser { Logger get _log => log; static final log = - Logger("use_case.unshare_album_with_user.UnshareAlbumWithUser"); + Logger("use_case.album.unshare_album_with_user.UnshareAlbumWithUser"); } diff --git a/app/lib/use_case/unshare_file_from_album.dart b/app/lib/use_case/album/unshare_file_from_album.dart similarity index 94% rename from app/lib/use_case/unshare_file_from_album.dart rename to app/lib/use_case/album/unshare_file_from_album.dart index 94c609cf..3e205d86 100644 --- a/app/lib/use_case/unshare_file_from_album.dart +++ b/app/lib/use_case/album/unshare_file_from_album.dart @@ -14,6 +14,7 @@ import 'package:nc_photos/use_case/list_share.dart'; import 'package:nc_photos/use_case/remove_share.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; part 'unshare_file_from_album.g.dart'; @@ -35,7 +36,7 @@ class UnshareFileFromAlbum { Album album, List files, List unshareWith, { - void Function(Share)? onUnshareFileFailed, + ErrorWithValueHandler? onUnshareFileFailed, }) async { _log.info( "[call] Unshare ${files.length} files from album '${album.name}' with ${unshareWith.length} users"); @@ -85,14 +86,14 @@ class UnshareFileFromAlbum { } Future _unshare(Account account, List shares, - void Function(Share)? onUnshareFileFailed) async { + ErrorWithValueHandler? onUnshareFileFailed) async { for (final s in shares) { try { await RemoveShare(_c.shareRepo)(account, s); } catch (e, stackTrace) { _log.severe( "[_unshare] Failed while RemoveShare: ${s.path}", e, stackTrace); - onUnshareFileFailed?.call(s); + onUnshareFileFailed?.call(s, e, stackTrace); } } } diff --git a/app/lib/use_case/unshare_file_from_album.g.dart b/app/lib/use_case/album/unshare_file_from_album.g.dart similarity index 83% rename from app/lib/use_case/unshare_file_from_album.g.dart rename to app/lib/use_case/album/unshare_file_from_album.g.dart index 89680f06..adaae550 100644 --- a/app/lib/use_case/unshare_file_from_album.g.dart +++ b/app/lib/use_case/album/unshare_file_from_album.g.dart @@ -11,5 +11,5 @@ extension _$UnshareFileFromAlbumNpLog on UnshareFileFromAlbum { Logger get _log => log; static final log = - Logger("use_case.unshare_file_from_album.UnshareFileFromAlbum"); + Logger("use_case.album.unshare_file_from_album.UnshareFileFromAlbum"); } diff --git a/app/lib/use_case/collection/share_collection.dart b/app/lib/use_case/collection/share_collection.dart new file mode 100644 index 00000000..58130aa2 --- /dev/null +++ b/app/lib/use_case/collection/share_collection.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/util.dart'; +import 'package:nc_photos/entity/sharee.dart'; + +class ShareCollection { + const ShareCollection(this._c); + + /// Share the collection with [sharee] + Future call( + Account account, + Collection collection, + Sharee sharee, { + required ValueChanged onCollectionUpdated, + }) => + CollectionAdapter.of(_c, account, collection).share( + sharee, + onCollectionUpdated: onCollectionUpdated, + ); + + final DiContainer _c; +} diff --git a/app/lib/use_case/collection/unshare_collection.dart b/app/lib/use_case/collection/unshare_collection.dart new file mode 100644 index 00000000..ed2a4270 --- /dev/null +++ b/app/lib/use_case/collection/unshare_collection.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; +import 'package:nc_photos/entity/collection/util.dart'; +import 'package:np_common/ci_string.dart'; + +class UnshareCollection { + const UnshareCollection(this._c); + + /// Unshare the collection with a user + Future call( + Account account, + Collection collection, + CiString userId, { + required ValueChanged onCollectionUpdated, + }) => + CollectionAdapter.of(_c, account, collection).unshare( + userId, + onCollectionUpdated: onCollectionUpdated, + ); + + final DiContainer _c; +} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 7cc7a4ba..050f0a10 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -52,6 +52,7 @@ import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; import 'package:nc_photos/widget/selectable_item_list.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos/widget/share_collection_dialog.dart'; import 'package:nc_photos/widget/simple_input_dialog.dart'; import 'package:nc_photos/widget/viewer.dart'; import 'package:nc_photos/widget/zoom_menu_button.dart'; diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 69288e9d..97a2c9a3 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -16,6 +16,7 @@ class _AppBar extends StatelessWidget { final canRename = adapter.isPermitted(CollectionCapability.rename); final canManualCover = adapter.isPermitted(CollectionCapability.manualCover); + final canShare = adapter.isPermitted(CollectionCapability.share); final actions = [ ZoomMenuButton( @@ -26,6 +27,12 @@ class _AppBar extends StatelessWidget { context.read().setAlbumBrowserZoomLevel(value); }, ), + if (canShare) + IconButton( + onPressed: () => _onSharePressed(context), + icon: const Icon(Icons.share), + tooltip: L10n.global().shareTooltip, + ), ]; if (state.items.isNotEmpty || canRename) { actions.add(PopupMenuButton<_MenuOption>( @@ -107,6 +114,17 @@ class _AppBar extends StatelessWidget { Navigator.of(context).pop(); } } + + Future _onSharePressed(BuildContext context) async { + final bloc = context.read<_Bloc>(); + await showDialog( + context: context, + builder: (_) => ShareCollectionDialog( + account: bloc.account, + collection: bloc.state.collection, + ), + ); + } } class _AppBarCover extends StatelessWidget { diff --git a/app/lib/widget/share_album_dialog.dart b/app/lib/widget/share_album_dialog.dart index 785ca676..e406cc80 100644 --- a/app/lib/widget/share_album_dialog.dart +++ b/app/lib/widget/share_album_dialog.dart @@ -15,8 +15,8 @@ import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; -import 'package:nc_photos/use_case/share_album_with_user.dart'; -import 'package:nc_photos/use_case/unshare_album_with_user.dart'; +import 'package:nc_photos/use_case/album/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/dialog_scaffold.dart'; import 'package:np_codegen/np_codegen.dart'; @@ -240,7 +240,7 @@ class _ShareAlbumDialogState extends State { widget.account, _album, sharee, - onShareFileFailed: (_) { + onShareFileFailed: (_, __, ___) { hasFailure = true; }, ); @@ -279,7 +279,7 @@ class _ShareAlbumDialogState extends State { widget.account, _album, share.shareWith, - onUnshareFileFailed: (_) { + onUnshareFileFailed: (_, __, ___) { hasFailure = true; }, ); diff --git a/app/lib/widget/share_collection_dialog.dart b/app/lib/widget/share_collection_dialog.dart new file mode 100644 index 00000000..5dc2ebe5 --- /dev/null +++ b/app/lib/widget/share_collection_dialog.dart @@ -0,0 +1,237 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.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/collections_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/util.dart'; +import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/suggester.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/ci_string.dart'; +import 'package:to_string/to_string.dart'; + +part 'share_collection_dialog.g.dart'; +part 'share_collection_dialog/bloc.dart'; +part 'share_collection_dialog/state_event.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; + +/// Dialog to share a new collection to other user on the same server +/// +/// Return the created collection, or null if user cancelled +class ShareCollectionDialog extends StatelessWidget { + const ShareCollectionDialog({ + super.key, + required this.account, + required this.collection, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + container: KiwiContainer().resolve(), + account: account, + collectionsController: + context.read().collectionsController, + collection: collection, + ), + child: const _WrappedShareCollectionDialog(), + ); + } + + final Account account; + final Collection collection; +} + +class _WrappedShareCollectionDialog extends StatefulWidget { + const _WrappedShareCollectionDialog(); + + @override + State createState() => _WrappedShareCollectionDialogState(); +} + +class _WrappedShareCollectionDialogState + extends State<_WrappedShareCollectionDialog> { + @override + void initState() { + super.initState(); + _bloc.add(const _LoadSharee()); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.error != current.error, + listener: (context, state) { + if (state.error != null) { + if (state.error!.error is CollectionPartialShareException) { + // TODO localize string + SnackBarManager().showSnackBar(const SnackBar( + content: Text("Collection shared partially"), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + } + }, + ), + ], + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.collection != current.collection || + previous.processingShares != current.processingShares, + builder: (context, state) { + final shares = { + ...state.collection.shares, + ...state.processingShares, + }.sortedBy((e) => e.username); + return SimpleDialog( + title: Text(L10n.global().shareAlbumDialogTitle), + children: [ + ...shares.map((s) => _ShareView( + share: s, + isProcessing: state.processingShares.contains(s), + onPressed: () { + _bloc.add(_Unshare(s)); + }, + )), + const _ShareeInputView(), + ], + ); + }, + ), + ); + } + + late final _bloc = context.read<_Bloc>(); +} + +class _ShareeInputView extends StatefulWidget { + const _ShareeInputView(); + + @override + State createState() => _ShareeInputViewState(); +} + +class _ShareeInputViewState extends State<_ShareeInputView> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.shareeSuggester != current.shareeSuggester, + listener: (context, state) { + // search again + if (_lastPattern != null) { + _onSearch(_lastPattern!); + } + }, + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: TypeAheadField( + textFieldConfiguration: TextFieldConfiguration( + controller: _textController, + decoration: InputDecoration( + hintText: L10n.global().addUserInputHint, + ), + ), + direction: AxisDirection.up, + suggestionsCallback: _onSearch, + itemBuilder: (context, suggestion) => ListTile( + title: Text(suggestion.label), + subtitle: Text(suggestion.shareWith.toString()), + ), + onSuggestionSelected: _onSuggestionSelected, + hideOnEmpty: true, + hideOnLoading: true, + autoFlipDirection: true, + ), + ), + ); + } + + Iterable _onSearch(String pattern) { + _lastPattern = pattern; + final suggester = _bloc.state.shareeSuggester; + return suggester?.search(pattern.toCi()) ?? []; + } + + void _onSuggestionSelected(Sharee sharee) { + _textController.clear(); + _bloc.add(_Share(sharee)); + } + + late final _bloc = context.read<_Bloc>(); + final _textController = TextEditingController(); + + String? _lastPattern; +} + +class _ShareView extends StatelessWidget { + const _ShareView({ + required this.share, + required this.isProcessing, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final Widget trailing; + if (isProcessing) { + trailing = const Padding( + padding: EdgeInsetsDirectional.only(end: 12), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ), + ); + } else { + trailing = Checkbox( + value: true, + onChanged: (_) {}, + ); + } + return SimpleDialogOption( + onPressed: isProcessing ? null : onPressed, + child: ListTile( + title: Text(share.username), + subtitle: Text(share.userId.toString()), + // pass through the tap event + trailing: IgnorePointer( + child: trailing, + ), + ), + ); + } + + final CollectionShare share; + final bool isProcessing; + final VoidCallback? onPressed; +} diff --git a/app/lib/widget/share_collection_dialog.g.dart b/app/lib/widget/share_collection_dialog.g.dart new file mode 100644 index 00000000..cbe3acf4 --- /dev/null +++ b/app/lib/widget/share_collection_dialog.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'share_collection_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {Collection? collection, + List? processingShares, + List? sharees, + Suggester? shareeSuggester, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic collection, + dynamic processingShares, + dynamic sharees = copyWithNull, + dynamic shareeSuggester = copyWithNull, + dynamic error = copyWithNull}) { + return _State( + collection: collection as Collection? ?? that.collection, + processingShares: + processingShares as List? ?? that.processingShares, + sharees: + sharees == copyWithNull ? that.sharees : sharees as List?, + shareeSuggester: shareeSuggester == copyWithNull + ? that.shareeSuggester + : shareeSuggester as Suggester?, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.share_collection_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {collection: $collection, processingShares: [length: ${processingShares.length}], sharees: ${sharees == null ? null : "[length: ${sharees!.length}]"}, shareeSuggester: $shareeSuggester, error: $error}"; + } +} + +extension _$_UpdateCollectionToString on _UpdateCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_UpdateCollection {collection: $collection}"; + } +} + +extension _$_LoadShareeToString on _LoadSharee { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadSharee {}"; + } +} + +extension _$_RefreshSuggesterToString on _RefreshSuggester { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_RefreshSuggester {}"; + } +} + +extension _$_ShareToString on _Share { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Share {sharee: $sharee}"; + } +} + +extension _$_UnshareToString on _Unshare { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Unshare {share: $share}"; + } +} + +extension _$_SetErrorToString on _SetError { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetError {error: $error, stackTrace: $stackTrace}"; + } +} diff --git a/app/lib/widget/share_collection_dialog/bloc.dart b/app/lib/widget/share_collection_dialog/bloc.dart new file mode 100644 index 00000000..56ad2185 --- /dev/null +++ b/app/lib/widget/share_collection_dialog/bloc.dart @@ -0,0 +1,147 @@ +part of '../share_collection_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required DiContainer container, + required this.account, + required this.collectionsController, + required Collection collection, + }) : _c = container, + super(_State.init( + collection: collection, + )) { + on<_UpdateCollection>(_onUpdateCollection); + on<_LoadSharee>(_onLoadSharee); + on<_RefreshSuggester>(_onRefreshSuggester); + + on<_ShareEventTag>((ev, emit) { + if (ev is _Share) { + return _onShare(ev, emit); + } else if (ev is _Unshare) { + return _onUnshare(ev, emit); + } else { + throw UnimplementedError(); + } + }); + + on<_SetError>(_onSetError); + + _collectionControllerSubscription = collectionsController.stream.listen( + (event) { + final c = event.data + .firstWhere((d) => state.collection.compareIdentity(d.collection)); + if (!identical(c, state.collection)) { + add(_UpdateCollection(c.collection)); + } + }, + onError: (e, stackTrace) { + add(_SetError(e, stackTrace)); + }, + ); + } + + @override + Future close() { + _collectionControllerSubscription?.cancel(); + return super.close(); + } + + @override + void onChange(Change<_State> change) { + if (change.currentState.sharees != change.nextState.sharees || + change.currentState.collection != change.nextState.collection || + change.currentState.processingShares != + change.nextState.processingShares) { + add(const _RefreshSuggester()); + } + super.onChange(change); + } + + @override + void onError(Object error, StackTrace stackTrace) { + add(_SetError(error, stackTrace)); + super.onError(error, stackTrace); + } + + void _onUpdateCollection(_UpdateCollection ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(collection: ev.collection)); + } + + Future _onLoadSharee(_LoadSharee ev, Emitter<_State> emit) async { + _log.info(ev); + final sharees = await _c.shareeRepo.list(account); + emit(state.copyWith(sharees: sharees)); + } + + void _onRefreshSuggester(_RefreshSuggester ev, Emitter<_State> emit) { + _log.info(ev); + final searchable = state.sharees + ?.where((s) => + !state.collection.shares.any((e) => e.userId == s.shareWith)) + .where((s) => + !state.processingShares.any((e) => e.userId == s.shareWith)) + .where((s) => s.shareWith != account.userId) + .toList() ?? + []; + emit(state.copyWith( + shareeSuggester: Suggester( + items: searchable, + itemToKeywords: (item) => [item.shareWith, item.label.toCi()], + maxResult: 10, + ), + )); + } + + Future _onShare(_Share ev, Emitter<_State> emit) async { + _log.info(ev); + if (state.collection.shares.any((s) => s.userId == ev.sharee.shareWith) || + state.processingShares.any((s) => s.userId == ev.sharee.shareWith)) { + _log.fine("[_onShare] Already shared with sharee: ${ev.sharee}"); + return; + } + emit(state.copyWith( + processingShares: [ + ...state.processingShares, + CollectionShare( + userId: ev.sharee.shareWith, + username: ev.sharee.label, + ), + ], + )); + await collectionsController.share(state.collection, ev.sharee); + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.sharee.shareWith) + .toList(), + )); + } + + Future _onUnshare(_Unshare ev, Emitter<_State> emit) async { + _log.info(ev); + emit(state.copyWith( + processingShares: [ + ...state.processingShares, + ev.share, + ], + )); + await collectionsController.unshare(state.collection, ev.share.userId); + emit(state.copyWith( + processingShares: state.processingShares + .where((s) => s.userId != ev.share.userId) + .toList(), + )); + } + + void _onSetError(_SetError ev, Emitter<_State> emit) { + _log.info(ev); + emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace))); + } + + final DiContainer _c; + final Account account; + final CollectionsController collectionsController; + + StreamSubscription? _collectionControllerSubscription; +} diff --git a/app/lib/widget/share_collection_dialog/state_event.dart b/app/lib/widget/share_collection_dialog/state_event.dart new file mode 100644 index 00000000..86eea090 --- /dev/null +++ b/app/lib/widget/share_collection_dialog/state_event.dart @@ -0,0 +1,96 @@ +part of '../share_collection_dialog.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.collection, + required this.processingShares, + this.sharees, + this.shareeSuggester, + this.error, + }); + + factory _State.init({ + required Collection collection, + }) { + return _State( + collection: collection, + processingShares: const [], + ); + } + + @override + String toString() => _$toString(); + + final Collection collection; + final List processingShares; + + final List? sharees; + final Suggester? shareeSuggester; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +@toString +class _UpdateCollection implements _Event { + const _UpdateCollection(this.collection); + + @override + String toString() => _$toString(); + + final Collection collection; +} + +@toString +class _LoadSharee implements _Event { + const _LoadSharee(); + + @override + String toString() => _$toString(); +} + +@toString +class _RefreshSuggester implements _Event { + const _RefreshSuggester(); + + @override + String toString() => _$toString(); +} + +mixin _ShareEventTag implements _Event {} + +@toString +class _Share with _ShareEventTag implements _Event { + const _Share(this.sharee); + + @override + String toString() => _$toString(); + + final Sharee sharee; +} + +@toString +class _Unshare with _ShareEventTag implements _Event { + const _Unshare(this.share); + + @override + String toString() => _$toString(); + + final CollectionShare share; +} + +@toString +class _SetError implements _Event { + const _SetError(this.error, [this.stackTrace]); + + @override + String toString() => _$toString(); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/app/test/use_case/share_album_with_user_test.dart b/app/test/use_case/share_album_with_user_test.dart index e829689a..b313e836 100644 --- a/app/test/use_case/share_album_with_user_test.dart +++ b/app/test/use_case/share_album_with_user_test.dart @@ -1,7 +1,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/use_case/share_album_with_user.dart'; +import 'package:nc_photos/use_case/album/share_album_with_user.dart'; import 'package:np_common/ci_string.dart'; import 'package:test/test.dart'; diff --git a/app/test/use_case/unshare_album_with_user_test.dart b/app/test/use_case/unshare_album_with_user_test.dart index 00cd6a55..59e6cf5d 100644 --- a/app/test/use_case/unshare_album_with_user_test.dart +++ b/app/test/use_case/unshare_album_with_user_test.dart @@ -1,7 +1,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/use_case/unshare_album_with_user.dart'; +import 'package:nc_photos/use_case/album/unshare_album_with_user.dart'; import 'package:np_common/ci_string.dart'; import 'package:test/test.dart';