#243 snack bar action: fixed countdown reset, fixed trigger after navigation

This commit is contained in:
Thibault Deckers 2022-05-09 22:20:55 +09:00
parent 4759fd52f2
commit d358116219
7 changed files with 236 additions and 74 deletions

View file

@ -43,6 +43,8 @@ import 'package:tuple/tuple.dart';
class AvesApp extends StatefulWidget { class AvesApp extends StatefulWidget {
final AppFlavor flavor; final AppFlavor flavor;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');
const AvesApp({ const AvesApp({
Key? key, Key? key,
required this.flavor, required this.flavor,
@ -66,7 +68,6 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events'); final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage(); Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
@ -117,7 +118,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final areAnimationsEnabled = s.item2; final areAnimationsEnabled = s.item2;
final themeBrightness = s.item3; final themeBrightness = s.item3;
return MaterialApp( return MaterialApp(
navigatorKey: _navigatorKey, navigatorKey: AvesApp.navigatorKey,
home: home, home: home,
navigatorObservers: _navigatorObservers, navigatorObservers: _navigatorObservers,
builder: (context, child) { builder: (context, child) {
@ -294,7 +295,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
reportService.log('New intent'); reportService.log('New intent');
_navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( AvesApp.navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute(
settings: const RouteSettings(name: HomePage.routeName), settings: const RouteSettings(name: HomePage.routeName),
builder: (_) => getFirstPage(intentData: intentData), builder: (_) => getFirstPage(intentData: intentData),
)); ));

View file

@ -18,6 +18,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/collection_page.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
@ -161,18 +162,30 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
action = SnackBarAction( action = SnackBarAction(
// TODO TLAD [l10n] key for "RESTORE" // TODO TLAD [l10n] key for "RESTORE"
label: l10n.entryActionRestore.toUpperCase(), label: l10n.entryActionRestore.toUpperCase(),
onPressed: () => move( onPressed: () {
context, // local context may be deactivated when action is triggered after navigation
moveType: MoveType.fromBin, final context = AvesApp.navigatorKey.currentContext;
entries: movedEntries, if (context != null) {
hideShowAction: true, move(
), context,
moveType: MoveType.fromBin,
entries: movedEntries,
hideShowAction: true,
);
}
},
); );
} }
} else if (!hideShowAction) { } else if (!hideShowAction) {
action = SnackBarAction( action = SnackBarAction(
label: l10n.showButtonLabel, 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);
}
},
); );
} }
} }

View file

