mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
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:
parent
7d5ca654a7
commit
68a776f23b
@ -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>({
|
||||
|
@ -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();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -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)
|
||||
|
@ -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)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
@ -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
121
lib/widgets/toast.dart
Executable 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user