From 2812e1336ec231bd7c225dca8efcf944813dd13c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 2 Aug 2021 04:46:16 +0800 Subject: [PATCH] Handle files in trash bin --- lib/api/api_util.dart | 21 +- lib/bloc/ls_trashbin.dart | 187 ++++++++++++ lib/bloc/scan_dir.dart | 29 +- lib/entity/file_util.dart | 5 + lib/event/event.dart | 7 + lib/l10n/app_en.arb | 54 ++++ lib/use_case/ls_trashbin.dart | 13 + lib/use_case/restore_trashbin.dart | 21 ++ lib/widget/home_albums.dart | 27 +- lib/widget/my_app.dart | 32 ++ lib/widget/trashbin_browser.dart | 472 +++++++++++++++++++++++++++++ lib/widget/trashbin_viewer.dart | 372 +++++++++++++++++++++++ 12 files changed, 1230 insertions(+), 10 deletions(-) create mode 100644 lib/bloc/ls_trashbin.dart create mode 100644 lib/use_case/ls_trashbin.dart create mode 100644 lib/use_case/restore_trashbin.dart create mode 100644 lib/widget/trashbin_browser.dart create mode 100644 lib/widget/trashbin_viewer.dart diff --git a/lib/api/api_util.dart b/lib/api/api_util.dart index c9f56d94..e77ea694 100644 --- a/lib/api/api_util.dart +++ b/lib/api/api_util.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception.dart'; /// Return the preview image URL for [file]. See [getFilePreviewUrlRelative] @@ -15,13 +16,14 @@ String getFilePreviewUrl( bool? a, }) { return "${account.url}/" - "${getFilePreviewUrlRelative(file, width: width, height: height, mode: mode, a: a)}"; + "${getFilePreviewUrlRelative(account, file, width: width, height: height, mode: mode, a: a)}"; } /// Return the relative preview image URL for [file]. If [a] == true, the /// preview will maintain the original aspect ratio, otherwise it will be /// cropped String getFilePreviewUrlRelative( + Account account, File file, { required int width, required int height, @@ -29,12 +31,18 @@ String getFilePreviewUrlRelative( bool? a, }) { String url; - if (file.fileId != null) { - url = "index.php/core/preview?fileId=${file.fileId}"; + if (file_util.isTrash(account, file)) { + // trashbin does not support preview.png endpoint + url = "index.php/apps/files_trashbin/preview?fileId=${file.fileId}"; } else { - final filePath = Uri.encodeQueryComponent(file.strippedPath); - url = "index.php/core/preview.png?file=$filePath"; + if (file.fileId != null) { + url = "index.php/core/preview?fileId=${file.fileId}"; + } else { + final filePath = Uri.encodeQueryComponent(file.strippedPath); + url = "index.php/core/preview.png?file=$filePath"; + } } + url = "$url&x=$width&y=$height"; if (mode != null) { url = "$url&mode=$mode"; @@ -56,6 +64,9 @@ String getFileUrlRelative(File file) { String getWebdavRootUrlRelative(Account account) => "remote.php/dav/files/${account.username}"; +String getTrashbinPath(Account account) => + "remote.php/dav/trashbin/${account.username}/trash"; + /// Query the app password for [account] Future exchangePassword(Account account) async { final response = await Api(account).request( diff --git a/lib/bloc/ls_trashbin.dart b/lib/bloc/ls_trashbin.dart new file mode 100644 index 00000000..c0705d9b --- /dev/null +++ b/lib/bloc/ls_trashbin.dart @@ -0,0 +1,187 @@ +import 'package:bloc/bloc.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/throttler.dart'; +import 'package:nc_photos/use_case/ls_trashbin.dart'; + +abstract class LsTrashbinBlocEvent { + const LsTrashbinBlocEvent(); +} + +class LsTrashbinBlocQuery extends LsTrashbinBlocEvent { + const LsTrashbinBlocQuery(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 _LsTrashbinBlocExternalEvent extends LsTrashbinBlocEvent { + const _LsTrashbinBlocExternalEvent(); + + @override + toString() { + return "$runtimeType {" + "}"; + } +} + +abstract class LsTrashbinBlocState { + const LsTrashbinBlocState(this.account, this.items); + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "items: List {length: ${items.length}}, " + "}"; + } + + final Account? account; + final List items; +} + +class LsTrashbinBlocInit extends LsTrashbinBlocState { + LsTrashbinBlocInit() : super(null, const []); +} + +class LsTrashbinBlocLoading extends LsTrashbinBlocState { + const LsTrashbinBlocLoading(Account? account, List items) + : super(account, items); +} + +class LsTrashbinBlocSuccess extends LsTrashbinBlocState { + const LsTrashbinBlocSuccess(Account? account, List items) + : super(account, items); +} + +class LsTrashbinBlocFailure extends LsTrashbinBlocState { + const LsTrashbinBlocFailure( + Account? account, List items, this.exception) + : super(account, 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 LsTrashbinBlocInconsistent extends LsTrashbinBlocState { + const LsTrashbinBlocInconsistent(Account? account, List items) + : super(account, items); +} + +class LsTrashbinBloc extends Bloc { + LsTrashbinBloc() : super(LsTrashbinBlocInit()) { + _fileRemovedEventListener = + AppEventListener(_onFileRemovedEvent); + _fileTrashbinRestoredEventListener = + AppEventListener( + _onFileTrashbinRestoredEvent); + _fileRemovedEventListener.begin(); + _fileTrashbinRestoredEventListener.begin(); + + _refreshThrottler = Throttler( + onTriggered: (_) { + add(_LsTrashbinBlocExternalEvent()); + }, + logTag: "LsTrashbinBloc.refresh", + ); + } + + static LsTrashbinBloc of(Account account) { + final id = "${account.scheme}://${account.username}@${account.address}"; + try { + _log.fine("[of] Resolving bloc for '$id'"); + return KiwiContainer().resolve("LsTrashbinBloc($id)"); + } catch (_) { + // no created instance for this account, make a new one + _log.info("[of] New bloc instance for account: $account"); + final bloc = LsTrashbinBloc(); + KiwiContainer() + .registerInstance(bloc, name: "LsTrashbinBloc($id)"); + return bloc; + } + } + + @override + mapEventToState(LsTrashbinBlocEvent event) async* { + _log.info("[mapEventToState] $event"); + if (event is LsTrashbinBlocQuery) { + yield* _onEventQuery(event); + } else if (event is _LsTrashbinBlocExternalEvent) { + yield* _onExternalEvent(event); + } + } + + Stream _onEventQuery(LsTrashbinBlocQuery ev) async* { + try { + yield LsTrashbinBlocLoading(ev.account, state.items); + yield LsTrashbinBlocSuccess(ev.account, await _query(ev)); + } catch (e) { + _log.severe("[_onEventQuery] Exception while request", e); + yield LsTrashbinBlocFailure(ev.account, state.items, e); + } + } + + Stream _onExternalEvent( + _LsTrashbinBlocExternalEvent ev) async* { + yield LsTrashbinBlocInconsistent(state.account, state.items); + } + + void _onFileRemovedEvent(FileRemovedEvent ev) { + if (state is LsTrashbinBlocInit) { + // no data in this bloc, ignore + return; + } + if (file_util.isTrash(ev.account, ev.file)) { + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + } + + void _onFileTrashbinRestoredEvent(FileTrashbinRestoredEvent ev) { + if (state is LsTrashbinBlocInit) { + // no data in this bloc, ignore + return; + } + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + + Future> _query(LsTrashbinBlocQuery ev) { + // caching contents in trashbin doesn't sounds useful + final fileRepo = FileRepo(FileWebdavDataSource()); + return LsTrashbin(fileRepo)(ev.account); + } + + late final AppEventListener _fileRemovedEventListener; + late final AppEventListener + _fileTrashbinRestoredEventListener; + + late Throttler _refreshThrottler; + + static final _log = Logger("bloc.ls_trashbin.LsTrashbinBloc"); +} diff --git a/lib/bloc/scan_dir.dart b/lib/bloc/scan_dir.dart index 21a37106..8e43733e 100644 --- a/lib/bloc/scan_dir.dart +++ b/lib/bloc/scan_dir.dart @@ -7,6 +7,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/throttler.dart'; @@ -118,8 +119,12 @@ class ScanDirBloc extends Bloc { AppEventListener(_onFileRemovedEvent); _filePropertyUpdatedEventListener = AppEventListener(_onFilePropertyUpdatedEvent); + _fileTrashbinRestoredEventListener = + AppEventListener( + _onFileTrashbinRestoredEvent); _fileRemovedEventListener.begin(); _filePropertyUpdatedEventListener.begin(); + _fileTrashbinRestoredEventListener.begin(); _refreshThrottler = Throttler( onTriggered: (_) { @@ -171,6 +176,7 @@ class ScanDirBloc extends Bloc { close() { _fileRemovedEventListener.end(); _filePropertyUpdatedEventListener.end(); + _fileTrashbinRestoredEventListener.end(); _refreshThrottler.clear(); return super.close(); } @@ -217,10 +223,12 @@ class ScanDirBloc extends Bloc { // no data in this bloc, ignore return; } - _refreshThrottler.trigger( - maxResponceTime: const Duration(seconds: 3), - maxPendingCount: 10, - ); + if (!file_util.isTrash(ev.account, ev.file)) { + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } } void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) { @@ -253,6 +261,17 @@ class ScanDirBloc extends Bloc { } } + void _onFileTrashbinRestoredEvent(FileTrashbinRestoredEvent ev) { + if (state is ScanDirBlocInit) { + // no data in this bloc, ignore + return; + } + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + Stream _queryOffline( ScanDirBlocQueryBase ev, ScanDirBlocState Function() getState) => _queryWithFileDataSource(ev, getState, FileAppDbDataSource()); @@ -287,6 +306,8 @@ class ScanDirBloc extends Bloc { late AppEventListener _fileRemovedEventListener; late AppEventListener _filePropertyUpdatedEventListener; + late final AppEventListener + _fileTrashbinRestoredEventListener; late Throttler _refreshThrottler; diff --git a/lib/entity/file_util.dart b/lib/entity/file_util.dart index b8eec361..a26f5518 100644 --- a/lib/entity/file_util.dart +++ b/lib/entity/file_util.dart @@ -1,3 +1,5 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; @@ -13,6 +15,9 @@ bool isSupportedVideoFormat(File file) => bool isMetadataSupportedFormat(File file) => _metadataSupportedFormatMimes.contains(file.contentType); +bool isTrash(Account account, File file) => + file.path.startsWith(api_util.getTrashbinPath(account)); + /// For a path "remote.php/dav/files/foo/bar.jpg", return foo String getUserDirName(File file) { if (file.path.startsWith("remote.php/dav/files/")) { diff --git a/lib/event/event.dart b/lib/event/event.dart index a36f7518..0e21495c 100644 --- a/lib/event/event.dart +++ b/lib/event/event.dart @@ -68,6 +68,13 @@ class FileRemovedEvent { final File file; } +class FileTrashbinRestoredEvent { + FileTrashbinRestoredEvent(this.account, this.file); + + final Account account; + final File file; +} + class ThemeChangedEvent {} class LanguageChangedEvent {} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2eaea131..10ec1f74 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -581,6 +581,60 @@ "@listNoResultsText": { "description": "When there's nothing in a list" }, + "albumTrashLabel": "Trash", + "@albumTrashLabel": { + "description": "Deleted photos" + }, + "restoreTooltip": "Restore", + "@restoreTooltip": { + "description": "Restore selected items from trashbin" + }, + "restoreSelectedProcessingNotification": "{count, plural, =1{Restoring 1 item} other{Restoring {count} items}}", + "@restoreSelectedProcessingNotification": { + "description": "Restoring selected items from trashbin", + "placeholders": { + "count": { + "example": "1" + } + } + }, + "restoreSelectedSuccessNotification": "All items restored successfully", + "@restoreSelectedSuccessNotification": { + "description": "Restored all selected items from trashbin successfully" + }, + "restoreSelectedFailureNotification": "{count, plural, =1{Failed restoring 1 item} other{Failed restoring {count} items}}", + "@restoreSelectedFailureNotification": { + "description": "Cannot restore some of the selected items from trashbin", + "placeholders": { + "count": { + "example": "1" + } + } + }, + "restoreProcessingNotification": "Restoring item", + "@restoreProcessingNotification": { + "description": "Restoring the opened item from trashbin" + }, + "restoreSuccessNotification": "Restored item successfully", + "@restoreSuccessNotification": { + "description": "Restored the opened item from trashbin successfully" + }, + "restoreFailureNotification": "Failed restoring item", + "@restoreFailureNotification": { + "description": "Cannot restore the opened item from trashbin" + }, + "deletePermanentlyTooltip": "Delete permanently", + "@deletePermanentlyTooltip": { + "description": "Permanently delete selected items from trashbin" + }, + "deletePermanentlyConfirmationDialogTitle": "Delete permanently", + "@deletePermanentlyConfirmationDialogTitle": { + "description": "Make sure the user wants to delete the items" + }, + "deletePermanentlyConfirmationDialogContent": "Selected items will be deleted permanently from the server.\n\nThis action is nonreversible", + "@deletePermanentlyConfirmationDialogContent": { + "description": "Make sure the user wants to delete the items" + }, "changelogTitle": "Changelog", "@changelogTitle": { diff --git a/lib/use_case/ls_trashbin.dart b/lib/use_case/ls_trashbin.dart new file mode 100644 index 00000000..715d5728 --- /dev/null +++ b/lib/use_case/ls_trashbin.dart @@ -0,0 +1,13 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/use_case/ls.dart'; + +class LsTrashbin { + LsTrashbin(this.fileRepo); + + Future> call(Account account) => + Ls(fileRepo)(account, File(path: api_util.getTrashbinPath(account))); + + final FileRepo fileRepo; +} diff --git a/lib/use_case/restore_trashbin.dart b/lib/use_case/restore_trashbin.dart new file mode 100644 index 00000000..9196aaca --- /dev/null +++ b/lib/use_case/restore_trashbin.dart @@ -0,0 +1,21 @@ +import 'package:event_bus/event_bus.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/use_case/move.dart'; +import 'package:path/path.dart' as path; + +class RestoreTrashbin { + RestoreTrashbin(this.fileRepo); + + Future call(Account account, File file) async { + await Move(fileRepo).call(account, file, + "remote.php/dav/trashbin/${account.username}/restore/${path.basename(file.path)}"); + KiwiContainer() + .resolve() + .fire(FileTrashbinRestoredEvent(account, file)); + } + + final FileRepo fileRepo; +} diff --git a/lib/widget/home_albums.dart b/lib/widget/home_albums.dart index 21302f82..642bf84d 100644 --- a/lib/widget/home_albums.dart +++ b/lib/widget/home_albums.dart @@ -29,6 +29,7 @@ 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/selection_app_bar.dart'; +import 'package:nc_photos/widget/trashbin_browser.dart'; import 'package:tuple/tuple.dart'; class HomeAlbums extends StatefulWidget { @@ -107,7 +108,7 @@ class _HomeAlbumsState extends State sliver: SliverStaggeredGrid.extentBuilder( maxCrossAxisExtent: 256, mainAxisSpacing: 8, - itemCount: _items.length + (_isSelectionMode ? 0 : 2), + itemCount: _items.length + (_isSelectionMode ? 0 : 3), itemBuilder: _buildItem, staggeredTileBuilder: (index) { return const StaggeredTile.count(1, 1); @@ -185,6 +186,8 @@ class _HomeAlbumsState extends State return _buildAlbumItem(context, index); } else if (index == _items.length) { return _buildArchiveItem(context); + } else if (index == _items.length + 1) { + return _buildTrashbinItem(context); } else { return _buildNewAlbumItem(context); } @@ -223,6 +226,28 @@ class _HomeAlbumsState extends State ); } + Widget _buildTrashbinItem(BuildContext context) { + return AlbumGridItem( + cover: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: AppTheme.getListItemBackgroundColor(context), + constraints: const BoxConstraints.expand(), + child: Icon( + Icons.delete, + color: Colors.white.withOpacity(.8), + size: 88, + ), + ), + ), + title: L10n.of(context).albumTrashLabel, + onTap: () { + Navigator.of(context).pushNamed(TrashbinBrowser.routeName, + arguments: TrashbinBrowserArguments(widget.account)); + }, + ); + } + Widget _buildNewAlbumItem(BuildContext context) { return AlbumGridItem( cover: ClipRRect( diff --git a/lib/widget/my_app.dart b/lib/widget/my_app.dart index dd584d7b..cc9ff848 100644 --- a/lib/widget/my_app.dart +++ b/lib/widget/my_app.dart @@ -20,6 +20,8 @@ import 'package:nc_photos/widget/settings.dart'; import 'package:nc_photos/widget/setup.dart'; import 'package:nc_photos/widget/sign_in.dart'; import 'package:nc_photos/widget/splash.dart'; +import 'package:nc_photos/widget/trashbin_browser.dart'; +import 'package:nc_photos/widget/trashbin_viewer.dart'; import 'package:nc_photos/widget/viewer.dart'; class MyApp extends StatefulWidget { @@ -103,6 +105,8 @@ class _MyAppState extends State implements SnackBarHandler { route ??= _handleDynamicAlbumBrowserRoute(settings); route ??= _handleAlbumDirPickerRoute(settings); route ??= _handleAlbumImporterRoute(settings); + route ??= _handleTrashbinBrowserRoute(settings); + route ??= _handleTrashbinViewerRoute(settings); return route; } @@ -253,6 +257,34 @@ class _MyAppState extends State implements SnackBarHandler { return null; } + Route? _handleTrashbinBrowserRoute(RouteSettings settings) { + try { + if (settings.name == TrashbinBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as TrashbinBrowserArguments; + return TrashbinBrowser.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleTrashbinBrowserRoute] Failed while handling route", e); + } + return null; + } + + Route? _handleTrashbinViewerRoute(RouteSettings settings) { + try { + if (settings.name == TrashbinViewer.routeName && + settings.arguments != null) { + final args = settings.arguments as TrashbinViewerArguments; + return TrashbinViewer.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleTrashbinViewerRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); late AppEventListener _themeChangedListener; diff --git a/lib/widget/trashbin_browser.dart b/lib/widget/trashbin_browser.dart new file mode 100644 index 00000000..d17343c0 --- /dev/null +++ b/lib/widget/trashbin_browser.dart @@ -0,0 +1,472 @@ +import 'package:flutter/foundation.dart'; +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/api/api_util.dart' as api_util; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc/ls_trashbin.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +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/remove.dart'; +import 'package:nc_photos/use_case/restore_trashbin.dart'; +import 'package:nc_photos/widget/photo_list_item.dart'; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos/widget/trashbin_viewer.dart'; +import 'package:nc_photos/widget/zoom_menu_button.dart'; + +class TrashbinBrowserArguments { + TrashbinBrowserArguments(this.account); + + final Account account; +} + +class TrashbinBrowser extends StatefulWidget { + static const routeName = "/trashbin-browser"; + + static Route buildRoute(TrashbinBrowserArguments args) => MaterialPageRoute( + builder: (context) => TrashbinBrowser.fromArgs(args), + ); + + TrashbinBrowser({ + Key? key, + required this.account, + }) : super(key: key); + + TrashbinBrowser.fromArgs(TrashbinBrowserArguments args, {Key? key}) + : this( + key: key, + account: args.account, + ); + + @override + createState() => _TrashbinBrowserState(); + + final Account account; +} + +class _TrashbinBrowserState extends State + with SelectableItemStreamListMixin { + @override + initState() { + super.initState(); + _initBloc(); + _thumbZoomLevel = Pref.inst().getAlbumBrowserZoomLevelOr(0); + } + + @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), + ), + ), + ), + ); + } + + void _initBloc() { + _bloc = LsTrashbinBloc.of(widget.account); + if (_bloc.state is LsTrashbinBlocInit) { + _log.info("[_initBloc] Initialize bloc"); + _reqQuery(); + } else { + // process the current state + WidgetsBinding.instance!.addPostFrameCallback((_) { + setState(() { + _onStateChange(context, _bloc.state); + }); + _reqQuery(); + }); + } + } + + Widget _buildContent(BuildContext context, LsTrashbinBlocState state) { + return Stack( + children: [ + buildItemStreamListOuter( + context, + child: Theme( + data: Theme.of(context).copyWith( + accentColor: AppTheme.getOverscrollIndicatorColor(context), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.only(top: 8), + sliver: buildItemStreamList( + maxCrossAxisExtent: _thumbSize.toDouble(), + ), + ), + ], + ), + ), + ), + if (state is LsTrashbinBlocLoading) + Align( + alignment: Alignment.bottomCenter, + child: const LinearProgressIndicator(), + ), + ], + ); + } + + Widget _buildAppBar(BuildContext context) { + if (isSelectionMode) { + return _buildSelectionAppBar(context); + } else { + return _buildNormalAppBar(context); + } + } + + Widget _buildSelectionAppBar(BuildContext context) { + return SelectionAppBar( + count: selectedListItems.length, + onClosePressed: () { + setState(() { + clearSelectedItems(); + }); + }, + actions: [ + IconButton( + icon: const Icon(Icons.restore_outlined), + tooltip: L10n.of(context).restoreTooltip, + onPressed: () { + _onSelectionAppBarRestorePressed(); + }, + ), + PopupMenuButton<_AppBarMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _AppBarMenuOption.delete, + child: Text(L10n.of(context).deletePermanentlyTooltip), + ), + ], + onSelected: (option) { + switch (option) { + case _AppBarMenuOption.delete: + _onSelectionAppBarDeletePressed(context); + break; + + default: + _log.shout("[_buildSelectionAppBar] Unknown option: $option"); + break; + } + }, + ) + ], + ); + } + + Widget _buildNormalAppBar(BuildContext context) { + return SliverAppBar( + title: Text(L10n.of(context).albumTrashLabel), + floating: true, + actions: [ + ZoomMenuButton( + initialZoom: _thumbZoomLevel, + minZoom: 0, + maxZoom: 2, + onZoomChanged: (value) { + setState(() { + _thumbZoomLevel = value.round(); + }); + Pref.inst().setAlbumBrowserZoomLevel(_thumbZoomLevel); + }, + ), + ], + ); + } + + void _onStateChange(BuildContext context, LsTrashbinBlocState state) { + if (state is LsTrashbinBlocInit) { + itemStreamListItems = []; + } else if (state is LsTrashbinBlocSuccess || + state is LsTrashbinBlocLoading) { + _transformItems(state.items); + } else if (state is LsTrashbinBlocFailure) { + _transformItems(state.items); + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.exception, context)), + duration: k.snackBarDurationNormal, + )); + } else if (state is LsTrashbinBlocInconsistent) { + _reqQuery(); + } + } + + void _onItemTap(int index) { + Navigator.pushNamed(context, TrashbinViewer.routeName, + arguments: + TrashbinViewerArguments(widget.account, _backingFiles, index)); + } + + Future _onSelectionAppBarRestorePressed() async { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context) + .restoreSelectedProcessingNotification(selectedListItems.length)), + duration: k.snackBarDurationShort, + )); + final selectedFiles = selectedListItems + .whereType<_FileListItem>() + .map((e) => e.file) + .toList(); + setState(() { + clearSelectedItems(); + }); + final fileRepo = FileRepo(FileWebdavDataSource()); + final failures = []; + for (final f in selectedFiles) { + try { + await RestoreTrashbin(fileRepo)(widget.account, f); + } catch (e, stacktrace) { + _log.shout( + "[_onSelectionAppBarRestorePressed] Failed while restoring file" + + (kDebugMode ? ": ${f.path}" : ""), + e, + stacktrace); + failures.add(f); + } + } + if (failures.isEmpty) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).restoreSelectedSuccessNotification), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context) + .restoreSelectedFailureNotification(failures.length)), + duration: k.snackBarDurationNormal, + )); + } + } + + Future _onSelectionAppBarDeletePressed(BuildContext context) async { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(L10n.of(context).deletePermanentlyConfirmationDialogTitle), + content: + Text(L10n.of(context).deletePermanentlyConfirmationDialogContent), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _deleteSelected(context); + }, + child: Text(L10n.of(context).confirmButtonLabel), + ), + ], + ), + ); + } + + void _transformItems(List files) { + _backingFiles = files + .where((element) => file_util.isSupportedFormat(element)) + .sorted((a, b) { + if (a.trashbinDeletionTime == null && b.trashbinDeletionTime == null) { + // ? + return 0; + } else if (a.trashbinDeletionTime == null) { + return -1; + } else if (b.trashbinDeletionTime == null) { + return 1; + } else { + return b.trashbinDeletionTime!.compareTo(a.trashbinDeletionTime!); + } + }); + + itemStreamListItems = () sync* { + for (int i = 0; i < _backingFiles.length; ++i) { + final f = _backingFiles[i]; + + final previewUrl = api_util.getFilePreviewUrl(widget.account, f, + width: _thumbSize, height: _thumbSize); + if (file_util.isSupportedImageFormat(f)) { + yield _ImageListItem( + file: f, + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else if (file_util.isSupportedVideoFormat(f)) { + yield _VideoListItem( + file: f, + account: widget.account, + previewUrl: previewUrl, + onTap: () => _onItemTap(i), + ); + } else { + _log.shout( + "[_transformItems] Unsupported file format: ${f.contentType}"); + } + } + }() + .toList(); + } + + Future _deleteSelected(BuildContext context) async { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context) + .deleteSelectedProcessingNotification(selectedListItems.length)), + duration: k.snackBarDurationShort, + )); + final selectedFiles = selectedListItems + .whereType<_FileListItem>() + .map((e) => e.file) + .toList(); + setState(() { + clearSelectedItems(); + }); + final fileRepo = FileRepo(FileCachedDataSource()); + final failures = []; + for (final f in selectedFiles) { + try { + await Remove(fileRepo, null)(widget.account, f); + } catch (e, stacktrace) { + _log.shout( + "[_deleteSelected] Failed while removing file" + + (kDebugMode ? ": ${f.path}" : ""), + e, + stacktrace); + failures.add(f); + } + } + if (failures.isEmpty) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).deleteSelectedSuccessNotification), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context) + .deleteSelectedFailureNotification(failures.length)), + duration: k.snackBarDurationNormal, + )); + } + } + + void _reqQuery() { + _bloc.add(LsTrashbinBlocQuery(widget.account)); + } + + int get _thumbSize { + switch (_thumbZoomLevel) { + case 1: + return 176; + + case 2: + return 256; + + case 0: + default: + return 112; + } + } + + late LsTrashbinBloc _bloc; + + var _backingFiles = []; + + var _thumbZoomLevel = 0; + + static final _log = Logger("widget.trashbin_browser._TrashbinBrowserState"); +} + +abstract class _ListItem implements SelectableItem { + _ListItem({ + VoidCallback? onTap, + }) : _onTap = onTap; + + @override + get onTap => _onTap; + + @override + get isSelectable => true; + + @override + get staggeredTile => const StaggeredTile.count(1, 1); + + final VoidCallback? _onTap; +} + +abstract class _FileListItem extends _ListItem { + _FileListItem({ + required this.file, + VoidCallback? onTap, + }) : super(onTap: onTap); + + @override + operator ==(Object other) { + return other is _FileListItem && file.path == other.file.path; + } + + @override + get hashCode => file.path.hashCode; + + final File file; +} + +class _ImageListItem extends _FileListItem { + _ImageListItem({ + required File file, + required this.account, + required this.previewUrl, + VoidCallback? onTap, + }) : super(file: file, onTap: onTap); + + @override + buildWidget(BuildContext context) { + return PhotoListImage( + account: account, + previewUrl: previewUrl, + isGif: file.contentType == "image/gif", + ); + } + + final Account account; + final String previewUrl; +} + +class _VideoListItem extends _FileListItem { + _VideoListItem({ + required File file, + required this.account, + required this.previewUrl, + VoidCallback? onTap, + }) : super(file: file, onTap: onTap); + + @override + buildWidget(BuildContext context) { + return PhotoListVideo( + account: account, + previewUrl: previewUrl, + ); + } + + final Account account; + final String previewUrl; +} + +enum _AppBarMenuOption { + delete, +} diff --git a/lib/widget/trashbin_viewer.dart b/lib/widget/trashbin_viewer.dart new file mode 100644 index 00000000..8ebc0097 --- /dev/null +++ b/lib/widget/trashbin_viewer.dart @@ -0,0 +1,372 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/use_case/restore_trashbin.dart'; +import 'package:nc_photos/widget/horizontal_page_viewer.dart'; +import 'package:nc_photos/widget/image_viewer.dart'; +import 'package:nc_photos/widget/video_viewer.dart'; + +class TrashbinViewerArguments { + TrashbinViewerArguments(this.account, this.streamFiles, this.startIndex); + + final Account account; + final List streamFiles; + final int startIndex; +} + +class TrashbinViewer extends StatefulWidget { + static const routeName = "/trashbin-viewer"; + + static Route buildRoute(TrashbinViewerArguments args) => MaterialPageRoute( + builder: (context) => TrashbinViewer.fromArgs(args), + ); + + TrashbinViewer({ + Key? key, + required this.account, + required this.streamFiles, + required this.startIndex, + }) : super(key: key); + + TrashbinViewer.fromArgs(TrashbinViewerArguments args, {Key? key}) + : this( + key: key, + account: args.account, + streamFiles: args.streamFiles, + startIndex: args.startIndex, + ); + + @override + createState() => _TrashbinViewerState(); + + final Account account; + final List streamFiles; + final int startIndex; +} + +class _TrashbinViewerState extends State { + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder( + builder: _buildContent, + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _isShowVideoControl = !_isShowVideoControl; + }); + }, + child: Stack( + children: [ + Container(color: Colors.black), + if (!_isViewerLoaded || + !_pageStates[_viewerController.currentPage]!.hasLoaded) + Align( + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), + HorizontalPageViewer( + pageCount: widget.streamFiles.length, + pageBuilder: _buildPage, + initialPage: widget.startIndex, + controller: _viewerController, + viewportFraction: _viewportFraction, + canSwitchPage: _canSwitchPage, + ), + _buildAppBar(context), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return Wrap( + children: [ + Stack( + children: [ + Container( + // + status bar height + height: kToolbarHeight + MediaQuery.of(context).padding.top, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: const Alignment(0, -1), + end: const Alignment(0, 1), + colors: [ + Color.fromARGB(192, 0, 0, 0), + Color.fromARGB(0, 0, 0, 0), + ], + ), + ), + ), + AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + brightness: Brightness.dark, + iconTheme: Theme.of(context).iconTheme.copyWith( + color: Colors.white.withOpacity(.87), + ), + actionsIconTheme: Theme.of(context).iconTheme.copyWith( + color: Colors.white.withOpacity(.87), + ), + actions: [ + IconButton( + icon: const Icon(Icons.restore_outlined), + tooltip: L10n.of(context).restoreTooltip, + onPressed: _onRestorePressed, + ), + PopupMenuButton<_AppBarMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _AppBarMenuOption.delete, + child: Text(L10n.of(context).deletePermanentlyTooltip), + ), + ], + onSelected: (option) { + switch (option) { + case _AppBarMenuOption.delete: + _onDeletePressed(context); + break; + + default: + _log.shout("[_buildAppBar] Unknown option: $option"); + break; + } + }, + ), + ], + ), + ], + ), + ], + ); + } + + void _onRestorePressed() async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_onRestorePressed] Restoring file: ${file.path}"); + var controller = SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).restoreProcessingNotification), + duration: k.snackBarDurationShort, + )); + controller?.closed.whenComplete(() { + controller = null; + }); + final fileRepo = FileRepo(FileCachedDataSource()); + try { + await RestoreTrashbin(fileRepo)(widget.account, file); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).restoreSuccessNotification), + duration: k.snackBarDurationNormal, + )); + Navigator.of(context).pop(); + } catch (e, stacktrace) { + _log.shout( + "Failed while restore trashbin" + + (kDebugMode ? ": ${file.path}" : ""), + e, + stacktrace); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text("${L10n.of(context).restoreFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + } + } + + Future _onDeletePressed(BuildContext context) async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_onDeletePressed] Deleting file permanently: ${file.path}"); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(L10n.of(context).deletePermanentlyConfirmationDialogTitle), + content: + Text(L10n.of(context).deletePermanentlyConfirmationDialogContent), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _delete(context); + }, + child: Text(L10n.of(context).confirmButtonLabel), + ), + ], + ), + ); + } + + Widget _buildPage(BuildContext context, int index) { + if (_pageStates[index] == null) { + _pageStates[index] = _PageState(); + } + return FractionallySizedBox( + widthFactor: 1 / _viewportFraction, + child: _buildItemView(context, index), + ); + } + + Widget _buildItemView(BuildContext context, int index) { + final file = widget.streamFiles[index]; + if (file_util.isSupportedImageFormat(file)) { + return _buildImageView(context, index); + } else if (file_util.isSupportedVideoFormat(file)) { + return _buildVideoView(context, index); + } else { + _log.shout("[_buildItemView] Unknown file format: ${file.contentType}"); + return Container(); + } + } + + Widget _buildImageView(BuildContext context, int index) { + return ImageViewer( + account: widget.account, + file: widget.streamFiles[index], + canZoom: true, + onLoaded: () => _onImageLoaded(index), + onZoomStarted: () { + setState(() { + _isZoomed = true; + }); + }, + onZoomEnded: () { + setState(() { + _isZoomed = false; + }); + }, + ); + } + + Widget _buildVideoView(BuildContext context, int index) { + return VideoViewer( + account: widget.account, + file: widget.streamFiles[index], + onLoaded: () => _onVideoLoaded(index), + onPlay: _onVideoPlay, + onPause: _onVideoPause, + isControlVisible: _isShowVideoControl, + ); + } + + void _onImageLoaded(int index) { + // currently pageview doesn't pre-load pages, we do it manually + // don't pre-load if user already navigated away + if (_viewerController.currentPage == index && + !_pageStates[index]!.hasLoaded) { + _log.info("[_onImageLoaded] Pre-loading nearby images"); + if (index > 0) { + final prevFile = widget.streamFiles[index - 1]; + if (file_util.isSupportedImageFormat(prevFile)) { + ImageViewer.preloadImage(widget.account, prevFile); + } + } + if (index + 1 < widget.streamFiles.length) { + final nextFile = widget.streamFiles[index + 1]; + if (file_util.isSupportedImageFormat(nextFile)) { + ImageViewer.preloadImage(widget.account, nextFile); + } + } + setState(() { + _pageStates[index]!.hasLoaded = true; + _isViewerLoaded = true; + }); + } + } + + void _onVideoLoaded(int index) { + if (_viewerController.currentPage == index && + !_pageStates[index]!.hasLoaded) { + setState(() { + _pageStates[index]!.hasLoaded = true; + _isViewerLoaded = true; + }); + } + } + + void _onVideoPlay() { + setState(() { + _isShowVideoControl = false; + }); + } + + void _onVideoPause() { + setState(() { + _isShowVideoControl = true; + }); + } + + Future _delete(BuildContext context) async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_delete] Removing file: ${file.path}"); + var controller = SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).deleteProcessingNotification), + duration: k.snackBarDurationShort, + )); + controller?.closed.whenComplete(() { + controller = null; + }); + try { + final fileRepo = FileRepo(FileCachedDataSource()); + await Remove(fileRepo, null)(widget.account, file); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.of(context).deleteSuccessNotification), + duration: k.snackBarDurationNormal, + )); + Navigator.of(context).pop(); + } catch (e, stacktrace) { + _log.shout( + "[_delete] Failed while remove" + + (kDebugMode ? ": ${file.path}" : ""), + e, + stacktrace); + controller?.close(); + SnackBarManager().showSnackBar(SnackBar( + content: Text("${L10n.of(context).deleteFailureNotification}: " + "${exception_util.toUserString(e, context)}"), + duration: k.snackBarDurationNormal, + )); + } + } + + bool get _canSwitchPage => !_isZoomed; + + var _isShowVideoControl = true; + var _isZoomed = false; + + final _viewerController = HorizontalPageViewerController(); + bool _isViewerLoaded = false; + final _pageStates = {}; + + static final _log = Logger("widget.trashbin_viewer._TrashbinViewerState"); + + static const _viewportFraction = 1.05; +} + +class _PageState { + bool hasLoaded = false; +} + +enum _AppBarMenuOption { + delete, +}