fix(dialogs): own text input resources

This commit is contained in:
edde746
2026-05-31 15:34:00 +02:00
parent 9cfc6c0f4a
commit c6c76d6828
4 changed files with 288 additions and 177 deletions
+106 -106
View File
@@ -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)),
),
],
);
}
}
+56 -30
View File
@@ -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);
});
}