diff --git a/app/lib/download_handler.dart b/app/lib/download_handler.dart index 28c658ff..95b87096 100644 --- a/app/lib/download_handler.dart +++ b/app/lib/download_handler.dart @@ -157,13 +157,13 @@ class _DownloadHandlerWeb extends _DownloadHandlerBase { String? parentDir, }) async { _log.info("[downloadFiles] Downloading ${files.length} file"); - var controller = SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().downloadProcessingNotification), - duration: k.snackBarDurationShort, - )); - controller?.closed.whenComplete(() { - controller = null; - }); + SnackBarManager().showSnackBar( + SnackBar( + content: Text(L10n.global().downloadProcessingNotification), + duration: k.snackBarDurationShort, + ), + canBeReplaced: true, + ); int successCount = 0; for (final f in files) { try { @@ -175,7 +175,6 @@ class _DownloadHandlerWeb extends _DownloadHandlerBase { ++successCount; } on PermissionException catch (_) { _log.warning("[downloadFiles] Permission not granted"); - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global().errorNoStoragePermission), duration: k.snackBarDurationNormal, @@ -186,7 +185,6 @@ class _DownloadHandlerWeb extends _DownloadHandlerBase { break; } catch (e, stackTrace) { _log.shout("[downloadFiles] Failed while DownloadFile", e, stackTrace); - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text("${L10n.global().downloadFailureNotification}: " "${exception_util.toUserString(e)}"), @@ -195,7 +193,6 @@ class _DownloadHandlerWeb extends _DownloadHandlerBase { } } if (successCount > 0) { - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global().downloadSuccessNotification), duration: k.snackBarDurationShort, diff --git a/app/lib/notified_action.dart b/app/lib/notified_action.dart index 0974ac0d..88e7258a 100644 --- a/app/lib/notified_action.dart +++ b/app/lib/notified_action.dart @@ -14,25 +14,22 @@ class NotifiedAction { }); Future call() async { - ScaffoldFeatureController? controller; if (processingText != null) { - controller = SnackBarManager().showSnackBar(SnackBar( - content: Text(processingText!), - duration: k.snackBarDurationShort, - )); + SnackBarManager().showSnackBar( + SnackBar( + content: Text(processingText!), + duration: k.snackBarDurationShort, + ), + canBeReplaced: true, + ); } - controller?.closed.whenComplete(() { - controller = null; - }); try { await action(); - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(successText), duration: k.snackBarDurationNormal, )); } catch (e) { - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text( (failureText?.isNotEmpty == true ? "$failureText: " : "") + @@ -67,16 +64,15 @@ class NotifiedListAction { /// Perform the action and return the success count Future call() async { - ScaffoldFeatureController? controller; if (processingText != null) { - controller = SnackBarManager().showSnackBar(SnackBar( - content: Text(processingText!), - duration: k.snackBarDurationShort, - )); + SnackBarManager().showSnackBar( + SnackBar( + content: Text(processingText!), + duration: k.snackBarDurationShort, + ), + canBeReplaced: true, + ); } - controller?.closed.whenComplete(() { - controller = null; - }); final failedItems = []; for (final item in list) { try { @@ -87,7 +83,6 @@ class NotifiedListAction { } } if (failedItems.isEmpty) { - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(successText), duration: k.snackBarDurationNormal, @@ -95,7 +90,6 @@ class NotifiedListAction { } else { final failureText = getFailureText?.call(failedItems); if (failureText?.isNotEmpty == true) { - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(failureText!), duration: k.snackBarDurationNormal, diff --git a/app/lib/share_handler.dart b/app/lib/share_handler.dart index a9cb50a9..444982eb 100644 --- a/app/lib/share_handler.dart +++ b/app/lib/share_handler.dart @@ -166,21 +166,19 @@ class ShareHandler { return; } _log.info("[_shareAsLink] Share as folder: ${result.albumName}"); - ScaffoldFeatureController? controller = - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().createShareProgressText), - duration: k.snackBarDurationShort, - )); - controller?.closed.whenComplete(() { - controller = null; - }); + SnackBarManager().showSnackBar( + SnackBar( + content: Text(L10n.global().createShareProgressText), + duration: k.snackBarDurationShort, + ), + canBeReplaced: true, + ); clearSelection?.call(); isSelectionCleared = true; final c = KiwiContainer().resolve(); final path = await _createDir(c.fileRepo, account, result.albumName); await _copyFilesToDir(c.fileRepo, account, files, path); - controller?.close(); return _shareFileAsLink( account, File( diff --git a/app/lib/snack_bar_manager.dart b/app/lib/snack_bar_manager.dart index 689c5184..8255294f 100644 --- a/app/lib/snack_bar_manager.dart +++ b/app/lib/snack_bar_manager.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -9,7 +11,8 @@ import 'package:logging/logging.dart'; class SnackBarManager { factory SnackBarManager() => _inst; - SnackBarManager._(); + @visibleForTesting + SnackBarManager.scoped(); void registerHandler(SnackBarHandler handler) { _handlers.add(handler); @@ -19,42 +22,69 @@ class SnackBarManager { _handlers.remove(handler); } - /// Show a snack bar if possible + /// Queue a snack bar to be shown ASAP /// - /// If the snack bar can't be shown at this time, return null. + /// If the snack bar can't be shown, return null. /// /// If [canBeReplaced] is true, this snackbar will be dismissed by the next /// snack bar - ScaffoldFeatureController? showSnackBar( + void showSnackBar( SnackBar snackBar, { bool canBeReplaced = false, }) { - if (_canPrevBeReplaced) { - _prevController?.close(); + _add(_Item(snackBar, canBeReplaced)); + _ensureRunning(); + } + + void _ensureRunning() { + if (!_isRunning) { + _isRunning = true; + _next(); } - _canPrevBeReplaced = canBeReplaced; + } + + void _add(_Item item) { + _queue.add(item); + if (_currentItem?.canBeReplaced == true) { + _currentItem!.controller?.close(); + } + } + + Future _next() async { + if (_queue.isEmpty) { + _isRunning = false; + return; + } + final item = _queue.removeFirst(); + if (item.canBeReplaced && _queue.isNotEmpty) { + _log.info("[_next] Skip replaceable snack bar"); + return _next(); + } + // show item for (final h in _handlers.reversed) { - final result = h.showSnackBar(snackBar); - if (result != null) { - _prevController = result; - result.closed.whenComplete(() { - if (identical(_prevController, result)) { - _prevController = null; - } - }); - return result; + final controller = h.showSnackBar(item.snackBar); + if (controller != null) { + item.controller = controller; + _currentItem = item; + try { + final reason = await controller.closed; + _log.fine("[_next] Snack bar closed: ${reason.name}"); + } finally { + _currentItem = null; + } + return _next(); } } - _log.warning("[showSnackBar] No handler available"); - _prevController = null; - return null; + _log.warning("[_next] No handler available"); + return _next(); } final _handlers = []; - ScaffoldFeatureController? _prevController; - var _canPrevBeReplaced = false; + final Queue<_Item> _queue = DoubleLinkedQueue(); + var _isRunning = false; + _Item? _currentItem; - static final _inst = SnackBarManager._(); + static final _inst = SnackBarManager.scoped(); static final _log = Logger("snack_bar_manager.SnackBarManager"); } @@ -63,3 +93,11 @@ abstract class SnackBarHandler { ScaffoldFeatureController? showSnackBar( SnackBar snackBar); } + +class _Item { + _Item(this.snackBar, this.canBeReplaced); + + final SnackBar snackBar; + final bool canBeReplaced; + ScaffoldFeatureController? controller; +} diff --git a/app/lib/widget/trashbin_viewer.dart b/app/lib/widget/trashbin_viewer.dart index f68e4d13..f10bf5b3 100644 --- a/app/lib/widget/trashbin_viewer.dart +++ b/app/lib/widget/trashbin_viewer.dart @@ -157,17 +157,16 @@ class _TrashbinViewerState extends State { void _onRestorePressed() async { final file = widget.streamFiles[_viewerController.currentPage]; _log.info("[_onRestorePressed] Restoring file: ${file.path}"); - var controller = SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().restoreProcessingNotification), - duration: k.snackBarDurationShort, - )); - controller?.closed.whenComplete(() { - controller = null; - }); + SnackBarManager().showSnackBar( + SnackBar( + content: Text(L10n.global().restoreProcessingNotification), + duration: k.snackBarDurationShort, + ), + canBeReplaced: true, + ); try { await RestoreTrashbin(KiwiContainer().resolve())( widget.account, file); - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(L10n.global().restoreSuccessNotification), duration: k.snackBarDurationNormal, @@ -178,7 +177,6 @@ class _TrashbinViewerState extends State { } catch (e, stacktrace) { _log.shout("Failed while restore trashbin: ${logFilename(file.path)}", e, stacktrace); - controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text("${L10n.global().restoreFailureNotification}: " "${exception_util.toUserString(e)}"), diff --git a/app/test/snack_bar_manager_test.dart b/app/test/snack_bar_manager_test.dart new file mode 100644 index 00000000..56ca4a99 --- /dev/null +++ b/app/test/snack_bar_manager_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; + +void main() { + group("SnackBarManager", () { + group("showSnackBar", () { + testWidgets("canBeReplaced = true", (tester) async { + final manager = SnackBarManager.scoped(); + await tester.pumpWidget(_TestWidget(manager)); + manager.showSnackBar( + const SnackBar( + content: Text("test1"), + duration: Duration(seconds: 10), + ), + canBeReplaced: true, + ); + await tester.pumpAndSettle(); + expect(find.text("test1"), findsOneWidget); + + manager.showSnackBar(const SnackBar( + content: Text("test2"), + duration: Duration(seconds: 1), + )); + await tester.pumpAndSettle(); + expect(find.text("test1"), findsNothing); + expect(find.text("test2"), findsOneWidget); + }); + }); + }); +} + +class _TestWidget extends StatefulWidget { + const _TestWidget(this.manager, {Key? key}) : super(key: key); + + @override + createState() => _TestWidgetState(); + + final SnackBarManager manager; +} + +class _TestWidgetState extends State<_TestWidget> implements SnackBarHandler { + @override + initState() { + super.initState(); + widget.manager.registerHandler(this); + } + + @override + build(BuildContext context) { + return MaterialApp( + scaffoldMessengerKey: _scaffoldMessengerKey, + home: Scaffold( + body: Container(), + ), + ); + } + + @override + dispose() { + super.dispose(); + widget.manager.unregisterHandler(this); + } + + @override + showSnackBar(SnackBar snackBar) => + _scaffoldMessengerKey.currentState?.showSnackBar(snackBar); + + final _scaffoldMessengerKey = GlobalKey(); +}