@ -7,6 +7,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/accessibility_service.dart'; import 'package:aves/services/accessibility_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -36,10 +37,12 @@ mixin FeedbackMixin {
// provide the messenger if feedback happens as the widget is disposed // provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(BuildContext context, ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) { void showFeedbackWithMessenger(BuildContext context, ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) {
_getSnackBarDuration(action != null).then((duration) { _getSnackBarDuration(action != null).then((duration) {
final start = DateTime.now();
final snackBarContent = _FeedbackMessage( final snackBarContent = _FeedbackMessage(
message: message, message: message,
progressColor: Theme.of(context).colorScheme.secondary, 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) { if (context.currentRouteName == EntryViewerPage.routeName) {
@ -65,6 +68,7 @@ mixin FeedbackMixin {
// the regular snack bar dismiss behavior is confused // the regular snack bar dismiss behavior is confused
// because it expects a `Scaffold` in context, // because it expects a `Scaffold` in context,
// so we manually dimiss the overlay entry // 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(); notificationOverlayEntry?.dismiss();
action.onPressed(); action.onPressed();
}, },
@ -273,72 +277,84 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
class _FeedbackMessage extends StatefulWidget { class _FeedbackMessage extends StatefulWidget {
final String message; final String message;
final Duration? duration; final DateTime? start, stop;
final Color progressColor; final Color progressColor;
const _FeedbackMessage({ const _FeedbackMessage({
Key? key, Key? key,
required this.message, required this.message,
required this.progressColor, required this.progressColor,
this.duration, this.start,
this.stop,
}) : super(key: key); }) : super(key: key);
@override @override
State<_FeedbackMessage> createState() => _FeedbackMessageState(); State<_FeedbackMessage> createState() => _FeedbackMessageState();
} }
class _FeedbackMessageState extends State<_FeedbackMessage> { class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerProviderStateMixin {
double _percent = 0; AnimationController? _animationController;
late int _remainingSecs; Animation<int>? _remainingDurationMillis;
Timer? _timer; int? _totalDurationMillis;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final duration = widget.duration; final start = widget.start;
if (duration != null) { final stop = widget.stop;
_remainingSecs = duration.inSeconds; if (start != null && stop != null) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _totalDurationMillis = stop.difference(start).inMilliseconds;
setState(() => _remainingSecs--); final remainingDuration = stop.difference(DateTime.now());
}); _animationController = AnimationController(
WidgetsBinding.instance!.addPostFrameCallback((_) => setState(() => _percent = 1.0)); duration: remainingDuration,
vsync: this,
);
_remainingDurationMillis = IntTween(
begin: remainingDuration.inMilliseconds,
end: 0,
).animate(CurvedAnimation(
parent: _animationController!,
curve: Curves.linear,
));
_animationController!.forward();
} }
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _animationController?.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final text = Text(widget.message); final text = Text(widget.message);
final duration = widget.duration;
final theme = Theme.of(context); final theme = Theme.of(context);
final contentTextStyle = theme.snackBarTheme.contentTextStyle ?? ThemeData(brightness: theme.brightness).textTheme.subtitle1; final contentTextStyle = theme.snackBarTheme.contentTextStyle ?? ThemeData(brightness: theme.brightness).textTheme.subtitle1;
return duration == null return _remainingDurationMillis == null
? text ? text
: Row( : Row(
children: [ children: [
Expanded(child: text), Expanded(child: text),
const SizedBox(width: 16), const SizedBox(width: 16),
CircularPercentIndicator( AnimatedBuilder(
percent: _percent, animation: _remainingDurationMillis!,
lineWidth: 2, builder: (context, child) {
radius: 16, final remainingDurationMillis = _remainingDurationMillis!.value;
// progress color is provided by the caller, return CircularIndicator(
// because we cannot use the app context theme here radius: 16,
backgroundColor: widget.progressColor, lineWidth: 2,
progressColor: Colors.grey, percent: remainingDurationMillis / _totalDurationMillis!,
animation: true, background: Colors.grey,
animationDuration: duration.inMilliseconds, // progress color is provided by the caller,
center: Text( // because we cannot use the app context theme here
'$_remainingSecs', foreground: widget.progressColor,
style: contentTextStyle, center: Text(
), '${(remainingDurationMillis / 1000).ceil()}',
animateFromLastPercent: true, style: contentTextStyle,
reverse: true, ),
);
},
), ),
], ],
); );

View file

@ -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<CircularIndicator> createState() => _CircularIndicatorState();
}
class _CircularIndicatorState extends State<CircularIndicator> {
@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;
}

View file

@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/action_mixins/entry_storage.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
@ -173,9 +174,25 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
final showAction = SnackBarAction( final showAction = SnackBarAction(
label: context.l10n.showButtonLabel, label: context.l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
// assume Album page is still the current page when action is triggered // local context may be deactivated when action is triggered after navigation
final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)); final context = AvesApp.navigatorKey.currentContext;
context.read<HighlightInfo>().trackItem(FilterGridItem(filter, null), highlightItem: filter); if (context != null) {
final highlightInfo = context.read<HighlightInfo>();
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); showFeedback(context, context.l10n.genericSuccessFeedback, showAction);

View file

@ -16,6 +16,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/theme/durations.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/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
@ -259,24 +260,26 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
source.refreshUris(newUris); source.refreshUris(newUris);
final l10n = context.l10n; final l10n = context.l10n;
final navigator = Navigator.of(context);
final showAction = isMainMode && newUris.isNotEmpty final showAction = isMainMode && newUris.isNotEmpty
? SnackBarAction( ? SnackBarAction(
label: l10n.showButtonLabel, label: l10n.showButtonLabel,
onPressed: () { onPressed: () {
// `context` may be obsolete if the user navigated away before triggering the action // local context may be deactivated when action is triggered after navigation
// so we reused the navigator retrieved before showing the snack bar final context = AvesApp.navigatorKey.currentContext;
navigator.pushAndRemoveUntil( if (context != null) {
MaterialPageRoute( Navigator.pushAndRemoveUntil(
settings: const RouteSettings(name: CollectionPage.routeName), context,
builder: (context) => CollectionPage( MaterialPageRoute(
source: source, settings: const RouteSettings(name: CollectionPage.routeName),
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, builder: (context) => CollectionPage(
highlightTest: (entry) => newUris.contains(entry.uri), source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => newUris.contains(entry.uri),
),
), ),
), (route) => false,
(route) => false, );
); }
}, },
) )
: null; : null;

View file

@ -7,6 +7,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.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; final l10n = context.l10n;
if (success) { if (success) {
final _collection = collection; final _collection = collection;
final navigator = Navigator.of(context);
final showAction = _collection != null final showAction = _collection != null
? SnackBarAction( ? SnackBarAction(
label: l10n.showButtonLabel, label: l10n.showButtonLabel,
onPressed: () { onPressed: () {
final source = _collection.source; // local context may be deactivated when action is triggered after navigation
final newUri = newFields['uri'] as String?; final context = AvesApp.navigatorKey.currentContext;
// `context` may be obsolete if the user navigated away before triggering the action if (context != null) {
// so we reused the navigator retrieved before showing the snack bar final source = _collection.source;
navigator.pushAndRemoveUntil( final newUri = newFields['uri'] as String?;
MaterialPageRoute( Navigator.pushAndRemoveUntil(
settings: const RouteSettings(name: CollectionPage.routeName), context,
builder: (context) => CollectionPage( MaterialPageRoute(
source: source, settings: const RouteSettings(name: CollectionPage.routeName),
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, builder: (context) => CollectionPage(
highlightTest: (entry) => entry.uri == newUri, source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => entry.uri == newUri,
),
), ),
), (route) => false,
(route) => false, );
); }
}, },
) )
: null; : null;