List pending shard albums

This commit is contained in:
Ming Ming 2021-08-21 01:02:13 +08:00
parent 6d2bfb2831
commit c0f65745f7
7 changed files with 505 additions and 10 deletions

View file

@ -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<ListPendingSharedAlbumBlocItem> items;
}
class ListPendingSharedAlbumBlocInit
extends ListPendingSharedAlbumBlocState {
ListPendingSharedAlbumBlocInit() : super(const []);
}
class ListPendingSharedAlbumBlocLoading
extends ListPendingSharedAlbumBlocState {
const ListPendingSharedAlbumBlocLoading(
List<ListPendingSharedAlbumBlocItem> items)
: super(items);
}
class ListPendingSharedAlbumBlocSuccess
extends ListPendingSharedAlbumBlocState {
const ListPendingSharedAlbumBlocSuccess(
List<ListPendingSharedAlbumBlocItem> items)
: super(items);
}
class ListPendingSharedAlbumBlocFailure
extends ListPendingSharedAlbumBlocState {
const ListPendingSharedAlbumBlocFailure(
List<ListPendingSharedAlbumBlocItem> 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<ListPendingSharedAlbumBlocItem> 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<ListPendingSharedAlbumBlocState> _onEventQuery(
ListPendingSharedAlbumBlocQuery ev) async* {
yield ListPendingSharedAlbumBlocLoading([]);
try {
final fileRepo = FileRepo(FileCachedDataSource());
final albumRepo = AlbumRepo(AlbumCachedDataSource());
final albums = <Album>[];
final errors = <dynamic>[];
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<ListPendingSharedAlbumBlocState> _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<FileMovedEvent>(_onFileMovedEvent);
static final _log =
Logger("bloc.list_pending_shared_album.ListPendingSharedAlbumBloc");
}

View file

@ -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<dynamic> call(Account account) async* {
List<File> 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");
}

View file

@ -109,7 +109,7 @@ class _AlbumBrowserState extends State<AlbumBrowser>
} }
@protected @protected
get canEdit => _album != null; get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true;
@override @override
enterEditMode() { enterEditMode() {

View file

@ -110,7 +110,7 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
} }
@protected @protected
get canEdit => _album != null; get canEdit => _album?.albumFile?.isOwned(widget.account.username) == true;
@override @override
enterEditMode() { enterEditMode() {
@ -261,12 +261,14 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
context, context,
widget.account, widget.account,
_album!, _album!,
menuItemBuilder: (context) => [ menuItemBuilder: canEdit
? (context) => [
PopupMenuItem( PopupMenuItem(
value: _menuValueConvertBasic, value: _menuValueConvertBasic,
child: Text(L10n.of(context).convertBasicAlbumMenuLabel), child: Text(L10n.of(context).convertBasicAlbumMenuLabel),
), ),
], ]
: null,
onSelectedMenuItem: (option) { onSelectedMenuItem: (option) {
switch (option) { switch (option) {
case _menuValueConvertBasic: case _menuValueConvertBasic:

View file

@ -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/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; 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/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.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/home_app_bar.dart';
import 'package:nc_photos/widget/new_album_dialog.dart'; import 'package:nc_photos/widget/new_album_dialog.dart';
import 'package:nc_photos/widget/page_visibility_mixin.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/selection_app_bar.dart';
import 'package:nc_photos/widget/trashbin_browser.dart'; import 'package:nc_photos/widget/trashbin_browser.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -192,7 +195,9 @@ class _HomeAlbumsState extends State<HomeAlbums>
return _buildArchiveItem(context); return _buildArchiveItem(context);
} else if (index == 1) { } else if (index == 1) {
return _buildTrashbinItem(context); 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); return _buildNewAlbumItem(context);
} else if (index == _extraGridItemCount) { } else if (index == _extraGridItemCount) {
return Container(); return Container();
@ -239,6 +244,20 @@ class _HomeAlbumsState extends State<HomeAlbums>
); );
} }
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) { Widget _buildNewAlbumItem(BuildContext context) {
return _NonAlbumGridItem( return _NonAlbumGridItem(
icon: Icons.add, icon: Icons.add,
@ -437,7 +456,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
static final _log = Logger("widget.home_albums._HomeAlbumsState"); static final _log = Logger("widget.home_albums._HomeAlbumsState");
static const _menuValueImport = 0; static const _menuValueImport = 0;
static const _extraGridItemCount = 3; static final _extraGridItemCount = 3 + (Lab().enableSharedAlbum ? 1 : 0);
} }
class _GridItem { class _GridItem {
@ -454,6 +473,7 @@ class _NonAlbumGridItem extends StatelessWidget {
required this.icon, required this.icon,
required this.label, required this.label,
this.onTap, this.onTap,
this.isShowIndicator = false,
}) : super(key: key); }) : super(key: key);
@override @override
@ -486,6 +506,12 @@ class _NonAlbumGridItem extends StatelessWidget {
Expanded( Expanded(
child: Text(label), 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 IconData icon;
final String label; final String label;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool isShowIndicator;
} }

View file

@ -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/dynamic_album_browser.dart';
import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/lab_settings.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/root_picker.dart';
import 'package:nc_photos/widget/settings.dart'; import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/setup.dart'; import 'package:nc_photos/widget/setup.dart';
@ -115,6 +116,7 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
route ??= _handleAlbumImporterRoute(settings); route ??= _handleAlbumImporterRoute(settings);
route ??= _handleTrashbinBrowserRoute(settings); route ??= _handleTrashbinBrowserRoute(settings);
route ??= _handleTrashbinViewerRoute(settings); route ??= _handleTrashbinViewerRoute(settings);
route ??= _handlePendingAlbumsRoute(settings);
return route; return route;
} }
@ -293,6 +295,19 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
return null; return null;
} }
Route<dynamic>? _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<ScaffoldMessengerState>(); final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late AppEventListener<ThemeChangedEvent> _themeChangedListener; late AppEventListener<ThemeChangedEvent> _themeChangedListener;

View file

@ -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<PendingAlbums> {
@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<ListPendingSharedAlbumBloc,
ListPendingSharedAlbumBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListPendingSharedAlbumBloc,
ListPendingSharedAlbumBlocState>(
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<ListPendingSharedAlbumBlocItem> 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<void> _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;
}