refactor(dialogs): unify text input contract

This commit is contained in:
edde746
2026-07-12 08:42:26 +02:00
parent b7078448ea
commit a481fc1000
5 changed files with 211 additions and 154 deletions
+3 -1
View File
@@ -106,11 +106,13 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
if (draft == null) return;
final currentValue = draft.value<String>(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,
@@ -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<AtmosDiagnosticsScreen> {
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());
}
},
),
),
],
-32
View File
@@ -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<void> 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,
+69 -113
View File
@@ -124,19 +124,24 @@ Future<bool> 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<String?> showTextInputDialog(
BuildContext context, {
required String title,
required String labelText,
required String hintText,
String? hintText,
String? initialValue,
String? confirmText,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
String? Function(String)? validator,
bool allowEmpty = false,
bool multiline = false,
bool obscureText = false,
}) {
return showScopedDialog<String>(
context: context,
@@ -150,112 +155,24 @@ Future<String?> 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<String?> showMultilineTextInputDialog(
BuildContext context, {
required String title,
required String labelText,
String? initialValue,
}) {
return showScopedDialog<String>(
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<T extends StatefulWidget> on State<T>, ControllerDisposerMixin<T> {
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<TextInputFormatter>? 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.
+126
View File
@@ -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<TextField>(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<TextField>(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<BuildContext> _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;
}