From 3e52581c050f8bcdf72b4c481c7fe605d49baceb Mon Sep 17 00:00:00 2001 From: Ahmed Elsayed Date: Sun, 8 Mar 2026 23:51:08 +0200 Subject: [PATCH 1/2] Add errorBuilder support for DropdownButtonFormField2 --- README.md | 5 ++-- packages/dropdown_button2/CHANGELOG.md | 3 +- .../lib/src/dropdown_button2.dart | 28 +++++++++++++------ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bfd7951..dc023aa 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,9 @@ customize to your needs. | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------- | :------: | | [decoration](https://pub.dev/documentation/dropdown_button2/latest/dropdown_button2/DropdownButtonFormField2/decoration.html) | The decoration of the dropdown button form field | InputDecoration | No | | [onSaved](https://api.flutter.dev/flutter/widgets/FormField/onSaved.html) | Called with the current selected item when the form is saved | FormFieldSetter | No | -| [validator](https://api.flutter.dev/flutter/widgets/FormField/validator.html) | Called to validates if the input is invalid and display error text | FormFieldValidator | No | -| [autovalidateMode](https://api.flutter.dev/flutter/widgets/AutovalidateMode.html) | Used to enable/disable auto validation | AutovalidateMode | No | +| [validator](https://api.flutter.dev/flutter/widgets/FormField/validator.html) | Called to validates if the input is invalid and display error text | FormFieldValidator | No | +| [errorBuilder](https://api.flutter.dev/flutter/widgets/FormField/errorBuilder.html) | Called to display a custom error widget instead of the default error text | Widget Function(BuildContext, String) | No | +| [autovalidateMode](https://api.flutter.dev/flutter/widgets/AutovalidateMode.html) | Used to enable/disable auto validation | AutovalidateMode | No | ## Installation diff --git a/packages/dropdown_button2/CHANGELOG.md b/packages/dropdown_button2/CHANGELOG.md index ac7be5d..8519247 100644 --- a/packages/dropdown_button2/CHANGELOG.md +++ b/packages/dropdown_button2/CHANGELOG.md @@ -1,6 +1,7 @@ ## UNRELEASED -- Upgrade minimum required Flutter SDK version to 3.32.0 +- Upgrade minimum required Flutter SDK version to 3.32.0. +- Add `errorBuilder` support for DropdownButtonFormField2 [Flutter core]. ## 3.0.0 diff --git a/packages/dropdown_button2/lib/src/dropdown_button2.dart b/packages/dropdown_button2/lib/src/dropdown_button2.dart index 1213c25..fabbfb3 100644 --- a/packages/dropdown_button2/lib/src/dropdown_button2.dart +++ b/packages/dropdown_button2/lib/src/dropdown_button2.dart @@ -1067,6 +1067,7 @@ class DropdownButtonFormField2 extends FormField { InputDecoration? decoration, super.onSaved, super.validator, + super.errorBuilder, AutovalidateMode? autovalidateMode, bool? enableFeedback, AlignmentGeometry alignment = AlignmentDirectional.centerStart, @@ -1096,10 +1097,8 @@ class DropdownButtonFormField2 extends FormField { builder: (FormFieldState field) { final _DropdownButtonFormField2State state = field as _DropdownButtonFormField2State; - final InputDecoration decorationArg = decoration ?? const InputDecoration(); - final InputDecoration effectiveDecoration = decorationArg.applyDefaults( - Theme.of(field.context).inputDecorationTheme, - ); + InputDecoration effectiveDecoration = (decoration ?? const InputDecoration()) + .applyDefaults(Theme.of(field.context).inputDecorationTheme); final bool showSelectedItem = items != null && @@ -1121,6 +1120,21 @@ class DropdownButtonFormField2 extends FormField { : effectiveHint != null || effectiveDisabledHint != null; final bool isEmpty = !showSelectedItem && !isHintOrDisabledHintAvailable; + if (field.errorText != null || effectiveDecoration.hintText != null) { + final Widget? error = field.errorText != null && errorBuilder != null + ? errorBuilder(state.context, field.errorText!) + : null; + final String? errorText = error == null ? field.errorText : null; + // Clear the decoration hintText because DropdownButton has its own hint logic. + final String? hintText = effectiveDecoration.hintText != null ? '' : null; + + effectiveDecoration = effectiveDecoration.copyWith( + error: error, + errorText: errorText, + hintText: hintText, + ); + } + final bool hasError = field.hasError || effectiveDecoration.errorText != null || @@ -1161,11 +1175,7 @@ class DropdownButtonFormField2 extends FormField { barrierColor: barrierColor, barrierLabel: barrierLabel, openDropdownListenable: openDropdownListenable, - inputDecoration: effectiveDecoration.copyWith( - errorText: field.errorText, - // Clear the decoration hintText because DropdownButton has its own hint logic. - hintText: effectiveDecoration.hintText != null ? '' : null, - ), + inputDecoration: effectiveDecoration, isEmpty: isEmpty, hasError: hasError, ), From a4172f9de72506e0c4dff591eecaaba607497f34 Mon Sep 17 00:00:00 2001 From: Ahmed Elsayed Date: Mon, 9 Mar 2026 00:16:03 +0200 Subject: [PATCH 2/2] Add error properties tests for DropdownButtonFormField2 --- .../test/dropdown_button2_test.dart | 266 +++++++++++++++++- 1 file changed, 265 insertions(+), 1 deletion(-) diff --git a/packages/dropdown_button2/test/dropdown_button2_test.dart b/packages/dropdown_button2/test/dropdown_button2_test.dart index 89e7c64..9033acd 100644 --- a/packages/dropdown_button2/test/dropdown_button2_test.dart +++ b/packages/dropdown_button2/test/dropdown_button2_test.dart @@ -100,7 +100,9 @@ void main() { testWidgets('inkwell should not go beyond border and cover error message when pressed', ( WidgetTester tester, ) async { - // Regression test for https://github.com/AhmedLSayed9/dropdown_button2/issues/56 + // Regression test for: + // https://github.com/AhmedLSayed9/dropdown_button2/issues/56 + // https://github.com/AhmedLSayed9/dropdown_button2/issues/199 final GlobalKey formKey = GlobalKey(); const errorMessage = 'error_message'; @@ -145,4 +147,266 @@ void main() { }); }, ); + + group( + 'DropdownButtonFormField2 Error Properties', + () { + final List menuItems = List.generate(10, (int index) => index); + final valueListenable = ValueNotifier(menuItems.first); + + final findDropdownButtonFormField = find.byType(DropdownButtonFormField2); + + List> buildItems() { + return menuItems.map>((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(); + } + + InputDecorator getFormFieldInputDecorator(WidgetTester tester) { + final findInputDecorator = find.descendant( + of: findDropdownButtonFormField, + matching: find.byType(InputDecorator), + ); + return tester.widget(findInputDecorator.first); + } + + testWidgets('validator error text is displayed', (WidgetTester tester) async { + final GlobalKey formKey = GlobalKey(); + const errorMessage = 'Please select a value'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => errorMessage, + ), + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + + expect(find.text(errorMessage), findsOneWidget); + }); + + testWidgets('validator returning null shows no error', (WidgetTester tester) async { + final GlobalKey formKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => null, + ), + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + + final inputDecorator = getFormFieldInputDecorator(tester); + expect(inputDecorator.decoration.errorText, isNull); + expect(inputDecorator.decoration.error, isNull); + }); + + testWidgets('autovalidateMode.always validates on first build', ( + WidgetTester tester, + ) async { + int validateCalled = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? value) { + validateCalled++; + return 'Error'; + }, + ), + ), + ), + ), + ); + + expect(validateCalled, 1); + expect(find.text('Error'), findsOneWidget); + }); + + testWidgets('decoration errorStyle is applied to error text', ( + WidgetTester tester, + ) async { + const errorStyle = TextStyle(color: Colors.orange, fontSize: 20); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'Styled error', + decoration: const InputDecoration(errorStyle: errorStyle), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final inputDecorator = getFormFieldInputDecorator(tester); + expect(inputDecorator.decoration.errorStyle, errorStyle); + expect(find.text('Styled error'), findsOneWidget); + }); + + testWidgets('decoration errorMaxLines is respected', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'A very long error message\nthat spans multiple lines', + decoration: const InputDecoration(errorMaxLines: 2), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final inputDecorator = getFormFieldInputDecorator(tester); + expect(inputDecorator.decoration.errorMaxLines, 2); + }); + + testWidgets('widget returned by errorBuilder is shown', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'Required', + errorBuilder: (BuildContext context, String errorText) { + return Text('Custom: $errorText'); + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Custom: Required'), findsOneWidget); + expect(find.text('Required'), findsNothing); + }); + + testWidgets('errorBuilder widget is passed to InputDecorator as error', ( + WidgetTester tester, + ) async { + final GlobalKey formKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => 'Required', + errorBuilder: (BuildContext context, String errorText) { + return Row( + children: [ + const Icon(Icons.error, color: Colors.red), + Text(errorText), + ], + ); + }, + ), + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + + final inputDecorator = getFormFieldInputDecorator(tester); + // When errorBuilder is used, error widget is set instead of errorText. + expect(inputDecorator.decoration.error, isNotNull); + expect(inputDecorator.decoration.errorText, isNull); + expect(find.byIcon(Icons.error), findsOneWidget); + }); + + testWidgets('errorBuilder is not called when there is no error', ( + WidgetTester tester, + ) async { + bool errorBuilderCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => null, + errorBuilder: (BuildContext context, String errorText) { + errorBuilderCalled = true; + return Text(errorText); + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(errorBuilderCalled, isFalse); + }); + }, + ); }