From d35811621964b3f69b77fe29946e204904b2b011 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 9 May 2022 22:20:55 +0900 Subject: [PATCH] #243 snack bar action: fixed countdown reset, fixed trigger after navigation --- lib/widgets/aves_app.dart | 7 +- .../common/action_mixins/entry_storage.dart | 27 +++-- .../common/action_mixins/feedback.dart | 82 +++++++------ lib/widgets/common/basic/circle.dart | 109 ++++++++++++++++++ .../common/action_delegates/album_set.dart | 23 +++- .../viewer/action/entry_action_delegate.dart | 29 ++--- lib/widgets/viewer/video_action_delegate.dart | 33 +++--- 7 files changed, 236 insertions(+), 74 deletions(-) create mode 100644 lib/widgets/common/basic/circle.dart diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index e21d4e03c..4e045aba8 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -43,6 +43,8 @@ import 'package:tuple/tuple.dart'; class AvesApp extends StatefulWidget { final AppFlavor flavor; + static final GlobalKey navigatorKey = GlobalKey(debugLabel: 'app-navigator'); + const AvesApp({ Key? key, required this.flavor, @@ -66,7 +68,6 @@ class _AvesAppState extends State with WidgetsBindingObserver { final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); - final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage(); @@ -117,7 +118,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { final areAnimationsEnabled = s.item2; final themeBrightness = s.item3; return MaterialApp( - navigatorKey: _navigatorKey, + navigatorKey: AvesApp.navigatorKey, home: home, navigatorObservers: _navigatorObservers, builder: (context, child) { @@ -294,7 +295,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; reportService.log('New intent'); - _navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( + AvesApp.navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( settings: const RouteSettings(name: HomePage.routeName), builder: (_) => getFirstPage(intentData: intentData), )); diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 11a0b11be..c88f375d9 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -18,6 +18,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -161,18 +162,30 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { action = SnackBarAction( // TODO TLAD [l10n] key for "RESTORE" label: l10n.entryActionRestore.toUpperCase(), - onPressed: () => move( - context, - moveType: MoveType.fromBin, - entries: movedEntries, - hideShowAction: true, - ), + onPressed: () { + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + move( + context, + moveType: MoveType.fromBin, + entries: movedEntries, + hideShowAction: true, + ); + } + }, ); } } else if (!hideShowAction) { action = SnackBarAction( label: l10n.showButtonLabel, - onPressed: () => _showMovedItems(context, destinationAlbums, movedOps), + onPressed: () { + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + _showMovedItems(context, destinationAlbums, movedOps); + } + }, ); } } diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index ff483618c..a209cc74d 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -7,6 +7,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/accessibility_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/circle.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:flutter/foundation.dart'; @@ -36,10 +37,12 @@ mixin FeedbackMixin { // provide the messenger if feedback happens as the widget is disposed void showFeedbackWithMessenger(BuildContext context, ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) { _getSnackBarDuration(action != null).then((duration) { + final start = DateTime.now(); final snackBarContent = _FeedbackMessage( message: message, progressColor: Theme.of(context).colorScheme.secondary, - duration: action != null ? duration : null, + start: start, + stop: action != null ? start.add(duration) : null, ); if (context.currentRouteName == EntryViewerPage.routeName) { @@ -65,6 +68,7 @@ mixin FeedbackMixin { // the regular snack bar dismiss behavior is confused // because it expects a `Scaffold` in context, // so we manually dimiss the overlay entry + // TODO TLAD [bug] after dismissing the overlay, tapping on the snack bar area makes the overlay visible again notificationOverlayEntry?.dismiss(); action.onPressed(); }, @@ -273,72 +277,84 @@ class _ReportOverlayState extends State> with SingleTickerPr class _FeedbackMessage extends StatefulWidget { final String message; - final Duration? duration; + final DateTime? start, stop; final Color progressColor; const _FeedbackMessage({ Key? key, required this.message, required this.progressColor, - this.duration, + this.start, + this.stop, }) : super(key: key); @override State<_FeedbackMessage> createState() => _FeedbackMessageState(); } -class _FeedbackMessageState extends State<_FeedbackMessage> { - double _percent = 0; - late int _remainingSecs; - Timer? _timer; +class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerProviderStateMixin { + AnimationController? _animationController; + Animation? _remainingDurationMillis; + int? _totalDurationMillis; @override void initState() { super.initState(); - final duration = widget.duration; - if (duration != null) { - _remainingSecs = duration.inSeconds; - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - setState(() => _remainingSecs--); - }); - WidgetsBinding.instance!.addPostFrameCallback((_) => setState(() => _percent = 1.0)); + final start = widget.start; + final stop = widget.stop; + if (start != null && stop != null) { + _totalDurationMillis = stop.difference(start).inMilliseconds; + final remainingDuration = stop.difference(DateTime.now()); + _animationController = AnimationController( + duration: remainingDuration, + vsync: this, + ); + _remainingDurationMillis = IntTween( + begin: remainingDuration.inMilliseconds, + end: 0, + ).animate(CurvedAnimation( + parent: _animationController!, + curve: Curves.linear, + )); + _animationController!.forward(); } } @override void dispose() { - _timer?.cancel(); + _animationController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final text = Text(widget.message); - final duration = widget.duration; final theme = Theme.of(context); final contentTextStyle = theme.snackBarTheme.contentTextStyle ?? ThemeData(brightness: theme.brightness).textTheme.subtitle1; - return duration == null + return _remainingDurationMillis == null ? text : Row( children: [ Expanded(child: text), const SizedBox(width: 16), - CircularPercentIndicator( - percent: _percent, - lineWidth: 2, - radius: 16, - // progress color is provided by the caller, - // because we cannot use the app context theme here - backgroundColor: widget.progressColor, - progressColor: Colors.grey, - animation: true, - animationDuration: duration.inMilliseconds, - center: Text( - '$_remainingSecs', - style: contentTextStyle, - ), - animateFromLastPercent: true, - reverse: true, + AnimatedBuilder( + animation: _remainingDurationMillis!, + builder: (context, child) { + final remainingDurationMillis = _remainingDurationMillis!.value; + return CircularIndicator( + radius: 16, + lineWidth: 2, + percent: remainingDurationMillis / _totalDurationMillis!, + background: Colors.grey, + // progress color is provided by the caller, + // because we cannot use the app context theme here + foreground: widget.progressColor, + center: Text( + '${(remainingDurationMillis / 1000).ceil()}', + style: contentTextStyle, + ), + ); + }, ), ], ); diff --git a/lib/widgets/common/basic/circle.dart b/lib/widgets/common/basic/circle.dart new file mode 100644 index 000000000..b429b70ac --- /dev/null +++ b/lib/widgets/common/basic/circle.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class CircularIndicator extends StatefulWidget { + final double radius, lineWidth, percent; + final Color background, foreground; + final Widget center; + + const CircularIndicator({ + Key? key, + required this.radius, + required this.lineWidth, + required this.percent, + required this.background, + required this.foreground, + required this.center, + }) : super(key: key); + + @override + State createState() => _CircularIndicatorState(); +} + +class _CircularIndicatorState extends State { + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: widget.radius * 2, + child: Stack( + alignment: Alignment.center, + children: [ + Circle( + radius: widget.radius, + lineWidth: widget.lineWidth, + percent: 1.0, + color: widget.background, + ), + Circle( + radius: widget.radius, + lineWidth: widget.lineWidth, + percent: widget.percent, + color: widget.foreground, + ), + widget.center, + ], + ), + ); + } +} + +class Circle extends StatelessWidget { + final double radius, lineWidth, percent; + final Color color; + + const Circle({ + Key? key, + required this.radius, + required this.lineWidth, + required this.percent, + required this.color, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.square(radius), + painter: _CirclePainter( + lineWidth: lineWidth, + radius: radius - lineWidth / 2, + color: color, + percent: percent, + ), + ); + } +} + +class _CirclePainter extends CustomPainter { + final double radius, lineWidth, percent; + final Color color; + + const _CirclePainter({ + required this.radius, + required this.lineWidth, + required this.percent, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint() + ..style = PaintingStyle.stroke + ..color = color + ..strokeWidth = lineWidth; + + canvas.translate(center.dx, center.dy); + canvas.rotate(-pi / 2); + canvas.drawArc( + Rect.fromCircle(center: Offset.zero, radius: radius), + 0, + 2 * pi * percent, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 72330a527..51b6ab0a9 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; @@ -173,9 +174,25 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with final showAction = SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { - // assume Album page is still the current page when action is triggered - final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)); - context.read().trackItem(FilterGridItem(filter, null), highlightItem: filter); + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + final highlightInfo = context.read(); + final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)); + if (context.currentRouteName == AlbumListPage.routeName) { + highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); + } else { + highlightInfo.set(filter); + await Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: AlbumListPage.routeName), + builder: (_) => const AlbumListPage(), + ), + (route) => false, + ); + } + } }, ); showFeedback(context, context.l10n.genericSuccessFeedback, showAction); diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 10decf912..b5b477cad 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -16,6 +16,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -259,24 +260,26 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix source.refreshUris(newUris); final l10n = context.l10n; - final navigator = Navigator.of(context); final showAction = isMainMode && newUris.isNotEmpty ? SnackBarAction( label: l10n.showButtonLabel, onPressed: () { - // `context` may be obsolete if the user navigated away before triggering the action - // so we reused the navigator retrieved before showing the snack bar - navigator.pushAndRemoveUntil( - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - highlightTest: (entry) => newUris.contains(entry.uri), + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + highlightTest: (entry) => newUris.contains(entry.uri), + ), ), - ), - (route) => false, - ); + (route) => false, + ); + } }, ) : null; diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 3af7d520b..d2cfb7e7b 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -7,6 +7,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -110,26 +111,28 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final l10n = context.l10n; if (success) { final _collection = collection; - final navigator = Navigator.of(context); final showAction = _collection != null ? SnackBarAction( label: l10n.showButtonLabel, onPressed: () { - final source = _collection.source; - final newUri = newFields['uri'] as String?; - // `context` may be obsolete if the user navigated away before triggering the action - // so we reused the navigator retrieved before showing the snack bar - navigator.pushAndRemoveUntil( - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, - highlightTest: (entry) => entry.uri == newUri, + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + final source = _collection.source; + final newUri = newFields['uri'] as String?; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + highlightTest: (entry) => entry.uri == newUri, + ), ), - ), - (route) => false, - ); + (route) => false, + ); + } }, ) : null;