#243 snack bar action: fixed countdown reset, fixed trigger after navigation
This commit is contained in:
parent
4759fd52f2
commit
d358116219
7 changed files with 236 additions and 74 deletions
|
@ -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),
|
||||
));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
109
lib/widgets/common/basic/circle.dart
Normal file
109
lib/widgets/common/basic/circle.dart
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue