Update snackbars and options dialogs.

* Re-implement snackbar to always be floating, always on top.
* Use a dialog for Options instead of bottom sheet.
This commit is contained in:
Dain Nilsson 2022-07-05 12:11:43 +02:00
parent 7d5ca654a7
commit 68a776f23b
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
9 changed files with 226 additions and 151 deletions

View File

@ -1,64 +1,46 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/toast.dart';
import 'models.dart';
import 'state.dart';
ScaffoldFeatureController showMessage(
void Function() showMessage(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 1),
}) {
final width = MediaQuery.of(context).size.width;
final narrow = width < 540;
return ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
duration: duration,
behavior: narrow ? SnackBarBehavior.fixed : SnackBarBehavior.floating,
width: narrow ? null : 400,
));
}
Duration duration = const Duration(seconds: 2),
}) =>
showToast(context, message, duration: duration);
Future<void> showBottomMenu(
BuildContext context, List<MenuAction> actions) async {
MediaQuery? mediaQuery = context.findAncestorWidgetOfExactType<MediaQuery>();
var width = mediaQuery?.data.size.width ?? 0;
await showModalBottomSheet(
await showBlurDialog(
context: context,
constraints: width > 540 ? const BoxConstraints(maxWidth: 380) : null,
builder: (context) => SafeArea(child: _BottomMenu(actions)));
}
class _BottomMenu extends ConsumerWidget {
final List<MenuAction> actions;
const _BottomMenu(this.actions);
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
Navigator.of(context).pop();
});
return Column(
mainAxisSize: MainAxisSize.min,
children: actions
.map((a) => ListTile(
leading: a.icon,
title: Text(a.text),
enabled: a.action != null,
onTap: a.action == null
? null
: () {
Navigator.pop(context);
a.action?.call(context);
},
))
.toList(),
);
}
builder: (context) {
return AlertDialog(
title: const Text('Options'),
contentPadding: const EdgeInsets.only(bottom: 24, top: 4),
content: Column(
mainAxisSize: MainAxisSize.min,
children: actions
.map((a) => ListTile(
leading: a.icon,
title: Text(a.text),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
enabled: a.action != null,
onTap: a.action == null
? null
: () {
Navigator.pop(context);
a.action?.call(context);
},
))
.toList(),
),
);
});
}
Future<T?> showBlurDialog<T>({

View File

@ -49,7 +49,7 @@ class AppFailurePage extends ConsumerWidget {
icon: const Icon(Icons.lock_open),
style: AppTheme.primaryOutlinedButtonStyle(context),
onPressed: () async {
final controller = showMessage(
final closeMessage = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
@ -59,7 +59,7 @@ class AppFailurePage extends ConsumerWidget {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
closeMessage();
}
}),
];

View File

@ -30,7 +30,7 @@ class DeviceErrorScreen extends ConsumerWidget {
label: const Text('Unlock'),
icon: const Icon(Icons.lock_open),
onPressed: () async {
final controller = showMessage(
final closeMessage = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
@ -40,7 +40,7 @@ class DeviceErrorScreen extends ConsumerWidget {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
closeMessage();
}
},
),

View File

@ -180,7 +180,7 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
context,
'Reconfiguring YubiKey...',
duration: const Duration(seconds: 8),
).close;
);
}
await ref
.read(managementStateProvider(widget.deviceData.node.path).notifier)

View File

@ -8,7 +8,6 @@ import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/dialog_frame.dart';
import '../models.dart';
import 'account_mixin.dart';
@ -123,68 +122,66 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
},
child: Focus(
autofocus: true,
child: DialogFrame(
child: AlertDialog(
title: Center(
child: Text(
title,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
softWrap: false,
),
child: AlertDialog(
title: Center(
child: Text(
title,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
softWrap: false,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (subtitle != null)
Text(
subtitle!,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.caption!.color,
),
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: CardTheme.of(context).color,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: IconTheme(
data: IconTheme.of(context).copyWith(size: 24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: buildCodeView(ref),
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (subtitle != null)
Text(
subtitle!,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.caption!.color,
),
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: CardTheme.of(context).color,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: IconTheme(
data: IconTheme.of(context).copyWith(size: 24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: buildCodeView(ref),
),
),
),
),
),
],
),
actionsPadding: const EdgeInsets.only(top: 10.0, right: -16.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, ref)),
),
)
),
],
),
actionsPadding: const EdgeInsets.only(top: 10.0, right: -16.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, ref)),
),
)
],
),
),
);

