refactor(dialogs): unify text input contract
This commit is contained in:
@@ -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());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user