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 {
|
||||
final nameController = TextEditingController();
|
||||
final valueController = TextEditingController();
|
||||
final valueFocusNode = FocusNode();
|
||||
final saveFocusNode = FocusNode();
|
||||
var selectedType = CustomPlayerType.command;
|
||||
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 result = await showDialog<_CustomPlayerDialogResult>(
|
||||
context: context,
|
||||
builder: (_) => const _AddCustomPlayerDialog(),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
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!;
|
||||
await svc.write(SettingsService.customExternalPlayers, [
|
||||
@@ -232,3 +134,101 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
||||
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,
|
||||
VoidCallback? onDispose,
|
||||
}) {
|
||||
final saveFocusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: contentBuilder(dialogContext, context, setDialogState, saveFocusNode),
|
||||
actions: [
|
||||
...?leadingActionsBuilder?.call(dialogContext, setDialogState),
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
final shouldClose = await onSave(dialogContext);
|
||||
if (shouldClose && dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
saveFocusNode.dispose();
|
||||
onDispose?.call();
|
||||
builder: (_) => _SettingsInputDialog(
|
||||
title: title,
|
||||
contentBuilder: contentBuilder,
|
||||
onSave: onSave,
|
||||
leadingActionsBuilder: leadingActionsBuilder,
|
||||
onDispose: onDispose,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SettingsInputDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final _SettingsDialogContentBuilder contentBuilder;
|
||||
final Future<bool> Function(BuildContext dialogContext) onSave;
|
||||
final _SettingsDialogActionsBuilder? leadingActionsBuilder;
|
||||
final VoidCallback? onDispose;
|
||||
|
||||
const _SettingsInputDialog({
|
||||
required this.title,
|
||||
required this.contentBuilder,
|
||||
required this.onSave,
|
||||
this.leadingActionsBuilder,
|
||||
this.onDispose,
|
||||
});
|
||||
|
||||
@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.
|
||||
|
||||
@@ -302,47 +302,10 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
|
||||
}
|
||||
|
||||
Future<void> _renameRoom(RecentRoom room) async {
|
||||
final controller = TextEditingController(text: room.name ?? '');
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameField');
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameCancel');
|
||||
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();
|
||||
}
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _RenameRoomDialog(room: room),
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
|
||||
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 {
|
||||
final RecentRoom room;
|
||||
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