fix(dialogs): own text input resources
This commit is contained in:
@@ -116,115 +116,17 @@ class _PlayerTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef _CustomPlayerDialogResult = ({String name, String value, CustomPlayerType type});
|
||||||
|
|
||||||
Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
||||||
final nameController = TextEditingController();
|
final result = await showDialog<_CustomPlayerDialogResult>(
|
||||||
final valueController = TextEditingController();
|
context: context,
|
||||||
final valueFocusNode = FocusNode();
|
builder: (_) => const _AddCustomPlayerDialog(),
|
||||||
final saveFocusNode = FocusNode();
|
);
|
||||||
var selectedType = CustomPlayerType.command;
|
if (result == null) return;
|
||||||
String? playerName;
|
|
||||||
String? playerValue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setDialogState) {
|
|
||||||
final isUrlScheme = selectedType == CustomPlayerType.urlScheme;
|
|
||||||
final String fieldLabel;
|
|
||||||
final String fieldHint;
|
|
||||||
if (isUrlScheme) {
|
|
||||||
fieldLabel = t.externalPlayer.playerUrlScheme;
|
|
||||||
fieldHint = 'myplayer://play?url=';
|
|
||||||
} else if (Platform.isAndroid) {
|
|
||||||
fieldLabel = t.externalPlayer.playerPackage;
|
|
||||||
fieldHint = 'com.example.player';
|
|
||||||
} else {
|
|
||||||
fieldLabel = t.externalPlayer.playerCommand;
|
|
||||||
fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player';
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(t.externalPlayer.addCustomPlayer),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 300,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FocusableTextField(
|
|
||||||
controller: nameController,
|
|
||||||
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
|
||||||
autofocus: true,
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
onSubmitted: (_) => primaryFocus?.nextFocus(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: SegmentedButton<CustomPlayerType>(
|
|
||||||
segments: [
|
|
||||||
ButtonSegment(
|
|
||||||
value: CustomPlayerType.command,
|
|
||||||
label: Text(
|
|
||||||
Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
|
||||||
],
|
|
||||||
selected: {selectedType},
|
|
||||||
onSelectionChanged: (value) => setDialogState(() => selectedType = value.first),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FocusableTextField(
|
|
||||||
controller: valueController,
|
|
||||||
focusNode: valueFocusNode,
|
|
||||||
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
onSubmitted: (_) => saveFocusNode.requestFocus(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
FocusableButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
|
||||||
),
|
|
||||||
FocusableButton(
|
|
||||||
focusNode: saveFocusNode,
|
|
||||||
onPressed: () {
|
|
||||||
if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
|
|
||||||
Navigator.pop(context, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
|
|
||||||
Navigator.pop(context, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(t.common.save),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != true) return;
|
|
||||||
playerName = nameController.text;
|
|
||||||
playerValue = valueController.text;
|
|
||||||
} finally {
|
|
||||||
nameController.dispose();
|
|
||||||
valueController.dispose();
|
|
||||||
valueFocusNode.dispose();
|
|
||||||
saveFocusNode.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
|
final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final newPlayer = ExternalPlayer.custom(id: id, name: playerName, value: playerValue, type: selectedType);
|
final newPlayer = ExternalPlayer.custom(id: id, name: result.name, value: result.value, type: result.type);
|
||||||
|
|
||||||
final svc = SettingsService.instanceOrNull!;
|
final svc = SettingsService.instanceOrNull!;
|
||||||
await svc.write(SettingsService.customExternalPlayers, [
|
await svc.write(SettingsService.customExternalPlayers, [
|
||||||
@@ -232,3 +134,101 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
|||||||
newPlayer,
|
newPlayer,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AddCustomPlayerDialog extends StatefulWidget {
|
||||||
|
const _AddCustomPlayerDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddCustomPlayerDialog> createState() => _AddCustomPlayerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _valueController = TextEditingController();
|
||||||
|
final _valueFocusNode = FocusNode(debugLabel: 'CustomExternalPlayerValue');
|
||||||
|
final _saveFocusNode = FocusNode(debugLabel: 'CustomExternalPlayerSave');
|
||||||
|
CustomPlayerType _selectedType = CustomPlayerType.command;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_valueFocusNode.dispose();
|
||||||
|
_saveFocusNode.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
_valueController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
final value = _valueController.text.trim();
|
||||||
|
if (name.isEmpty || value.isEmpty) return;
|
||||||
|
Navigator.pop(context, (name: name, value: value, type: _selectedType));
|
||||||
|
}
|
||||||
|
|
||||||
|
({String label, String hint}) get _valueFieldInfo {
|
||||||
|
if (_selectedType == CustomPlayerType.urlScheme) {
|
||||||
|
return (label: t.externalPlayer.playerUrlScheme, hint: 'myplayer://play?url=');
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return (label: t.externalPlayer.playerPackage, hint: 'com.example.player');
|
||||||
|
}
|
||||||
|
return (label: t.externalPlayer.playerCommand, hint: Platform.isMacOS ? 'mpv' : '/usr/bin/player');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final (:label, :hint) = _valueFieldInfo;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(t.externalPlayer.addCustomPlayer),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 300,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FocusableTextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => _valueFocusNode.requestFocus(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SegmentedButton<CustomPlayerType>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(
|
||||||
|
value: CustomPlayerType.command,
|
||||||
|
label: Text(Platform.isAndroid ? t.externalPlayer.playerPackage : t.externalPlayer.playerCommand),
|
||||||
|
),
|
||||||
|
ButtonSegment(value: CustomPlayerType.urlScheme, label: Text(t.externalPlayer.playerUrlScheme)),
|
||||||
|
],
|
||||||
|
selected: {_selectedType},
|
||||||
|
onSelectionChanged: (value) => setState(() => _selectedType = value.first),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FocusableTextField(
|
||||||
|
controller: _valueController,
|
||||||
|
focusNode: _valueFocusNode,
|
||||||
|
decoration: InputDecoration(labelText: label, hintText: hint),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _saveFocusNode.requestFocus(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FocusableButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||||
|
),
|
||||||
|
FocusableButton(
|
||||||
|
focusNode: _saveFocusNode,
|
||||||
|
onPressed: _submit,
|
||||||
|
child: FilledButton(onPressed: _submit, child: Text(t.common.save)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,38 +36,64 @@ void _showSettingsInputDialog({
|
|||||||
_SettingsDialogActionsBuilder? leadingActionsBuilder,
|
_SettingsDialogActionsBuilder? leadingActionsBuilder,
|
||||||
VoidCallback? onDispose,
|
VoidCallback? onDispose,
|
||||||
}) {
|
}) {
|
||||||
final saveFocusNode = FocusNode();
|
showDialog<void>(
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (_) => _SettingsInputDialog(
|
||||||
return StatefulBuilder(
|
title: title,
|
||||||
builder: (context, setDialogState) {
|
contentBuilder: contentBuilder,
|
||||||
return AlertDialog(
|
onSave: onSave,
|
||||||
title: Text(title),
|
leadingActionsBuilder: leadingActionsBuilder,
|
||||||
content: contentBuilder(dialogContext, context, setDialogState, saveFocusNode),
|
onDispose: onDispose,
|
||||||
actions: [
|
),
|
||||||
...?leadingActionsBuilder?.call(dialogContext, setDialogState),
|
);
|
||||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
}
|
||||||
DialogActionButton(
|
|
||||||
focusNode: saveFocusNode,
|
class _SettingsInputDialog extends StatefulWidget {
|
||||||
onPressed: () async {
|
final String title;
|
||||||
final shouldClose = await onSave(dialogContext);
|
final _SettingsDialogContentBuilder contentBuilder;
|
||||||
if (shouldClose && dialogContext.mounted) {
|
final Future<bool> Function(BuildContext dialogContext) onSave;
|
||||||
Navigator.pop(dialogContext);
|
final _SettingsDialogActionsBuilder? leadingActionsBuilder;
|
||||||
}
|
final VoidCallback? onDispose;
|
||||||
},
|
|
||||||
label: t.common.save,
|
const _SettingsInputDialog({
|
||||||
),
|
required this.title,
|
||||||
],
|
required this.contentBuilder,
|
||||||
);
|
required this.onSave,
|
||||||
},
|
this.leadingActionsBuilder,
|
||||||
);
|
this.onDispose,
|
||||||
},
|
|
||||||
).then((_) {
|
|
||||||
saveFocusNode.dispose();
|
|
||||||
onDispose?.call();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SettingsInputDialog> createState() => _SettingsInputDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsInputDialogState extends State<_SettingsInputDialog> {
|
||||||
|
final _saveFocusNode = FocusNode(debugLabel: 'SettingsInputSave');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveFocusNode.dispose();
|
||||||
|
widget.onDispose?.call();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final shouldClose = await widget.onSave(context);
|
||||||
|
if (shouldClose && mounted) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: widget.contentBuilder(context, context, setState, _saveFocusNode),
|
||||||
|
actions: [
|
||||||
|
...?widget.leadingActionsBuilder?.call(context, setState),
|
||||||
|
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
|
||||||
|
DialogActionButton(focusNode: _saveFocusNode, onPressed: _save, label: t.common.save),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a selection dialog with focusable rows for dpad/keyboard navigation.
|
/// Shows a selection dialog with focusable rows for dpad/keyboard navigation.
|
||||||
|
|||||||
@@ -302,47 +302,10 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _renameRoom(RecentRoom room) async {
|
Future<void> _renameRoom(RecentRoom room) async {
|
||||||
final controller = TextEditingController(text: room.name ?? '');
|
final name = await showDialog<String>(
|
||||||
final fieldFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameField');
|
context: context,
|
||||||
final cancelFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameCancel');
|
builder: (_) => _RenameRoomDialog(room: room),
|
||||||
final saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameSave');
|
);
|
||||||
String? name;
|
|
||||||
try {
|
|
||||||
name = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(t.watchTogether.renameRoom),
|
|
||||||
content: FocusableTextField(
|
|
||||||
controller: controller,
|
|
||||||
focusNode: fieldFocusNode,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: InputDecoration(hintText: room.code),
|
|
||||||
onNavigateDown: saveFocusNode.requestFocus,
|
|
||||||
onSubmitted: (value) => Navigator.pop(context, value),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
DialogActionButton(
|
|
||||||
focusNode: cancelFocusNode,
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
onNavigateRight: saveFocusNode.requestFocus,
|
|
||||||
label: t.common.cancel,
|
|
||||||
),
|
|
||||||
DialogActionButton(
|
|
||||||
focusNode: saveFocusNode,
|
|
||||||
onPressed: () => Navigator.pop(context, controller.text),
|
|
||||||
onNavigateLeft: cancelFocusNode.requestFocus,
|
|
||||||
isPrimary: true,
|
|
||||||
label: t.common.save,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
controller.dispose();
|
|
||||||
fieldFocusNode.dispose();
|
|
||||||
cancelFocusNode.dispose();
|
|
||||||
saveFocusNode.dispose();
|
|
||||||
}
|
|
||||||
if (name == null || !mounted) return;
|
if (name == null || !mounted) return;
|
||||||
|
|
||||||
await RecentRoomsService.renameRoom(room.code, name.isEmpty ? null : name);
|
await RecentRoomsService.renameRoom(room.code, name.isEmpty ? null : name);
|
||||||
@@ -355,6 +318,65 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RenameRoomDialog extends StatefulWidget {
|
||||||
|
final RecentRoom room;
|
||||||
|
|
||||||
|
const _RenameRoomDialog({required this.room});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RenameRoomDialog> createState() => _RenameRoomDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RenameRoomDialogState extends State<_RenameRoomDialog> {
|
||||||
|
late final _controller = TextEditingController(text: widget.room.name ?? '');
|
||||||
|
final _fieldFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameField');
|
||||||
|
final _cancelFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameCancel');
|
||||||
|
final _saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameSave');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fieldFocusNode.dispose();
|
||||||
|
_cancelFocusNode.dispose();
|
||||||
|
_saveFocusNode.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit(String value) {
|
||||||
|
Navigator.pop(context, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(t.watchTogether.renameRoom),
|
||||||
|
content: FocusableTextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _fieldFocusNode,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(hintText: widget.room.code),
|
||||||
|
onNavigateDown: _saveFocusNode.requestFocus,
|
||||||
|
onSubmitted: _submit,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
DialogActionButton(
|
||||||
|
focusNode: _cancelFocusNode,
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
onNavigateRight: _saveFocusNode.requestFocus,
|
||||||
|
label: t.common.cancel,
|
||||||
|
),
|
||||||
|
DialogActionButton(
|
||||||
|
focusNode: _saveFocusNode,
|
||||||
|
onPressed: () => _submit(_controller.text),
|
||||||
|
onNavigateLeft: _cancelFocusNode.requestFocus,
|
||||||
|
isPrimary: true,
|
||||||
|
label: t.common.save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RecentRoomTile extends StatelessWidget {
|
class _RecentRoomTile extends StatelessWidget {
|
||||||
final RecentRoom room;
|
final RecentRoom room;
|
||||||
final bool isBusy;
|
final bool isBusy;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/screens/settings/settings_utils.dart';
|
||||||
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
tearDown(() {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('settings text input survives TV keyboard back dismissal', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1280, 720));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
late BuildContext hostContext;
|
||||||
|
final saved = <String>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
hostContext = context;
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
showRegexInputDialog(
|
||||||
|
context: hostContext,
|
||||||
|
title: 'Regex',
|
||||||
|
currentValue: 'abc',
|
||||||
|
defaultValue: '.*',
|
||||||
|
onSave: (value) async => saved.add(value),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_dialog')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_dialog')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_dialog')), findsNothing);
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(AlertDialog), findsNothing);
|
||||||
|
expect(saved, isEmpty);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user