From a481fc10002aee84203ebb1e78a9918106a9057d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:35:05 +0200 Subject: [PATCH] refactor(dialogs): unify text input contract --- lib/screens/metadata_edit_screen.dart | 4 +- .../settings/atmos_diagnostics_screen.dart | 21 +- lib/screens/settings/settings_utils.dart | 32 --- lib/utils/dialogs.dart | 182 +++++++----------- test/utils/dialogs_test.dart | 126 ++++++++++++ 5 files changed, 211 insertions(+), 154 deletions(-) create mode 100644 test/utils/dialogs_test.dart diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index f62cd81e..c406a5eb 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -106,11 +106,13 @@ class _MetadataEditScreenState extends State { if (draft == null) return; final currentValue = draft.value(field.id) ?? ''; final result = multiline - ? await showMultilineTextInputDialog( + ? await showTextInputDialog( context, title: field.label, labelText: field.label, initialValue: currentValue, + allowEmpty: true, + multiline: true, ) : await showTextInputDialog( context, diff --git a/lib/screens/settings/atmos_diagnostics_screen.dart b/lib/screens/settings/atmos_diagnostics_screen.dart index 4935bb18..a26a6017 100644 --- a/lib/screens/settings/atmos_diagnostics_screen.dart +++ b/lib/screens/settings/atmos_diagnostics_screen.dart @@ -6,12 +6,12 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart'; +import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; -import 'settings_utils.dart'; /// #1300 diagnostics: plays known test signals through a bare AVPlayer (via /// the native AtmosProbe plugin) so a tester can read the receiver's format @@ -145,13 +145,18 @@ class _AtmosDiagnosticsScreenState extends State { icon: Symbols.link_rounded, title: t.settings.atmosTestUrl, subtitle: value.isEmpty ? t.settings.atmosTestUrlDescription : value, - onTap: () => showTextInputDialog( - context: context, - title: t.settings.atmosTestUrl, - labelText: 'URL', - currentValue: value, - onSave: (v) => SettingsService.instance.write(SettingsService.atmosProbeUrl, v), - ), + onTap: () async { + final result = await showTextInputDialog( + context, + title: t.settings.atmosTestUrl, + labelText: 'URL', + initialValue: value, + allowEmpty: true, + ); + if (result != null) { + await SettingsService.instance.write(SettingsService.atmosProbeUrl, result.trim()); + } + }, ), ), ], diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index e657cba2..29b52ad4 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -364,38 +364,6 @@ void _showColorInputDialogTV({ ); } -/// Shows a text input dialog with regex validation and reset-to-default support. -/// Plain single-field text input dialog (dpad/TV friendly via -/// [FocusableTextField]); the free-form sibling of [showRegexInputDialog]. -void showTextInputDialog({ - required BuildContext context, - required String title, - required String labelText, - required String currentValue, - required Future Function(String value) onSave, -}) { - final controller = TextEditingController(text: currentValue); - - _showSettingsInputDialog( - context: context, - title: title, - contentBuilder: (_, _, _, saveFocusNode) { - return FocusableTextField( - controller: controller, - decoration: InputDecoration(labelText: labelText), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () => saveFocusNode.requestFocus(), - ); - }, - onSave: (_) async { - await onSave(controller.text.trim()); - return true; - }, - onDispose: controller.dispose, - ); -} - void showRegexInputDialog({ required BuildContext context, required String title, diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 313ab115..1a268f51 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -124,19 +124,24 @@ Future showDeleteConfirmation( ); } -/// Shows a text input dialog for creating/naming items -/// Returns the entered text, or null if cancelled +/// Shows a text input dialog and returns validated submitted text. +/// +/// Returns `null` when the dialog is cancelled or dismissed. Validation errors +/// are shown in the field and keep the dialog open, so a non-null result always +/// represents an explicit, valid submission. Future showTextInputDialog( BuildContext context, { required String title, required String labelText, - required String hintText, + String? hintText, String? initialValue, String? confirmText, TextInputType? keyboardType, List? inputFormatters, String? Function(String)? validator, bool allowEmpty = false, + bool multiline = false, + bool obscureText = false, }) { return showScopedDialog( context: context, @@ -150,112 +155,24 @@ Future showTextInputDialog( inputFormatters: inputFormatters, validator: validator, allowEmpty: allowEmpty, + multiline: multiline, + obscureText: obscureText, ), ); } -/// Shows a multiline text input dialog for editing longer text like summaries. -/// Returns the entered text, or null if cancelled. -/// Allows empty text to be submitted (for clearing fields). -Future showMultilineTextInputDialog( - BuildContext context, { - required String title, - required String labelText, - String? initialValue, -}) { - return showScopedDialog( - context: context, - builder: (context) => _MultilineTextInputDialog(title: title, labelText: labelText, initialValue: initialValue), - ); -} - -/// Shared lifecycle for the two private text-input dialogs below: a single -/// [TextEditingController] seeded from [initialValue], plus a focus node for -/// the save button. -mixin _TextInputDialogStateMixin on State, ControllerDisposerMixin { - late final TextEditingController _controller; - final _fieldFocusNode = FocusNode(); - final _cancelFocusNode = FocusNode(); - final _saveFocusNode = FocusNode(); - - String? get initialValue; - - @override - void initState() { - super.initState(); - _controller = createTextEditingController(text: initialValue); - } - - @override - void dispose() { - _fieldFocusNode.dispose(); - _cancelFocusNode.dispose(); - _saveFocusNode.dispose(); - super.dispose(); - } -} - -class _MultilineTextInputDialog extends StatefulWidget { - final String title; - final String labelText; - final String? initialValue; - - const _MultilineTextInputDialog({required this.title, required this.labelText, this.initialValue}); - - @override - State<_MultilineTextInputDialog> createState() => _MultilineTextInputDialogState(); -} - -class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> - with ControllerDisposerMixin, _TextInputDialogStateMixin<_MultilineTextInputDialog> { - @override - String? get initialValue => widget.initialValue; - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: SizedBox( - width: 400, - child: FocusableTextField( - controller: _controller, - focusNode: _fieldFocusNode, - autofocus: true, - decoration: InputDecoration(labelText: widget.labelText), - keyboardType: TextInputType.multiline, - maxLines: 8, - minLines: 3, - onNavigateDown: _saveFocusNode.requestFocus, - ), - ), - actions: [ - DialogActionButton( - focusNode: _cancelFocusNode, - onPressed: () => Navigator.pop(context), - onNavigateRight: _saveFocusNode.requestFocus, - label: t.common.cancel, - ), - DialogActionButton( - onPressed: () => Navigator.pop(context, _controller.text), - label: t.common.save, - focusNode: _saveFocusNode, - onNavigateLeft: _cancelFocusNode.requestFocus, - ), - ], - ); - } -} - class _TextInputDialog extends StatefulWidget { final String title; final String labelText; - final String hintText; + final String? hintText; final String? initialValue; final String? confirmText; final TextInputType? keyboardType; final List? inputFormatters; final String? Function(String)? validator; final bool allowEmpty; + final bool multiline; + final bool obscureText; const _TextInputDialog({ required this.title, @@ -267,39 +184,60 @@ class _TextInputDialog extends StatefulWidget { this.inputFormatters, this.validator, this.allowEmpty = false, - }); + this.multiline = false, + this.obscureText = false, + }) : assert(!multiline || !obscureText, 'A text input dialog cannot be both multiline and obscure.'); @override State<_TextInputDialog> createState() => _TextInputDialogState(); } -class _TextInputDialogState extends State<_TextInputDialog> - with ControllerDisposerMixin, _TextInputDialogStateMixin<_TextInputDialog> { +class _TextInputDialogState extends State<_TextInputDialog> with ControllerDisposerMixin { + late final TextEditingController _controller; + final _fieldFocusNode = FocusNode(debugLabel: 'TextInputField'); + final _cancelFocusNode = FocusNode(debugLabel: 'TextInputCancel'); + final _saveFocusNode = FocusNode(debugLabel: 'TextInputSave'); + String? _errorText; + @override - String? get initialValue => widget.initialValue; + void initState() { + super.initState(); + _controller = createTextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _fieldFocusNode.dispose(); + _cancelFocusNode.dispose(); + _saveFocusNode.dispose(); + super.dispose(); + } + + String? _validate(String text) { + if (text.isEmpty && !widget.allowEmpty) return t.addServer.required; + return widget.validator?.call(text); + } void _submit() { final text = _controller.text; - if (text.isEmpty && !widget.allowEmpty) return; - if (widget.validator != null && widget.validator!(text) != null) return; + final errorText = _validate(text); + if (errorText != null) { + setState(() => _errorText = errorText); + return; + } Navigator.pop(context, text); } + void _handleChanged(String text) { + if (_errorText == null) return; + setState(() => _errorText = _validate(text)); + } + @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), - content: FocusableTextField( - controller: _controller, - focusNode: _fieldFocusNode, - autofocus: true, - decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText), - keyboardType: widget.keyboardType, - inputFormatters: widget.inputFormatters, - textInputAction: TextInputAction.done, - onNavigateDown: _saveFocusNode.requestFocus, - onSubmitted: (_) => _saveFocusNode.requestFocus(), - ), + content: widget.multiline ? SizedBox(width: 400, child: textField) : textField, actions: [ DialogActionButton( focusNode: _cancelFocusNode, @@ -316,6 +254,24 @@ class _TextInputDialogState extends State<_TextInputDialog> ], ); } + + Widget get textField { + return FocusableTextField( + controller: _controller, + focusNode: _fieldFocusNode, + autofocus: true, + decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText), + keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null), + inputFormatters: widget.inputFormatters, + textInputAction: widget.multiline ? TextInputAction.newline : TextInputAction.done, + obscureText: widget.obscureText, + maxLines: widget.multiline ? 8 : 1, + minLines: widget.multiline ? 3 : 1, + onChanged: _handleChanged, + onNavigateDown: _saveFocusNode.requestFocus, + onSubmitted: widget.multiline ? null : (_) => _saveFocusNode.requestFocus(), + ); + } } /// Shows a simple option picker dialog with focusable items for TV/keyboard navigation. diff --git a/test/utils/dialogs_test.dart b/test/utils/dialogs_test.dart new file mode 100644 index 00000000..8a0c72e6 --- /dev/null +++ b/test/utils/dialogs_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/dialogs.dart'; +import 'package:plezy/utils/platform_detector.dart'; + +void main() { + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); + }); + + testWidgets('text input dialog returns submitted text', (tester) async { + final hostContext = await _pumpHost(tester); + final result = showTextInputDialog(hostContext, title: 'Name', labelText: 'Name'); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'New name'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + await expectLater(result, completion('New name')); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('text input dialog returns null when cancelled', (tester) async { + final hostContext = await _pumpHost(tester); + final result = showTextInputDialog(hostContext, title: 'Name', labelText: 'Name'); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + await expectLater(result, completion(isNull)); + }); + + testWidgets('text input dialog shows validation errors and stays open', (tester) async { + final hostContext = await _pumpHost(tester); + final result = showTextInputDialog( + hostContext, + title: 'Name', + labelText: 'Name', + validator: (value) => value.length < 3 ? 'Too short' : null, + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'ab'); + await tester.tap(find.text('Save')); + await tester.pump(); + + expect(find.text('Too short'), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'valid'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + await expectLater(result, completion('valid')); + }); + + testWidgets('text input dialog seeds multiline initial value', (tester) async { + final hostContext = await _pumpHost(tester); + final result = showTextInputDialog( + hostContext, + title: 'Summary', + labelText: 'Summary', + initialValue: 'Line one\nLine two', + allowEmpty: true, + multiline: true, + ); + + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, 'Line one\nLine two'); + expect(field.keyboardType, TextInputType.multiline); + expect(field.maxLines, 8); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await expectLater(result, completion('Line one\nLine two')); + }); + + testWidgets('TV back closes keyboard, restores field focus, then cancels dialog', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final hostContext = await _pumpHost(tester); + final result = showTextInputDialog(hostContext, title: 'Name', labelText: 'Name', initialValue: 'TV value'); + + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField, skipOffstage: false)); + 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(field.focusNode?.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await expectLater(result, completion(isNull)); + expect(find.byType(AlertDialog), findsNothing); + }); +} + +Future _pumpHost(WidgetTester tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + return hostContext; +}