From c0f65745f77d0479eafb395eb7a0ea24644c217d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 21 Aug 2021 01:02:13 +0800 Subject: [PATCH] List pending shard albums --- lib/bloc/list_pending_shared_album.dart | 183 +++++++++++++++++ lib/use_case/list_pending_shared_album.dart | 59 ++++++ lib/widget/album_browser.dart | 2 +- lib/widget/dynamic_album_browser.dart | 16 +- lib/widget/home_albums.dart | 31 ++- lib/widget/my_app.dart | 15 ++ lib/widget/pending_albums.dart | 209 ++++++++++++++++++++ 7 files changed, 505 insertions(+), 10 deletions(-) create mode 100644 lib/bloc/list_pending_shared_album.dart create mode 100644 lib/use_case/list_pending_shared_album.dart create mode 100644 lib/widget/pending_albums.dart diff --git a/lib/bloc/list_pending_shared_album.dart b/lib/bloc/list_pending_shared_album.dart new file mode 100644 index 00000000..76db2787 --- /dev/null +++ b/lib/bloc/list_pending_shared_album.dart @@ -0,0 +1,183 @@ +import 'package:bloc/bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/list_pending_shared_album.dart'; +import 'package:tuple/tuple.dart'; + +class ListPendingSharedAlbumBlocItem { + ListPendingSharedAlbumBlocItem(this.album); + + final Album album; +} + +abstract class ListPendingSharedAlbumBlocEvent { + const ListPendingSharedAlbumBlocEvent(); +} + +class ListPendingSharedAlbumBlocQuery + extends ListPendingSharedAlbumBlocEvent { + const ListPendingSharedAlbumBlocQuery( + this.account, + ); + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "}"; + } + + final Account account; +} + +/// An external event has happened and may affect the state of this bloc +class _ListPendingSharedAlbumBlocExternalEvent + extends ListPendingSharedAlbumBlocEvent { + const _ListPendingSharedAlbumBlocExternalEvent(); + + @override + toString() { + return "$runtimeType {" + "}"; + } +} + +abstract class ListPendingSharedAlbumBlocState { + const ListPendingSharedAlbumBlocState(this.items); + + @override + toString() { + return "$runtimeType {" + "items: List {length: ${items.length}}, " + "}"; + } + + final List items; +} + +class ListPendingSharedAlbumBlocInit + extends ListPendingSharedAlbumBlocState { + ListPendingSharedAlbumBlocInit() : super(const []); +} + +class ListPendingSharedAlbumBlocLoading + extends ListPendingSharedAlbumBlocState { + const ListPendingSharedAlbumBlocLoading( + List items) + : super(items); +} + +class ListPendingSharedAlbumBlocSuccess + extends ListPendingSharedAlbumBlocState { + const ListPendingSharedAlbumBlocSuccess( + List items) + : super(items); +} + +class ListPendingSharedAlbumBlocFailure + extends ListPendingSharedAlbumBlocState { + const ListPendingSharedAlbumBlocFailure( + List items, this.exception) + : super(items); + + @override + toString() { + return "$runtimeType {" + "super: ${super.toString()}, " + "exception: $exception, " + "}"; + } + + final dynamic exception; +} + +/// The state of this bloc is inconsistent. This typically means that the data +/// may have been changed externally +class ListPendingSharedAlbumBlocInconsistent + extends ListPendingSharedAlbumBlocState { + const ListPendingSharedAlbumBlocInconsistent( + List items) + : super(items); +} + +/// Return a list of importable shared albums in the pending dir +class ListPendingSharedAlbumBloc extends Bloc< + ListPendingSharedAlbumBlocEvent, ListPendingSharedAlbumBlocState> { + ListPendingSharedAlbumBloc() : super(ListPendingSharedAlbumBlocInit()) { + _fileMovedEventListener.begin(); + } + + @override + mapEventToState(ListPendingSharedAlbumBlocEvent event) async* { + _log.info("[mapEventToState] $event"); + if (event is ListPendingSharedAlbumBlocQuery) { + yield* _onEventQuery(event); + } else if (event is _ListPendingSharedAlbumBlocExternalEvent) { + yield* _onExternalEvent(event); + } + } + + @override + close() { + _fileMovedEventListener.end(); + return super.close(); + } + + Stream _onEventQuery( + ListPendingSharedAlbumBlocQuery ev) async* { + yield ListPendingSharedAlbumBlocLoading([]); + try { + final fileRepo = FileRepo(FileCachedDataSource()); + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + final albums = []; + final errors = []; + await for (final result + in ListPendingSharedAlbum(fileRepo, albumRepo)(ev.account)) { + if (result is Tuple2) { + _log.severe("[_onEventQuery] Exception while ListPendingSharedAlbum", + result.item1, result.item2); + errors.add(result.item1); + } else if (result is Album) { + albums.add(result); + } + } + final items = + albums.map((e) => ListPendingSharedAlbumBlocItem(e)).toList(); + if (errors.isEmpty) { + yield ListPendingSharedAlbumBlocSuccess(items); + } else { + yield ListPendingSharedAlbumBlocFailure(items, errors.first); + } + } catch (e) { + _log.severe("[_onEventQuery] Exception", e); + yield ListPendingSharedAlbumBlocFailure(state.items, e); + } + } + + Stream _onExternalEvent( + _ListPendingSharedAlbumBlocExternalEvent ev) async* { + yield ListPendingSharedAlbumBlocInconsistent(state.items); + } + + void _onFileMovedEvent(FileMovedEvent ev) { + if (state is ListPendingSharedAlbumBlocInit) { + // no data in this bloc, ignore + return; + } + if (ev.file.path.startsWith( + remote_storage_util.getRemotePendingSharedAlbumsDir(ev.account))) { + add(_ListPendingSharedAlbumBlocExternalEvent()); + } + } + + late final _fileMovedEventListener = + AppEventListener(_onFileMovedEvent); + + static final _log = + Logger("bloc.list_pending_shared_album.ListPendingSharedAlbumBloc"); +} diff --git a/lib/use_case/list_pending_shared_album.dart b/lib/use_case/list_pending_shared_album.dart new file mode 100644 index 00000000..187eb8db --- /dev/null +++ b/lib/use_case/list_pending_shared_album.dart @@ -0,0 +1,59 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/ls.dart'; +import 'package:tuple/tuple.dart'; + +class ListPendingSharedAlbum { + ListPendingSharedAlbum(this.fileRepo, this.albumRepo); + + /// Return shared albums that are known to us (in pending dir) but not added + /// to the user library + /// + /// The returned stream would emit either Album data or a tuple of exception + /// and stacktrace + Stream call(Account account) async* { + List ls; + try { + ls = await Ls(fileRepo)( + account, + File( + path: remote_storage_util.getRemotePendingSharedAlbumsDir(account), + )); + } catch (e, stacktrace) { + if (e is ApiException && e.response.statusCode == 404) { + // no albums + return; + } + yield Tuple2(e, stacktrace); + return; + } + final albumFiles = + ls.where((element) => element.isCollection != true).toList(); + for (final f in albumFiles) { + try { + yield await albumRepo.get(account, f); + } catch (e, stacktrace) { + yield Tuple2(e, stacktrace); + } + } + try { + albumRepo.cleanUp( + account, + remote_storage_util.getRemotePendingSharedAlbumsDir(account), + albumFiles); + } catch (e, stacktrace) { + // not important, log and ignore + _log.shout("[_call] Failed while cleanUp", e, stacktrace); + } + } + + final FileRepo fileRepo; + final AlbumRepo albumRepo; + + static final _log = + Logger("user_case.list_pending_shared_album.ListPendingSharedAlbum"); +} diff --git a/lib/widget/album_browser.dart b/lib/widget/album_browser.dart index 22bb2e5c..e00e93c5 100644 --- a/lib/widget/album_browser.dart +++ b/lib/widget/album_browser.dart @@ -109,7 +109,7 @@ class _AlbumBrowserState extends State } @protected - get canEdit => _album != null; + get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; @override enterEditMode() { diff --git a/lib/widget/dynamic_album_browser.dart b/lib/widget/dynamic_album_browser.dart index e04952e9..b430b0e5 100644 --- a/lib/widget/dynamic_album_browser.dart +++ b/lib/widget/dynamic_album_browser.dart @@ -110,7 +110,7 @@ class _DynamicAlbumBrowserState extends State } @protected - get canEdit => _album != null; + get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true; @override enterEditMode() { @@ -261,12 +261,14 @@ class _DynamicAlbumBrowserState extends State context, widget.account, _album!, - menuItemBuilder: (context) => [ - PopupMenuItem( - value: _menuValueConvertBasic, - child: Text(L10n.of(context).convertBasicAlbumMenuLabel), - ), - ], + menuItemBuilder: canEdit + ? (context) => [ + PopupMenuItem( + value: _menuValueConvertBasic, + child: Text(L10n.of(context).convertBasicAlbumMenuLabel), + ), + ] + : null, onSelectedMenuItem: (option) { switch (option) { case _menuValueConvertBasic: diff --git a/lib/widget/home_albums.dart b/lib/widget/home_albums.dart index c66e461d..640caf0d 100644 --- a/lib/widget/home_albums.dart +++ b/lib/widget/home_albums.dart @@ -15,6 +15,8 @@ import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/lab.dart'; +import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/remove.dart'; @@ -27,6 +29,7 @@ import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/home_app_bar.dart'; import 'package:nc_photos/widget/new_album_dialog.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; +import 'package:nc_photos/widget/pending_albums.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/trashbin_browser.dart'; import 'package:tuple/tuple.dart'; @@ -192,7 +195,9 @@ class _HomeAlbumsState extends State return _buildArchiveItem(context); } else if (index == 1) { return _buildTrashbinItem(context); - } else if (index == 2) { + } else if (index == 2 && Lab().enableSharedAlbum) { + return _buildShareItem(context); + } else if (index == 2 + (Lab().enableSharedAlbum ? 1 : 0)) { return _buildNewAlbumItem(context); } else if (index == _extraGridItemCount) { return Container(); @@ -239,6 +244,20 @@ class _HomeAlbumsState extends State ); } + Widget _buildShareItem(BuildContext context) { + return _NonAlbumGridItem( + icon: Icons.share_outlined, + label: "Sharing", + isShowIndicator: Pref.inst().hasNewSharedAlbumOr(false), + onTap: _isSelectionMode + ? null + : () { + Navigator.of(context).pushNamed(PendingAlbums.routeName, + arguments: PendingAlbumsArguments(widget.account)); + }, + ); + } + Widget _buildNewAlbumItem(BuildContext context) { return _NonAlbumGridItem( icon: Icons.add, @@ -437,7 +456,7 @@ class _HomeAlbumsState extends State static final _log = Logger("widget.home_albums._HomeAlbumsState"); static const _menuValueImport = 0; - static const _extraGridItemCount = 3; + static final _extraGridItemCount = 3 + (Lab().enableSharedAlbum ? 1 : 0); } class _GridItem { @@ -454,6 +473,7 @@ class _NonAlbumGridItem extends StatelessWidget { required this.icon, required this.label, this.onTap, + this.isShowIndicator = false, }) : super(key: key); @override @@ -486,6 +506,12 @@ class _NonAlbumGridItem extends StatelessWidget { Expanded( child: Text(label), ), + if (isShowIndicator) + Icon( + Icons.circle, + color: Colors.red, + size: 8, + ), ], ), ), @@ -498,4 +524,5 @@ class _NonAlbumGridItem extends StatelessWidget { final IconData icon; final String label; final VoidCallback? onTap; + final bool isShowIndicator; } diff --git a/lib/widget/my_app.dart b/lib/widget/my_app.dart index 1840557d..d018fc97 100644 --- a/lib/widget/my_app.dart +++ b/lib/widget/my_app.dart @@ -16,6 +16,7 @@ import 'package:nc_photos/widget/connect.dart'; import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/lab_settings.dart'; +import 'package:nc_photos/widget/pending_albums.dart'; import 'package:nc_photos/widget/root_picker.dart'; import 'package:nc_photos/widget/settings.dart'; import 'package:nc_photos/widget/setup.dart'; @@ -115,6 +116,7 @@ class _MyAppState extends State implements SnackBarHandler { route ??= _handleAlbumImporterRoute(settings); route ??= _handleTrashbinBrowserRoute(settings); route ??= _handleTrashbinViewerRoute(settings); + route ??= _handlePendingAlbumsRoute(settings); return route; } @@ -293,6 +295,19 @@ class _MyAppState extends State implements SnackBarHandler { return null; } + Route? _handlePendingAlbumsRoute(RouteSettings settings) { + try { + if (settings.name == PendingAlbums.routeName && + settings.arguments != null) { + final args = settings.arguments as PendingAlbumsArguments; + return PendingAlbums.buildRoute(args); + } + } catch (e) { + _log.severe("[_handlePendingAlbumsRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); late AppEventListener _themeChangedListener; diff --git a/lib/widget/pending_albums.dart b/lib/widget/pending_albums.dart new file mode 100644 index 00000000..0cb689d8 --- /dev/null +++ b/lib/widget/pending_albums.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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_pending_shared_album.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/use_case/import_potential_shared_album.dart'; +import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util; +import 'package:nc_photos/widget/builder/album_grid_item_builder.dart'; +import 'package:nc_photos/widget/empty_list_indicator.dart'; +import 'package:tuple/tuple.dart'; + +class PendingAlbumsArguments { + PendingAlbumsArguments(this.account); + + final Account account; +} + +class PendingAlbums extends StatefulWidget { + static const routeName = "/pending-albums"; + + static Route buildRoute(PendingAlbumsArguments args) => + MaterialPageRoute( + builder: (context) => PendingAlbums.fromArgs(args), + ); + + PendingAlbums({ + Key? key, + required this.account, + }) : super(key: key); + + PendingAlbums.fromArgs(PendingAlbumsArguments args, {Key? key}) + : this( + key: key, + account: args.account, + ); + + @override + createState() => _PendingAlbumsState(); + + final Account account; +} + +class _PendingAlbumsState extends State { + @override + initState() { + super.initState(); + _importPotentialSharedAlbum().then((_) { + _bloc.add(ListPendingSharedAlbumBlocQuery(widget.account)); + }); + Pref.inst().setNewSharedAlbum(false); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + bloc: _bloc, + builder: (context, state) => _buildContent(context, state), + ), + ), + ), + ); + } + + Widget _buildContent( + BuildContext context, ListPendingSharedAlbumBlocState state) { + if (state is ListPendingSharedAlbumBlocSuccess && _items.isEmpty) { + return Column( + children: [ + AppBar( + title: Text("Sharing with you"), + elevation: 0, + ), + Expanded( + child: EmptyListIndicator( + icon: Icons.share_outlined, + text: L10n.of(context).listEmptyText, + ), + ), + ], + ); + } else { + return Stack( + children: [ + Theme( + data: Theme.of(context).copyWith( + accentColor: AppTheme.getOverscrollIndicatorColor(context), + ), + child: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text("Sharing with you"), + ), + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverStaggeredGrid.extentBuilder( + maxCrossAxisExtent: 256, + mainAxisSpacing: 8, + itemCount: _items.length, + itemBuilder: _buildItem, + staggeredTileBuilder: (_) => + const StaggeredTile.count(1, 1), + ), + ), + ], + ), + ), + if (!_isReady || state is ListPendingSharedAlbumBlocLoading) + Align( + alignment: Alignment.bottomCenter, + child: const LinearProgressIndicator(), + ), + ], + ); + } + } + + Widget _buildItem(BuildContext context, int index) { + final item = _items[index]; + return AlbumGridItemBuilder( + account: widget.account, + album: item.album, + onTap: () => _onItemTap(context, item), + ).build(context); + } + + void _onStateChange( + BuildContext context, ListPendingSharedAlbumBlocState state) { + if (state is ListPendingSharedAlbumBlocSuccess || + state is ListPendingSharedAlbumBlocLoading) { + _transformItems(state.items); + } else if (state is ListPendingSharedAlbumBlocFailure) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.exception, context)), + duration: k.snackBarDurationNormal, + )); + } else if (state is ListPendingSharedAlbumBlocInconsistent) { + _bloc.add(ListPendingSharedAlbumBlocQuery(widget.account)); + } + _isReady = true; + } + + void _onItemTap(BuildContext context, _GridItem item) { + album_browser_util.open(context, widget.account, item.album); + } + + void _transformItems(List items) { + final sortedAlbums = items + .map((e) => Tuple2( + e.album.provider.latestItemTime ?? e.album.lastUpdated, e.album)) + .sorted((a, b) { + // then sort in descending order + final tmp = b.item1.compareTo(a.item1); + if (tmp != 0) { + return tmp; + } else { + return a.item2.name.compareTo(b.item2.name); + } + }).map((e) => e.item2); + _items.clear(); + _items.addAll(sortedAlbums.map((e) => _GridItem(e))); + } + + Future _importPotentialSharedAlbum() async { + final fileRepo = FileRepo(FileWebdavDataSource()); + // don't want the potential albums to be cached at this moment + final albumRepo = AlbumRepo(AlbumRemoteDataSource()); + try { + await ImportPotentialSharedAlbum(fileRepo, albumRepo)(widget.account); + } catch (e, stacktrace) { + _log.shout( + "[_importPotentialSharedAlbum] Failed while ImportPotentialSharedAlbum", + e, + stacktrace); + } + } + + final _bloc = ListPendingSharedAlbumBloc(); + bool _isReady = false; + + var _items = <_GridItem>[]; + + static final _log = + Logger("widget.pending_albums._PendingAlbumsState"); +} + +class _GridItem { + _GridItem(this.album); + + Album album; +}