#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 {
final AppFlavor flavor;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');
const AvesApp({
Key? key,
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 _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
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();
@ -117,7 +118,7 @@ class _AvesAppState extends State<AvesApp> 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<AvesApp> 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),
));

View file

@ -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);
}
},
);
}
}

View file

@ -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<T> extends State<ReportOverlay<T>> 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<int>? _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,
),
);
},
),
],
);

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/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<AlbumFilter> 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<HighlightInfo>().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<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);

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/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;

View file

@ -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;