diff --git a/app/lib/bloc/list_sharing.dart b/app/lib/bloc/list_sharing.dart deleted file mode 100644 index f9cd489d..00000000 --- a/app/lib/bloc/list_sharing.dart +++ /dev/null @@ -1,424 +0,0 @@ -import 'package:flutter/foundation.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/bloc/bloc_util.dart' as bloc_util; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_descriptor.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/entity/share.dart'; -import 'package:nc_photos/event/event.dart'; -import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; -import 'package:nc_photos/throttler.dart'; -import 'package:nc_photos/use_case/find_file.dart'; -import 'package:nc_photos/use_case/list_share_with_me.dart'; -import 'package:nc_photos/use_case/ls.dart'; -import 'package:nc_photos/use_case/ls_single_file.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:np_collection/np_collection.dart'; -import 'package:path/path.dart' as path_lib; -import 'package:to_string/to_string.dart'; - -part 'list_sharing.g.dart'; - -abstract class ListSharingItem { - const ListSharingItem(this.share); - - final Share share; -} - -class ListSharingFile extends ListSharingItem { - const ListSharingFile(super.share, this.file); - - final File file; -} - -class ListSharingAlbum extends ListSharingItem { - const ListSharingAlbum(super.share, this.album); - - final Album album; -} - -abstract class ListSharingBlocEvent { - const ListSharingBlocEvent(); -} - -@toString -class ListSharingBlocQuery extends ListSharingBlocEvent { - const ListSharingBlocQuery(this.account); - - @override - String toString() => _$toString(); - - final Account account; -} - -@toString -class _ListSharingBlocShareRemoved extends ListSharingBlocEvent { - const _ListSharingBlocShareRemoved(this.shares); - - @override - String toString() => _$toString(); - - @Format(r"${$?.toReadableString()}") - final List shares; -} - -@toString -class _ListSharingBlocPendingSharedAlbumMoved extends ListSharingBlocEvent { - const _ListSharingBlocPendingSharedAlbumMoved( - this.account, this.file, this.destination); - - @override - String toString() => _$toString(); - - final Account account; - final File file; - final String destination; -} - -@toString -abstract class ListSharingBlocState { - const ListSharingBlocState(this.account, this.items); - - @override - String toString() => _$toString(); - - final Account? account; - final List items; -} - -class ListSharingBlocInit extends ListSharingBlocState { - ListSharingBlocInit() : super(null, const []); -} - -class ListSharingBlocLoading extends ListSharingBlocState { - const ListSharingBlocLoading(Account? account, List items) - : super(account, items); -} - -class ListSharingBlocSuccess extends ListSharingBlocState { - const ListSharingBlocSuccess(Account? account, List items) - : super(account, items); - - ListSharingBlocSuccess copyWith({ - Account? account, - List? items, - }) => - ListSharingBlocSuccess( - account ?? this.account, - items ?? List.of(this.items), - ); -} - -@toString -class ListSharingBlocFailure extends ListSharingBlocState { - const ListSharingBlocFailure( - Account? account, List items, this.exception) - : super(account, items); - - @override - String toString() => _$toString(); - - ListSharingBlocFailure copyWith({ - Account? account, - List? items, - dynamic exception, - }) => - ListSharingBlocFailure( - account ?? this.account, - items ?? List.of(this.items), - exception ?? this.exception, - ); - - final dynamic exception; -} - -/// List shares to be shown in [SharingBrowser] -@npLog -class ListSharingBloc extends Bloc { - ListSharingBloc(this._c) - : assert(require(_c)), - assert(FindFile.require(_c)), - assert(ListShareWithMe.require(_c)), - assert(LsSingleFile.require(_c)), - super(ListSharingBlocInit()) { - _shareRemovedListener.begin(); - _fileMovedEventListener.begin(); - - _refreshThrottler = Throttler( - onTriggered: (shares) { - add(_ListSharingBlocShareRemoved(shares)); - }, - logTag: "ListSharingBloc.refresh", - ); - - on(_onEvent); - } - - static bool require(DiContainer c) => - DiContainer.has(c, DiType.albumRepo) && - DiContainer.has(c, DiType.fileRepo) && - DiContainer.has(c, DiType.shareRepo); - - static ListSharingBloc of(Account account) { - final name = - bloc_util.getInstNameForRootAwareAccount("ListSharingBloc", account); - try { - _log.fine("[of] Resolving bloc for '$name'"); - return KiwiContainer().resolve(name); - } catch (_) { - // no created instance for this account, make a new one - _log.info("[of] New bloc instance for account: $account"); - final bloc = ListSharingBloc(KiwiContainer().resolve()); - KiwiContainer().registerInstance(bloc, name: name); - return bloc; - } - } - - @override - close() { - _shareRemovedListener.end(); - _fileMovedEventListener.end(); - _refreshThrottler.clear(); - return super.close(); - } - - Future _onEvent( - ListSharingBlocEvent event, Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is ListSharingBlocQuery) { - await _onEventQuery(event, emit); - } else if (event is _ListSharingBlocShareRemoved) { - await _onEventShareRemoved(event, emit); - } else if (event is _ListSharingBlocPendingSharedAlbumMoved) { - await _onEventPendingSharedAlbumMoved(event, emit); - } - } - - Future _onEventQuery( - ListSharingBlocQuery ev, Emitter emit) async { - try { - emit(ListSharingBlocLoading(ev.account, state.items)); - emit(ListSharingBlocSuccess(ev.account, await _query(ev))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(ListSharingBlocFailure(ev.account, state.items, e)); - } - } - - Future _onEventShareRemoved(_ListSharingBlocShareRemoved ev, - Emitter emit) async { - if (state is! ListSharingBlocSuccess && state is! ListSharingBlocFailure) { - return; - } - final newItems = - state.items.where((i) => !ev.shares.contains(i.share)).toList(); - // i love hacks :) - emit((state as dynamic).copyWith( - items: newItems, - ) as ListSharingBlocState); - } - - Future _onEventPendingSharedAlbumMoved( - _ListSharingBlocPendingSharedAlbumMoved ev, - Emitter emit) async { - if (state.items.isEmpty) { - return; - } - try { - emit(ListSharingBlocLoading(ev.account, state.items)); - - final items = List.of(state.items); - items.removeWhere( - (i) => i is ListSharingAlbum && i.share.path == ev.file.strippedPath); - final newShares = - await ListShareWithMe(_c)(ev.account, File(path: ev.destination)); - final newAlbumFile = await LsSingleFile(_c)(ev.account, ev.destination); - final newAlbum = await _c.albumRepo.get(ev.account, newAlbumFile); - for (final s in newShares) { - items.add(ListSharingAlbum(s, newAlbum)); - } - - emit(ListSharingBlocSuccess(ev.account, items)); - } catch (e, stackTrace) { - _log.severe("[_onEventPendingSharedAlbumMoved] Exception while request", - e, stackTrace); - emit(ListSharingBlocFailure(ev.account, state.items, e)); - } - } - - void _onShareRemovedEvent(ShareRemovedEvent ev) { - if (_isAccountOfInterest(ev.account)) { - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - data: ev.share, - ); - } - } - - void _onFileMovedEvent(FileMovedEvent ev) { - if (state is ListSharingBlocInit) { - // no data in this bloc, ignore - return; - } - if (_isAccountOfInterest(ev.account)) { - if (ev.destination - .startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account)) && - ev.file.path.startsWith(remote_storage_util - .getRemotePendingSharedAlbumsDir(ev.account))) { - // moving from/to pending dir - add(_ListSharingBlocPendingSharedAlbumMoved( - ev.account, ev.file, ev.destination)); - } - } - } - - Future> _query(ListSharingBlocQuery ev) async { - final sharedAlbumFiles = await Ls(_c.fileRepo)( - ev.account, - File( - path: remote_storage_util.getRemoteAlbumsDir(ev.account), - )); - return (await Future.wait([ - _querySharesByMe(ev, sharedAlbumFiles), - _querySharesWithMe(ev, sharedAlbumFiles), - ])) - .reduce((value, element) => value + element); - } - - Future> _querySharesByMe( - ListSharingBlocQuery ev, List sharedAlbumFiles) async { - final shares = await _c.shareRepo.listAll(ev.account); - final futures = shares.map((s) async { - final webdavPath = file_util.unstripPath(ev.account, s.path); - // include link share dirs - if (s.itemType == ShareItemType.folder) { - if (webdavPath.startsWith( - remote_storage_util.getRemoteLinkSharesDir(ev.account))) { - return ListSharingFile( - s, - File( - path: webdavPath, - fileId: s.itemSource, - isCollection: true, - ), - ); - } - } - // include shared albums - if (path_lib.dirname(webdavPath) == - remote_storage_util.getRemoteAlbumsDir(ev.account)) { - try { - final file = sharedAlbumFiles - .firstWhere((element) => element.fileId == s.itemSource); - return await _querySharedAlbum(ev, s, file); - } catch (e, stackTrace) { - _log.severe( - "[_querySharesWithMe] Shared album not found: ${s.itemSource}", - e, - stackTrace); - return null; - } - } - - if (!file_util.isSupportedMime(s.mimeType)) { - return null; - } - // show only link shares - if (s.url == null) { - return null; - } - if (ev.account.roots - .every((r) => r.isNotEmpty && !s.path.startsWith("$r/"))) { - // ignore files not under root dirs - return null; - } - - try { - final file = (await FindFile(_c)(ev.account, [s.itemSource])).first; - return ListSharingFile(s, file); - } catch (e, stackTrace) { - _log.severe("[_querySharesByMe] File not found: ${s.itemSource}", e, - stackTrace); - return null; - } - }); - return (await Future.wait(futures)).whereType().toList(); - } - - Future> _querySharesWithMe( - ListSharingBlocQuery ev, List sharedAlbumFiles) async { - final pendingSharedAlbumFiles = await Ls(_c.fileRepo)( - ev.account, - File( - path: remote_storage_util.getRemotePendingSharedAlbumsDir(ev.account), - )); - - final shares = await _c.shareRepo.reverseListAll(ev.account); - final futures = shares.map((s) async { - final webdavPath = file_util.unstripPath(ev.account, s.path); - // include pending shared albums - if (path_lib.dirname(webdavPath) == - remote_storage_util.getRemotePendingSharedAlbumsDir(ev.account)) { - try { - final file = pendingSharedAlbumFiles - .firstWhere((element) => element.fileId == s.itemSource); - return await _querySharedAlbum(ev, s, file); - } catch (e, stackTrace) { - _log.severe( - "[_querySharesWithMe] Pending shared album not found: ${s.itemSource}", - e, - stackTrace); - return null; - } - } - // include shared albums - if (path_lib.dirname(webdavPath) == - remote_storage_util.getRemoteAlbumsDir(ev.account)) { - try { - final file = sharedAlbumFiles - .firstWhere((element) => element.fileId == s.itemSource); - return await _querySharedAlbum(ev, s, file); - } catch (e, stackTrace) { - _log.severe( - "[_querySharesWithMe] Shared album not found: ${s.itemSource}", - e, - stackTrace); - return null; - } - } - }); - return (await Future.wait(futures)).whereType().toList(); - } - - Future _querySharedAlbum( - ListSharingBlocQuery ev, Share share, File albumFile) async { - try { - final album = await _c.albumRepo.get(ev.account, albumFile); - return ListSharingAlbum(share, album); - } catch (e, stackTrace) { - _log.shout( - "[_querySharedAlbum] Failed while getting album", e, stackTrace); - return null; - } - } - - bool _isAccountOfInterest(Account account) => - state.account == null || state.account!.compareServerIdentity(account); - - final DiContainer _c; - - late final _shareRemovedListener = - AppEventListener(_onShareRemovedEvent); - late final _fileMovedEventListener = - AppEventListener(_onFileMovedEvent); - - late Throttler _refreshThrottler; - - static final _log = _$ListSharingBlocNpLog.log; -} diff --git a/app/lib/bloc/list_sharing.g.dart b/app/lib/bloc/list_sharing.g.dart deleted file mode 100644 index 60c5add4..00000000 --- a/app/lib/bloc/list_sharing.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'list_sharing.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$ListSharingBlocNpLog on ListSharingBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.list_sharing.ListSharingBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$ListSharingBlocQueryToString on ListSharingBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListSharingBlocQuery {account: $account}"; - } -} - -extension _$_ListSharingBlocShareRemovedToString - on _ListSharingBlocShareRemoved { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListSharingBlocShareRemoved {shares: ${shares.toReadableString()}}"; - } -} - -extension _$_ListSharingBlocPendingSharedAlbumMovedToString - on _ListSharingBlocPendingSharedAlbumMoved { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "_ListSharingBlocPendingSharedAlbumMoved {account: $account, file: ${file.path}, destination: $destination}"; - } -} - -extension _$ListSharingBlocStateToString on ListSharingBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "ListSharingBlocState")} {account: $account, items: [length: ${items.length}]}"; - } -} - -extension _$ListSharingBlocFailureToString on ListSharingBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "ListSharingBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}"; - } -} diff --git a/app/lib/controller/account_controller.dart b/app/lib/controller/account_controller.dart index 6707182b..6f37d5a2 100644 --- a/app/lib/controller/account_controller.dart +++ b/app/lib/controller/account_controller.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/controller/collections_controller.dart'; import 'package:nc_photos/controller/persons_controller.dart'; import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/controller/session_controller.dart'; +import 'package:nc_photos/controller/sharings_controller.dart'; import 'package:nc_photos/controller/sync_controller.dart'; import 'package:nc_photos/di_container.dart'; @@ -23,6 +24,8 @@ class AccountController { _syncController = null; _sessionController?.dispose(); _sessionController = null; + _sharingsController?.dispose(); + _sharingsController = null; } Account get account => _account!; @@ -58,6 +61,12 @@ class AccountController { SessionController get sessionController => _sessionController ??= SessionController(); + SharingsController get sharingsController => + _sharingsController ??= SharingsController( + KiwiContainer().resolve(), + account: _account!, + ); + Account? _account; CollectionsController? _collectionsController; ServerController? _serverController; @@ -65,4 +74,5 @@ class AccountController { PersonsController? _personsController; SyncController? _syncController; SessionController? _sessionController; + SharingsController? _sharingsController; } diff --git a/app/lib/controller/sharings_controller.dart b/app/lib/controller/sharings_controller.dart new file mode 100644 index 00000000..60fe9f0c --- /dev/null +++ b/app/lib/controller/sharings_controller.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:copy_with/copy_with.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.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/share.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/rx_extension.dart'; +import 'package:nc_photos/use_case/list_share_with_me.dart'; +import 'package:nc_photos/use_case/list_sharing.dart'; +import 'package:nc_photos/use_case/ls_single_file.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'sharings_controller.g.dart'; + +abstract class SharingStreamData { + static SharingStreamData _fromListSharingData(ListSharingData src) { + if (src is ListSharingFileData) { + return SharingStreamFileData(src.share, src.file); + } else if (src is ListSharingAlbumData) { + return SharingStreamAlbumData(src.share, src.album); + } else { + throw ArgumentError("Unsupported type: ${src.runtimeType}"); + } + } +} + +class SharingStreamShareData implements SharingStreamData { + const SharingStreamShareData(this.share); + + final Share share; +} + +class SharingStreamFileData extends SharingStreamShareData { + const SharingStreamFileData(super.share, this.file); + + final File file; +} + +class SharingStreamAlbumData extends SharingStreamShareData { + const SharingStreamAlbumData(super.share, this.album); + + final Album album; +} + +@genCopyWith +class SharingStreamEvent { + const SharingStreamEvent({ + required this.data, + required this.hasNext, + }); + + final List data; + + /// If true, the results are intermediate values and may not represent the + /// latest state + final bool hasNext; +} + +@npLog +class SharingsController { + SharingsController( + this._c, { + required this.account, + }); + + void dispose() { + _sharingStreamContorller.close(); + + _shareRemovedListener?.end(); + _fileMovedEventListener?.end(); + } + + /// Return a stream of curated shares associated with [account] + /// + /// There's no guarantee that the returned list is always sorted in some ways, + /// callers must sort it by themselves if the ordering is important + ValueStream get stream { + if (!_isSharingStreamInited) { + _isSharingStreamInited = true; + unawaited(_load(isReload: false)); + } + return _sharingStreamContorller.stream; + } + + /// In the future we need to get rid of the listeners and this reload function + /// and move all manipulations to this controller + Future reload() async { + if (_isSharingStreamInited) { + return _load(isReload: true); + } else { + _log.warning("[reload] Not inited, ignore"); + } + } + + Future _load({required bool isReload}) async { + var lastData = _sharingStreamContorller.value.copyWith(hasNext: true); + _sharingStreamContorller.add(lastData); + final completer = Completer(); + ListSharing(_c)(account).listen( + (c) { + lastData = SharingStreamEvent( + data: c.map(SharingStreamData._fromListSharingData).toList(), + hasNext: true, + ); + if (!isReload) { + _sharingStreamContorller.add(lastData); + } + }, + onError: _sharingStreamContorller.addError, + onDone: () => completer.complete(), + ); + await completer.future; + _sharingStreamContorller.add(lastData.copyWith(hasNext: false)); + + _shareRemovedListener = + AppEventListener(_onShareRemovedEvent)..begin(); + _fileMovedEventListener = + AppEventListener(_onFileMovedEvent)..begin(); + } + + void _onShareRemovedEvent(ShareRemovedEvent ev) { + if (!_isAccountOfInterest(ev.account)) { + return; + } + _sharingStreamContorller.addWithValue((value) => value.copyWith( + data: value.data.where((e) { + if (e is SharingStreamShareData) { + return e.share.id != ev.share.id; + } else { + return true; + } + }).toList(), + )); + } + + Future _onFileMovedEvent(FileMovedEvent ev) async { + if (!_isAccountOfInterest(ev.account)) { + return; + } + if (ev.destination + .startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account)) && + ev.file.path.startsWith( + remote_storage_util.getRemotePendingSharedAlbumsDir(ev.account))) { + // moving from pending dir to album dir + } else if (ev.destination.startsWith( + remote_storage_util.getRemotePendingSharedAlbumsDir(ev.account)) && + ev.file.path + .startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account))) { + // moving from album dir to pending dir + } else { + // unrelated file + return; + } + _log.info("[_onFileMovedEvent] ${ev.file.path} -> ${ev.destination}"); + final newShares = + await ListShareWithMe(_c)(ev.account, File(path: ev.destination)); + final newAlbumFile = await LsSingleFile(_c)(ev.account, ev.destination); + final newAlbum = await _c.albumRepo.get(ev.account, newAlbumFile); + if (_sharingStreamContorller.isClosed) { + return; + } + _sharingStreamContorller.addWithValue((value) => value.copyWith( + data: value.data + .whereNot((e) => + e is SharingStreamAlbumData && + e.share.path == ev.file.strippedPath) + .toList() + ..addAll(newShares.map((s) => SharingStreamAlbumData(s, newAlbum))), + )); + } + + bool _isAccountOfInterest(Account account) => + this.account.compareServerIdentity(account); + + final DiContainer _c; + final Account account; + + var _isSharingStreamInited = false; + final _sharingStreamContorller = BehaviorSubject.seeded( + const SharingStreamEvent(data: [], hasNext: true), + ); + + AppEventListener? _shareRemovedListener; + AppEventListener? _fileMovedEventListener; +} diff --git a/app/lib/controller/sharings_controller.g.dart b/app/lib/controller/sharings_controller.g.dart new file mode 100644 index 00000000..65f09db7 --- /dev/null +++ b/app/lib/controller/sharings_controller.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sharings_controller.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $SharingStreamEventCopyWithWorker { + SharingStreamEvent call({List? data, bool? hasNext}); +} + +class _$SharingStreamEventCopyWithWorkerImpl + implements $SharingStreamEventCopyWithWorker { + _$SharingStreamEventCopyWithWorkerImpl(this.that); + + @override + SharingStreamEvent call({dynamic data, dynamic hasNext}) { + return SharingStreamEvent( + data: data as List? ?? that.data, + hasNext: hasNext as bool? ?? that.hasNext); + } + + final SharingStreamEvent that; +} + +extension $SharingStreamEventCopyWith on SharingStreamEvent { + $SharingStreamEventCopyWithWorker get copyWith => _$copyWith; + $SharingStreamEventCopyWithWorker get _$copyWith => + _$SharingStreamEventCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$SharingsControllerNpLog on SharingsController { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("controller.sharings_controller.SharingsController"); +} diff --git a/app/lib/use_case/list_sharing.dart b/app/lib/use_case/list_sharing.dart new file mode 100644 index 00000000..fdc77c04 --- /dev/null +++ b/app/lib/use_case/list_sharing.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:collection/collection.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.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/share.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/find_file.dart'; +import 'package:nc_photos/use_case/ls.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:path/path.dart' as path_lib; + +part 'list_sharing.g.dart'; + +abstract class ListSharingData {} + +class ListSharingFileData implements ListSharingData { + const ListSharingFileData(this.share, this.file); + + final Share share; + final File file; +} + +class ListSharingAlbumData implements ListSharingData { + const ListSharingAlbumData(this.share, this.album); + + final Share share; + final Album album; +} + +@npLog +class ListSharing { + ListSharing(this._c); + + Stream> call(Account account) async* { + final sharedAlbumFiles = await Ls(_c.fileRepo)( + account, + File( + path: remote_storage_util.getRemoteAlbumsDir(account), + ), + ); + + final controller = StreamController>(); + var byMe = []; + var isByMeDone = false; + var withMe = []; + var isWithMeDone = false; + + void notify() { + controller.add([ + ...byMe, + ...withMe, + ]); + } + + void onDone() { + if (isByMeDone && isWithMeDone) { + controller.close(); + } + } + + unawaited(_querySharesByMe(account, sharedAlbumFiles).then((value) { + byMe = value; + notify(); + }).catchError((e, stackTrace) { + controller.addError(e, stackTrace); + }).whenComplete(() { + isByMeDone = true; + onDone(); + })); + unawaited(_querySharesWithMe(account, sharedAlbumFiles).then((value) { + withMe = value; + notify(); + }).catchError((e, stackTrace) { + controller.addError(e, stackTrace); + }).whenComplete(() { + isWithMeDone = true; + onDone(); + })); + yield* controller.stream; + } + + Future> _querySharesByMe( + Account account, List sharedAlbumFiles) async { + final shares = await _c.shareRepo.listAll(account); + final futures = shares.map((s) async { + final webdavPath = file_util.unstripPath(account, s.path); + // include link share dirs + if (s.itemType == ShareItemType.folder) { + if (webdavPath + .startsWith(remote_storage_util.getRemoteLinkSharesDir(account))) { + return ListSharingFileData( + s, + File( + path: webdavPath, + fileId: s.itemSource, + isCollection: true, + ), + ); + } + } + // include shared albums + if (path_lib.dirname(webdavPath) == + remote_storage_util.getRemoteAlbumsDir(account)) { + try { + final file = sharedAlbumFiles + .firstWhere((element) => element.fileId == s.itemSource); + return await _querySharedAlbum(account, s, file); + } catch (e, stackTrace) { + _log.severe( + "[_querySharesWithMe] Shared album not found: ${s.itemSource}", + e, + stackTrace); + return null; + } + } + + if (!file_util.isSupportedMime(s.mimeType)) { + return null; + } + // show only link shares + if (s.url == null) { + return null; + } + if (account.roots + .every((r) => r.isNotEmpty && !s.path.startsWith("$r/"))) { + // ignore files not under root dirs + return null; + } + + try { + final file = (await FindFile(_c)(account, [s.itemSource])).first; + return ListSharingFileData(s, file); + } catch (e, stackTrace) { + _log.severe("[_querySharesByMe] File not found: ${s.itemSource}", e, + stackTrace); + return null; + } + }); + return (await Future.wait(futures)).whereNotNull().toList(); + } + + Future> _querySharesWithMe( + Account account, List sharedAlbumFiles) async { + final pendingSharedAlbumFiles = await Ls(_c.fileRepo)( + account, + File( + path: remote_storage_util.getRemotePendingSharedAlbumsDir(account), + ), + ); + + final shares = await _c.shareRepo.reverseListAll(account); + final futures = shares.map((s) async { + final webdavPath = file_util.unstripPath(account, s.path); + // include pending shared albums + if (path_lib.dirname(webdavPath) == + remote_storage_util.getRemotePendingSharedAlbumsDir(account)) { + try { + final file = pendingSharedAlbumFiles + .firstWhere((element) => element.fileId == s.itemSource); + return await _querySharedAlbum(account, s, file); + } catch (e, stackTrace) { + _log.severe( + "[_querySharesWithMe] Pending shared album not found: ${s.itemSource}", + e, + stackTrace); + return null; + } + } + // include shared albums + if (path_lib.dirname(webdavPath) == + remote_storage_util.getRemoteAlbumsDir(account)) { + try { + final file = sharedAlbumFiles + .firstWhere((element) => element.fileId == s.itemSource); + return await _querySharedAlbum(account, s, file); + } catch (e, stackTrace) { + _log.severe( + "[_querySharesWithMe] Shared album not found: ${s.itemSource}", + e, + stackTrace); + return null; + } + } + }); + return (await Future.wait(futures)).whereNotNull().toList(); + } + + Future _querySharedAlbum( + Account account, Share share, File albumFile) async { + try { + final album = await _c.albumRepo.get(account, albumFile); + return ListSharingAlbumData(share, album); + } catch (e, stackTrace) { + _log.shout( + "[_querySharedAlbum] Failed while getting album", e, stackTrace); + return null; + } + } + + final DiContainer _c; +} diff --git a/app/lib/use_case/list_sharing.g.dart b/app/lib/use_case/list_sharing.g.dart new file mode 100644 index 00000000..1db56a26 --- /dev/null +++ b/app/lib/use_case/list_sharing.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'list_sharing.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$ListSharingNpLog on ListSharing { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("use_case.list_sharing.ListSharing"); +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 0872c419..1f027068 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -191,6 +191,7 @@ class _WrappedAppState extends State<_WrappedApp> PeopleBrowser.routeName: PeopleBrowser.buildRoute, EnhancementSettings.routeName: EnhancementSettings.buildRoute, Settings.routeName: Settings.buildRoute, + SharingBrowser.routeName: SharingBrowser.buildRoute, }; Route? _onGenerateRoute(RouteSettings settings) { @@ -208,7 +209,6 @@ class _WrappedAppState extends State<_WrappedApp> route ??= _handleTrashbinBrowserRoute(settings); route ??= _handleTrashbinViewerRoute(settings); route ??= _handleSlideshowViewerRoute(settings); - route ??= _handleSharingBrowserRoute(settings); route ??= _handleSharedFileViewerRoute(settings); route ??= _handleAlbumShareOutlierBrowserRoute(settings); route ??= _handleShareFolderPickerRoute(settings); @@ -379,20 +379,6 @@ class _WrappedAppState extends State<_WrappedApp> return null; } - Route? _handleSharingBrowserRoute(RouteSettings settings) { - try { - if (settings.name == SharingBrowser.routeName && - settings.arguments != null) { - final args = settings.arguments as SharingBrowserArguments; - return SharingBrowser.buildRoute(args); - } - } catch (e) { - _log.severe( - "[_handleSharingBrowserRoute] Failed while handling route", e); - } - return null; - } - Route? _handleSharedFileViewerRoute(RouteSettings settings) { try { if (settings.name == SharedFileViewer.routeName && diff --git a/app/lib/widget/sharing_browser.dart b/app/lib/widget/sharing_browser.dart index 66f4f739..51ba6607 100644 --- a/app/lib/widget/sharing_browser.dart +++ b/app/lib/widget/sharing_browser.dart @@ -1,4 +1,7 @@ +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:intl/intl.dart'; @@ -6,7 +9,9 @@ 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/bloc/list_sharing.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/account_pref_controller.dart'; +import 'package:nc_photos/controller/sharings_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/data_source.dart'; @@ -15,6 +20,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/entity/share.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/object_extension.dart'; @@ -23,12 +29,22 @@ import 'package:nc_photos/use_case/import_potential_shared_album.dart'; import 'package:nc_photos/widget/collection_browser.dart'; import 'package:nc_photos/widget/empty_list_indicator.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; +import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:nc_photos/widget/shared_file_viewer.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; import 'package:np_common/or_null.dart'; import 'package:np_ui/np_ui.dart'; +import 'package:to_string/to_string.dart'; part 'sharing_browser.g.dart'; +part 'sharing_browser/bloc.dart'; +part 'sharing_browser/state_event.dart'; +part 'sharing_browser/type.dart'; + +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +typedef _BlocListener = BlocListener<_Bloc, _State>; +// typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; class SharingBrowserArguments { SharingBrowserArguments(this.account); @@ -37,39 +53,44 @@ class SharingBrowserArguments { } /// Show a list of all shares associated with this account -class SharingBrowser extends StatefulWidget { +class SharingBrowser extends StatelessWidget { static const routeName = "/sharing-browser"; - static Route buildRoute(SharingBrowserArguments args) => MaterialPageRoute( - builder: (context) => SharingBrowser.fromArgs(args), + static Route buildRoute() => MaterialPageRoute( + builder: (_) => const SharingBrowser(), ); - const SharingBrowser({ - Key? key, - required this.account, - }) : super(key: key); - - SharingBrowser.fromArgs(SharingBrowserArguments args, {Key? key}) - : this( - key: key, - account: args.account, - ); + const SharingBrowser({super.key}); @override - createState() => _SharingBrowserState(); + Widget build(BuildContext context) { + final accountController = context.read(); + return BlocProvider( + create: (_) => _Bloc( + account: accountController.account, + accountPrefController: accountController.accountPrefController, + sharingsController: accountController.sharingsController, + ), + child: const _WrappedSharingBrowser(), + ); + } +} - final Account account; +class _WrappedSharingBrowser extends StatefulWidget { + const _WrappedSharingBrowser(); + + @override + State createState() => _WrappedSharingBrowserState(); } @npLog -class _SharingBrowserState extends State { +class _WrappedSharingBrowserState extends State<_WrappedSharingBrowser> + with RouteAware, PageVisibilityMixin { @override initState() { super.initState(); - _importPotentialSharedAlbum().whenComplete(() { - _initBloc(); - }); - AccountPref.of(widget.account).run((obj) { + _bloc.add(const _Init()); + AccountPref.of(_bloc.account).run((obj) { if (obj.hasNewSharedAlbumOr()) { obj.setNewSharedAlbum(false); } @@ -77,67 +98,82 @@ class _SharingBrowserState extends State { } @override - build(BuildContext context) { - return Scaffold( - body: BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + _BlocListener( + listenWhen: (previous, current) => previous.items != current.items, + listener: (context, state) { + _bloc.add(_TransformItems(state.items)); + }, + ), + _BlocListener( + listenWhen: (previous, current) => previous.error != current.error, + listener: (context, state) { + if (state.error != null && isPageVisible()) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: Scaffold( + body: _BlocBuilder( + buildWhen: (previous, current) => + previous.items.isEmpty != current.items.isEmpty || + previous.isLoading != current.isLoading, + builder: (context, state) { + if (state.items.isEmpty && !state.isLoading) { + return const _EmptyContentList(); + } else { + return Stack( + children: [ + CustomScrollView( + slivers: [ + const _AppBar(), + SliverToBoxAdapter( + child: _BlocBuilder( + buildWhen: (previous, current) => + previous.isLoading != current.isLoading, + builder: (context, state) => state.isLoading + ? const LinearProgressIndicator() + : const SizedBox(height: 4), + ), + ), + const _ContentList(), + ], + ), + ], + ); + } + }, ), ), ); } - void _initBloc() { - if (_bloc.state is ListSharingBlocInit) { - _log.info("[_initBloc] Initialize bloc"); - } else { - // process the current state - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _onStateChange(context, _bloc.state); - }); - } - }); - } - _reqQuery(); - } + late final _bloc = context.read<_Bloc>(); +} - Widget _buildContent(BuildContext context, ListSharingBlocState state) { - if ((state is ListSharingBlocSuccess || state is ListSharingBlocFailure) && - state.items.isEmpty) { - return _buildEmptyContent(context); - } else { - return Stack( - children: [ - CustomScrollView( - slivers: [ - SliverAppBar( - title: Text(L10n.global().collectionSharingLabel), - floating: true, - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => _buildItem(context, _items[index]), - childCount: _items.length, - ), - ), - ], - ), - if (state is ListSharingBlocLoading) - const Align( - alignment: Alignment.bottomCenter, - child: LinearProgressIndicator(), - ), - ], - ); - } - } +class _AppBar extends StatelessWidget { + const _AppBar(); - Widget _buildEmptyContent(BuildContext context) { + @override + Widget build(BuildContext context) { + return SliverAppBar( + title: Text(L10n.global().collectionSharingLabel), + floating: true, + ); + } +} + +class _EmptyContentList extends StatelessWidget { + const _EmptyContentList(); + + @override + Widget build(BuildContext context) { return Column( children: [ AppBar( @@ -153,115 +189,63 @@ class _SharingBrowserState extends State { ], ); } +} - Widget _buildFileItem(BuildContext context, List shares) { - assert(shares.first is ListSharingFile); - final item = shares.first as ListSharingFile; - return _FileTile( - account: widget.account, - item: item, - isLinkShare: shares.any((e) => e.share.url?.isNotEmpty == true), - onTap: () { - Navigator.of(context).pushNamed(SharedFileViewer.routeName, - arguments: SharedFileViewerArguments( - widget.account, - item.file, - shares.map((e) => e.share).toList(), - )); - }, - ); - } +class _ContentList extends StatelessWidget { + const _ContentList(); - Widget _buildAlbumItem(BuildContext context, List shares) { - assert(shares.first is ListSharingAlbum); - final item = shares.first as ListSharingAlbum; - return _AlbumTile( - account: widget.account, - item: item, - onTap: () => _onAlbumShareItemTap(context, item), - ); - } - - Widget _buildItem(BuildContext context, List shares) { - if (shares.first is ListSharingFile) { - return _buildFileItem(context, shares); - } else if (shares.first is ListSharingAlbum) { - return _buildAlbumItem(context, shares); - } else { - throw StateError("Unknown item type: ${shares.first.runtimeType}"); - } - } - - void _onStateChange(BuildContext context, ListSharingBlocState state) { - if (state is ListSharingBlocInit) { - _items = []; - } else if (state is ListSharingBlocSuccess || - state is ListSharingBlocLoading) { - _transformItems(state.items); - } else if (state is ListSharingBlocFailure) { - _transformItems(state.items); - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onAlbumShareItemTap(BuildContext context, ListSharingAlbum share) { - Navigator.of(context).pushNamed( - CollectionBrowser.routeName, - arguments: CollectionBrowserArguments( - CollectionBuilder.byAlbum(widget.account, share.album), + @override + Widget build(BuildContext context) { + return _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedItems != current.transformedItems, + builder: (_, state) => SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + _buildItem(context, state.transformedItems[index]), + childCount: state.transformedItems.length, + ), ), ); } - void _transformItems(List items) { - // group shares of the same file - final map = >{}; - for (final i in items) { - final isSharedByMe = (i.share.uidOwner == widget.account.userId); - final groupKey = "${i.share.path}?$isSharedByMe"; - map[groupKey] ??= []; - map[groupKey]!.add(i); + Widget _buildItem(BuildContext context, _Item data) { + if (data is _FileShareItem) { + return _buildFileItem(context, data); + } else if (data is _AlbumShareItem) { + return _buildAlbumItem(context, data); + } else { + throw ArgumentError("Unknown item type: ${data.runtimeType}"); } - // sort the sub-lists - for (final list in map.values) { - list.sort((a, b) => b.share.stime.compareTo(a.share.stime)); - } - // then sort the map and convert it to list - _items = map.entries - .sorted((a, b) => - b.value.first.share.stime.compareTo(a.value.first.share.stime)) - .map((e) => e.value) - .toList(); } - void _reqQuery() { - _bloc.add(ListSharingBlocQuery(widget.account)); + Widget _buildFileItem(BuildContext context, _FileShareItem item) { + return _FileTile( + account: item.account, + item: item, + isLinkShare: item.shares.any((e) => e.url?.isNotEmpty == true), + onTap: () { + Navigator.of(context).pushNamed(SharedFileViewer.routeName, + arguments: SharedFileViewerArguments( + item.account, item.file, item.shares)); + }, + ); } - Future> _importPotentialSharedAlbum() async { - final c = KiwiContainer().resolve().copyWith( - // don't want the potential albums to be cached at this moment - fileRepo: const OrNull(FileRepo(FileWebdavDataSource())), - albumRepo: OrNull(AlbumRepo(AlbumRemoteDataSource())), + Widget _buildAlbumItem(BuildContext context, _AlbumShareItem item) { + return _AlbumTile( + account: item.account, + item: item, + onTap: () { + Navigator.of(context).pushNamed( + CollectionBrowser.routeName, + arguments: CollectionBrowserArguments( + CollectionBuilder.byAlbum(item.account, item.album), + ), ); - try { - return await ImportPotentialSharedAlbum(c)( - widget.account, AccountPref.of(widget.account)); - } catch (e, stackTrace) { - _log.shout( - "[_importPotentialSharedAlbum] Failed while ImportPotentialSharedAlbum", - e, - stackTrace); - return []; - } + }, + ); } - - late final _bloc = ListSharingBloc.of(widget.account); - - var _items = >[]; } class _ListTile extends StatelessWidget { @@ -274,7 +258,7 @@ class _ListTile extends StatelessWidget { }); @override - build(BuildContext context) { + Widget build(BuildContext context) { return UnboundedListTile( leading: leading, title: Text( @@ -305,9 +289,9 @@ class _FileTile extends StatelessWidget { @override Widget build(BuildContext context) { - final dateStr = _getDateFormat(context).format(item.share.stime.toLocal()); + final dateStr = _getDateFormat(context).format(item.sharedTime!.toLocal()); return _ListTile( - leading: item.share.itemType == ShareItemType.folder + leading: item.shares.first.itemType == ShareItemType.folder ? const SizedBox( height: _leadingSize, width: _leadingSize, @@ -320,18 +304,18 @@ class _FileTile extends StatelessWidget { dimension: _leadingSize, errorBuilder: (_) => const Icon(Icons.folder, size: 32), ), - label: item.share.filename, - description: item.share.uidOwner == account.userId + label: item.name, + description: item.sharedBy == null ? L10n.global().fileLastSharedDescription(dateStr) - : L10n.global().fileLastSharedByOthersDescription( - item.share.displaynameOwner, dateStr), + : L10n.global() + .fileLastSharedByOthersDescription(item.sharedBy!, dateStr), trailing: isLinkShare ? const Icon(Icons.link) : null, onTap: onTap, ); } final Account account; - final ListSharingFile item; + final _FileShareItem item; final bool isLinkShare; final VoidCallback? onTap; } @@ -345,7 +329,7 @@ class _AlbumTile extends StatelessWidget { @override Widget build(BuildContext context) { - final dateStr = _getDateFormat(context).format(item.share.stime.toLocal()); + final dateStr = _getDateFormat(context).format(item.sharedTime!.toLocal()); final cover = item.album.coverProvider.getCover(item.album); return _ListTile( leading: cover == null @@ -361,17 +345,17 @@ class _AlbumTile extends StatelessWidget { errorBuilder: (_) => const Icon(Icons.photo_album, size: 32), ), label: item.album.name, - description: item.share.uidOwner == account.userId + description: item.sharedBy == null ? L10n.global().fileLastSharedDescription(dateStr) - : L10n.global().albumLastSharedByOthersDescription( - item.share.displaynameOwner, dateStr), + : L10n.global() + .albumLastSharedByOthersDescription(item.sharedBy!, dateStr), trailing: const Icon(Icons.photo_album_outlined), onTap: onTap, ); } final Account account; - final ListSharingAlbum item; + final _AlbumShareItem item; final VoidCallback? onTap; } diff --git a/app/lib/widget/sharing_browser.g.dart b/app/lib/widget/sharing_browser.g.dart index 371bd9ea..8022e677 100644 --- a/app/lib/widget/sharing_browser.g.dart +++ b/app/lib/widget/sharing_browser.g.dart @@ -2,13 +2,105 @@ part of 'sharing_browser.dart'; +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? items, + bool? isLoading, + List<_Item>? transformedItems, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic items, + dynamic isLoading, + dynamic transformedItems, + dynamic error = copyWithNull}) { + return _State( + items: items as List? ?? that.items, + isLoading: isLoading as bool? ?? that.isLoading, + transformedItems: + transformedItems as List<_Item>? ?? that.transformedItems, + 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 _$_SharingBrowserStateNpLog on _SharingBrowserState { +extension _$_WrappedSharingBrowserStateNpLog on _WrappedSharingBrowserState { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.sharing_browser._SharingBrowserState"); + static final log = + Logger("widget.sharing_browser._WrappedSharingBrowserState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.sharing_browser._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {items: [length: ${items.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], error: $error}"; + } +} + +extension _$_InitToString on _Init { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Init {}"; + } +} + +extension _$_TransformItemsToString on _TransformItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformItems {items: [length: ${items.length}]}"; + } +} + +extension _$_ListSharingBlocShareRemovedToString + on _ListSharingBlocShareRemoved { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ListSharingBlocShareRemoved {shares: ${shares.toReadableString()}}"; + } +} + +extension _$_ListSharingBlocPendingSharedAlbumMovedToString + on _ListSharingBlocPendingSharedAlbumMoved { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_ListSharingBlocPendingSharedAlbumMoved {account: $account, file: ${file.path}, destination: $destination}"; + } } diff --git a/app/lib/widget/sharing_browser/bloc.dart b/app/lib/widget/sharing_browser/bloc.dart new file mode 100644 index 00000000..65272e23 --- /dev/null +++ b/app/lib/widget/sharing_browser/bloc.dart @@ -0,0 +1,86 @@ +part of '../sharing_browser.dart'; + +/// List shares to be shown in [SharingBrowser] +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required this.account, + required this.accountPrefController, + required this.sharingsController, + }) : super(_State.init()) { + on<_Init>(_onInit); + on<_TransformItems>(_onTransformItems); + } + + Future _onInit(_Init ev, Emitter<_State> emit) async { + _log.info(ev); + try { + await _importPotentialSharedAlbum(); + } catch (e, stackTrace) { + _log.severe( + "[_onInit] Failed while _importPotentialSharedAlbum", e, stackTrace); + } + unawaited(sharingsController.reload()); + return emit.forEach( + sharingsController.stream, + onData: (data) => state.copyWith( + items: data.data, + isLoading: data.hasNext, + ), + onError: (e, stackTrace) { + _log.severe("[_onInit] Uncaught exception", e, stackTrace); + return state.copyWith( + isLoading: false, + error: ExceptionEvent(e, stackTrace), + ); + }, + ); + } + + Future _onTransformItems( + _TransformItems ev, Emitter<_State> emit) async { + _log.info(ev); + // group shares of the same file + final map = >{}; + for (final i in ev.items) { + if (i is SharingStreamShareData) { + final isSharedByMe = (i.share.uidOwner == account.userId); + final groupKey = "${i.share.path}?$isSharedByMe"; + map[groupKey] ??= []; + map[groupKey]!.add(i); + } + } + final results = <_Item>[]; + // sort and convert the sub-lists + for (final list in map.values) { + results.add(_Item.fromSharingStreamData( + account, list.sortedBy((e) => e.share.stime).reversed.toList())); + } + // then sort the list itself + emit(state.copyWith( + transformedItems: results.sortedBy((e) => e.sortTime).reversed.toList(), + )); + } + + Future> _importPotentialSharedAlbum() async { + final c = KiwiContainer().resolve().copyWith( + // don't want the potential albums to be cached at this moment + fileRepo: const OrNull(FileRepo(FileWebdavDataSource())), + albumRepo: OrNull(AlbumRepo(AlbumRemoteDataSource())), + ); + try { + return await ImportPotentialSharedAlbum(c)( + account, accountPrefController.raw); + } catch (e, stackTrace) { + _log.shout( + "[_importPotentialSharedAlbum] Failed while ImportPotentialSharedAlbum", + e, + stackTrace); + return []; + } + } + + final Account account; + final AccountPrefController accountPrefController; + final SharingsController sharingsController; +} diff --git a/app/lib/widget/sharing_browser/state_event.dart b/app/lib/widget/sharing_browser/state_event.dart new file mode 100644 index 00000000..6bd99c5b --- /dev/null +++ b/app/lib/widget/sharing_browser/state_event.dart @@ -0,0 +1,71 @@ +part of '../sharing_browser.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.items, + required this.isLoading, + required this.transformedItems, + this.error, + }); + + factory _State.init() => const _State( + items: [], + isLoading: true, + transformedItems: [], + ); + + @override + String toString() => _$toString(); + + final List items; + final bool isLoading; + final List<_Item> transformedItems; + + final ExceptionEvent? error; +} + +abstract class _Event {} + +@toString +class _Init implements _Event { + const _Init(); + + @override + String toString() => _$toString(); +} + +@toString +class _TransformItems implements _Event { + const _TransformItems(this.items); + + @override + String toString() => _$toString(); + + final List items; +} + +@toString +class _ListSharingBlocShareRemoved implements _Event { + const _ListSharingBlocShareRemoved(this.shares); + + @override + String toString() => _$toString(); + + @Format(r"${$?.toReadableString()}") + final List shares; +} + +@toString +class _ListSharingBlocPendingSharedAlbumMoved implements _Event { + const _ListSharingBlocPendingSharedAlbumMoved( + this.account, this.file, this.destination); + + @override + String toString() => _$toString(); + + final Account account; + final File file; + final String destination; +} diff --git a/app/lib/widget/sharing_browser/type.dart b/app/lib/widget/sharing_browser/type.dart new file mode 100644 index 00000000..b4bcc9d2 --- /dev/null +++ b/app/lib/widget/sharing_browser/type.dart @@ -0,0 +1,81 @@ +part of '../sharing_browser.dart'; + +abstract class _Item { + static _Item fromSharingStreamData( + Account account, List src) { + if (src.first is SharingStreamFileData) { + final casted = src.cast(); + return _FileShareItem( + account: account, + file: casted.first.file, + shares: casted.map((e) => e.share).toList(), + ); + } else if (src.first is SharingStreamAlbumData) { + final casted = src.cast(); + return _AlbumShareItem( + account: account, + album: casted.first.album, + shares: casted.map((e) => e.share).toList(), + ); + } else { + throw ArgumentError("Unknown type: ${src.runtimeType}"); + } + } + + String get name; + String? get sharedBy; + DateTime? get sharedTime; + DateTime get sortTime; +} + +class _FileShareItem implements _Item { + const _FileShareItem({ + required this.account, + required this.shares, + required this.file, + }); + + @override + String get name => shares.first.filename; + + @override + String? get sharedBy => shares.first.uidOwner == account.userId + ? null + : shares.first.displaynameOwner; + + @override + DateTime? get sharedTime => shares.first.stime; + + @override + DateTime get sortTime => shares.first.stime; + + final Account account; + final List shares; + final File file; +} + +class _AlbumShareItem implements _Item { + const _AlbumShareItem({ + required this.account, + required this.shares, + required this.album, + }); + + @override + String get name => album.name; + + @override + String? get sharedBy => shares.first.uidOwner == account.userId + ? null + : shares.first.displaynameOwner; + + @override + DateTime? get sharedTime => shares.first.stime; + + @override + DateTime get sortTime => shares.first.stime; + + final Account account; + final List shares; + final Album album; +}