View File

@ -58,6 +58,7 @@ class AccountView extends ConsumerWidget with AccountMixin {
child: ListTile(
leading: e.icon,
title: Text(e.text),
enabled: action != null,
dense: true,
contentPadding: EdgeInsets.zero,
),

View File

@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
class DialogFrame extends StatelessWidget {
final Widget child;
const DialogFrame({super.key, required this.child});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
// Shows Snackbars above modal
child: Scaffold(
backgroundColor: Colors.transparent,
body: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTap: () {}, // Block onTap of parent gesture detector
child: child,
),
),
);
}

View File

@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'dialog_frame.dart';
class ResponsiveDialog extends StatefulWidget {
final Widget? title;
final Widget child;
@ -48,25 +46,23 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
final cancelText = widget.onCancel == null && widget.actions.isEmpty
? 'Close'
: 'Cancel';
return DialogFrame(
child: AlertDialog(
title: widget.title,
scrollable: true,
content: SizedBox(
width: 380,
child: Container(key: _childKey, child: widget.child),
),
actions: [
TextButton(
child: Text(cancelText),
onPressed: () {
widget.onCancel?.call();
Navigator.of(context).pop();
},
),
...widget.actions
],
return AlertDialog(
title: widget.title,
scrollable: true,
content: SizedBox(
width: 380,
child: Container(key: _childKey, child: widget.child),
),
actions: [
TextButton(
child: Text(cancelText),
onPressed: () {
widget.onCancel?.call();
Navigator.of(context).pop();
},
),
...widget.actions
],
);
}
}));

121
lib/widgets/toast.dart Executable file
View File

@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter/material.dart';
class Toast extends StatefulWidget {
final String message;
final Duration duration;
final void Function() onComplete;
final Color? backgroundColor;
final TextStyle? textStyle;
const Toast(
this.message,
this.duration, {
required this.onComplete,
this.backgroundColor,
this.textStyle,
super.key,
});
@override
State<StatefulWidget> createState() => _ToastState();
}
class _ToastState extends State<Toast> with SingleTickerProviderStateMixin {
late AnimationController _animator;
late Tween<double> _tween;
late Animation<double> _opacity;
@override
void initState() {
super.initState();
_animator = AnimationController(
duration: const Duration(milliseconds: 100), vsync: this);
_tween = Tween(begin: 0, end: 1);
_opacity = _tween.animate(_animator)
..addListener(() {
setState(() {});
});
_animate();
}
void _animate() async {
await _animator.forward();
if (mounted) {
await Future.delayed(widget.duration);
}
if (mounted) {
await _animator.reverse();
}
widget.onComplete();
}
@override
void dispose() {
_animator.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: Material(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
color: widget.backgroundColor,
child: Center(
child: Text(
widget.message,
style: widget.textStyle,
)),
),
);
}
}
void Function() showToast(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 2),
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bool isThemeDark = theme.brightness == Brightness.dark;
final Color backgroundColor = isThemeDark
? colorScheme.onSurface
: Color.alphaBlend(
colorScheme.onSurface.withOpacity(0.80), colorScheme.surface);
final textStyle =
ThemeData(brightness: isThemeDark ? Brightness.light : Brightness.dark)
.textTheme
.subtitle1;
OverlayEntry? entry;
entry = OverlayEntry(builder: (context) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 50,
width: 400,
margin: const EdgeInsets.all(8),
child: Toast(
message,
duration,
backgroundColor: backgroundColor,
textStyle: textStyle,
onComplete: () {
entry!.remove();
},
),
),
);
});
Timer.run(() {
Overlay.of(context)!.insert(entry!);
});
return entry.remove;
}