diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 8fe2122e..2fb4811a 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -116,115 +116,17 @@ class _PlayerTile extends StatelessWidget { } } +typedef _CustomPlayerDialogResult = ({String name, String value, CustomPlayerType type}); + Future _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( - 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( - 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 _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( + 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)), + ), + ], + ); + } +} diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 97a95009..3dba2370 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -36,38 +36,64 @@ void _showSettingsInputDialog({ _SettingsDialogActionsBuilder? leadingActionsBuilder, VoidCallback? onDispose, }) { - final saveFocusNode = FocusNode(); - - showDialog( + showDialog( 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 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 _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. diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 4a77801c..b098f44f 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -302,47 +302,10 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta } Future _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( - 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( + 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; diff --git a/test/screens/settings/settings_utils_test.dart b/test/screens/settings/settings_utils_test.dart new file mode 100644 index 00000000..daf35933 --- /dev/null +++ b/test/screens/settings/settings_utils_test.dart @@ -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 = []; + + 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); + }); +}