diff --git a/example/lib/main.dart b/example/lib/main.dart index 7abd2057d..ede785cd0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:example/sources/conditional_fields.dart'; +import 'package:example/sources/decorated_radio_checkbox.dart'; import 'package:example/sources/dynamic_fields.dart'; import 'package:example/sources/related_fields.dart'; import 'package:flutter/material.dart'; @@ -17,16 +18,19 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( title: 'Flutter FormBuilder Demo', debugShowCheckedModeBanner: false, - localizationsDelegates: [ + localizationsDelegates: const [ FormBuilderLocalizations.delegate, ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, ], supportedLocales: FormBuilderLocalizations.supportedLocales, - home: _HomePage(), + theme: ThemeData.light().copyWith( + appBarTheme: const AppBarTheme() + .copyWith(backgroundColor: Colors.blue.shade200)), + home: const _HomePage(), ); } } @@ -141,6 +145,23 @@ class _HomePage extends StatelessWidget { ); }, ), + const Divider(), + ListTile( + title: const Text('Radio Checkbox itemDecorator'), + trailing: const Icon(Icons.arrow_right_sharp), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return const CodePage( + title: 'ItemDecorators', + child: DecoratedRadioCheckbox(), + ); + }, + ), + ); + }, + ), ], ), ); diff --git a/example/lib/sources/decorated_radio_checkbox.dart b/example/lib/sources/decorated_radio_checkbox.dart new file mode 100644 index 000000000..ecab94c34 --- /dev/null +++ b/example/lib/sources/decorated_radio_checkbox.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; + +/// Demonstrates the use of itemDecorators to wrap a box around selection items +class DecoratedRadioCheckbox extends StatefulWidget { + const DecoratedRadioCheckbox({Key? key}) : super(key: key); + + @override + State createState() => _DecoratedRadioCheckboxState(); +} + +class _DecoratedRadioCheckboxState extends State { + final _formKey = GlobalKey(); + int? option; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: FormBuilder( + key: _formKey, + child: Column( + children: [ + const SizedBox(height: 20), + // this text appears correctly if the textScaler <> 1.0 + const Text( + 'label:column of Widgets itemBorder:true orient:wrap wrapSpacing:5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderCheckboxGroup( + name: 'aCheckboxGroup1', + options: getDemoOptionsWidgets(), + wrapSpacing: 5.0, + itemDecoration: BoxDecoration( + color: Colors.orange.shade200, + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text( + 'label:column of Widgets itemBorder:true orient:wrap wrapSpacing:5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderCheckboxGroup( + name: 'aCheckboxGroup2', + options: getDemoOptionsWidgets(), + wrapSpacing: 5.0, + controlAffinity: ControlAffinity.trailing, + itemDecoration: BoxDecoration( + color: Colors.amber.shade200, + border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text( + 'label:column of Widgets itemBorder:true orient:wrap wrapSpacing: 5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderRadioGroup( + name: 'aRadioGroup1', + options: getDemoOptionsWidgets(), + wrapSpacing: 5.0, + itemDecoration: BoxDecoration( + color: Colors.green.shade200, + border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text('label:value itemBorder:true orient:wrap wrapSpacing:10.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderRadioGroup( + name: 'aRadioGroup2', + options: getDemoOptions(), + wrapSpacing: 10.0, + wrapRunSpacing: 10.0, + decoration: InputDecoration( + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.only(left: 20, top: 40), + labelText: 'hello there', + icon: const Icon(Icons.access_alarm_outlined), + fillColor: Colors.red.shade200), + itemDecoration: BoxDecoration( + color: Colors.blueGrey.shade200, + border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text( + 'itemDecoration:false label:value orient:wrap wrapSpacing:10.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderRadioGroup( + name: 'aRadioGroup3', + options: getDemoOptions(), + wrapSpacing: 10.0, + ), + const SizedBox(height: 20), + const Text('orient:horiz itemBorder:false wrapSpacing:5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderCheckboxGroup( + name: 'aCheckboxGroup3', + options: getDemoOptionsWidgets(), + wrapSpacing: 5.0, + orientation: OptionsOrientation.horizontal, + itemDecoration: BoxDecoration( + color: Colors.grey.shade300, +// border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text('orient:vert itemBorder:true wrapSpacing:5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderCheckboxGroup( + name: 'aCheckboxGroup3', + options: getDemoOptionsWidgets(), + wrapSpacing: 5.0, + orientation: OptionsOrientation.vertical, + itemDecoration: BoxDecoration( + color: Colors.red.shade100, + border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + const SizedBox(height: 20), + const Text( + 'label:w/sizebox orient:vert itemBorder:true wrapSpacing:5.0', + textScaler: TextScaler.linear(1.01)), + FormBuilderRadioGroup( + name: 'aRadioGroup4', + options: getDemoOptionsWidgets(forceMinWidth: 80.0), + wrapSpacing: 5.0, + orientation: OptionsOrientation.vertical, + itemDecoration: BoxDecoration( + color: Colors.lightBlue.shade100, + border: Border.all(color: Colors.blueAccent), + borderRadius: BorderRadius.circular(5.0)), + ), + ], + ), + )); + } + + /// options using column of widgets for the label + /// We can force a min width by creating a sized box so we don't need another parameter + List getDemoOptionsWidgets({forceMinWidth = 0.0}) { + return [ + FormBuilderFieldOption( + value: "airplane", + child: Container( + padding: const EdgeInsets.all(5.0), + child: Column( + children: [ + const Text("Airplane"), + const Icon(Icons.airplanemode_on), + SizedBox(width: forceMinWidth, height: 0.0), + ], + )), + ), + FormBuilderFieldOption( + value: "fire-truck", + child: Container( + padding: const EdgeInsets.all(5.0), + child: Column(children: [ + const Text("Fire Truck"), + const Icon(Icons.fire_truck), + SizedBox(width: forceMinWidth, height: 0.0), + ])), + ), + FormBuilderFieldOption( + value: "bus-alert", + child: Container( + padding: const EdgeInsets.all(5.0), + child: Column(children: [ + const Text("Bus Alert"), + const Icon(Icons.bus_alert), + SizedBox(width: forceMinWidth, height: 0.0), + ])), + ), + FormBuilderFieldOption( + value: "firetruck", + child: Container( + padding: const EdgeInsets.all(5.0), + child: Column(children: [ + const Text("Motorcycle"), + const Icon(Icons.motorcycle), + SizedBox(width: forceMinWidth, height: 0.0), + ])), + ), + ]; + } + + /// opens using just values + List getDemoOptions() { + return const [ + FormBuilderFieldOption( + value: "airplane", + ), + FormBuilderFieldOption( + value: "fire-truck", + ), + FormBuilderFieldOption( + value: "bus-alert", + ), + FormBuilderFieldOption( + value: "firetruck", + ), + ]; + } +} diff --git a/lib/src/fields/form_builder_checkbox_group.dart b/lib/src/fields/form_builder_checkbox_group.dart index 4c7a0f861..7bd4c71d1 100644 --- a/lib/src/fields/form_builder_checkbox_group.dart +++ b/lib/src/fields/form_builder_checkbox_group.dart @@ -25,6 +25,11 @@ class FormBuilderCheckboxGroup extends FormBuilderFieldDecoration> { final ControlAffinity controlAffinity; final OptionsOrientation orientation; + /// A BoxDecoration that is added to each item if provided + /// WrapSpacing is reused for the the padding inside the itemDecoration + /// on the side opposite from the control + final BoxDecoration? itemDecoration; + /// Creates a list of Checkboxes for selecting multiple options FormBuilderCheckboxGroup({ super.key, @@ -60,6 +65,7 @@ class FormBuilderCheckboxGroup extends FormBuilderFieldDecoration> { this.separator, this.controlAffinity = ControlAffinity.leading, this.orientation = OptionsOrientation.wrap, + this.itemDecoration, }) : super( builder: (FormFieldState?> field) { final state = field as _FormBuilderCheckboxGroupState; @@ -93,6 +99,7 @@ class FormBuilderCheckboxGroup extends FormBuilderFieldDecoration> { wrapVerticalDirection: wrapVerticalDirection, separator: separator, controlAffinity: controlAffinity, + itemDecoration: itemDecoration, ), ); }, diff --git a/lib/src/fields/form_builder_radio_group.dart b/lib/src/fields/form_builder_radio_group.dart index 7696480c2..0c6acb983 100644 --- a/lib/src/fields/form_builder_radio_group.dart +++ b/lib/src/fields/form_builder_radio_group.dart @@ -22,6 +22,11 @@ class FormBuilderRadioGroup extends FormBuilderFieldDecoration { final WrapAlignment wrapRunAlignment; final WrapCrossAlignment wrapCrossAxisAlignment; + /// A BoxDecoration that is added to each item if provided + /// WrapSpacing is reused for the the padding inside the itemDecoration + /// on the side opposite from the control + final BoxDecoration? itemDecoration; + /// Creates field to select one value from a list of Radio Widgets FormBuilderRadioGroup({ super.autovalidateMode = AutovalidateMode.disabled, @@ -54,6 +59,7 @@ class FormBuilderRadioGroup extends FormBuilderFieldDecoration { super.valueTransformer, super.onReset, super.restorationId, + this.itemDecoration, }) : super( builder: (FormFieldState field) { final state = field as _FormBuilderRadioGroupState; @@ -84,6 +90,7 @@ class FormBuilderRadioGroup extends FormBuilderFieldDecoration { wrapSpacing: wrapSpacing, wrapTextDirection: wrapTextDirection, wrapVerticalDirection: wrapVerticalDirection, + itemDecoration: itemDecoration, ), ); }, diff --git a/lib/src/widgets/grouped_checkbox.dart b/lib/src/widgets/grouped_checkbox.dart index 1cefe1802..31f977347 100644 --- a/lib/src/widgets/grouped_checkbox.dart +++ b/lib/src/widgets/grouped_checkbox.dart @@ -181,6 +181,12 @@ class GroupedCheckbox extends StatelessWidget { final ControlAffinity controlAffinity; + /// A BoxDecoration that is added to each item if provided + /// [wrapSpacing] is used as inter-item bottom margin for [Orientation.vertical] + /// [wrapSpacing] is used as inter-item right margin for [Orientation.horizontal]. + /// on the side opposite from the control + final BoxDecoration? itemDecoration; + const GroupedCheckbox({ super.key, required this.options, @@ -205,13 +211,14 @@ class GroupedCheckbox extends StatelessWidget { this.separator, this.controlAffinity = ControlAffinity.leading, this.visualDensity, + this.itemDecoration, }); @override Widget build(BuildContext context) { final widgetList = []; for (var i = 0; i < options.length; i++) { - widgetList.add(item(i)); + widgetList.add(buildItem(i)); } Widget finalWidget; if (orientation == OptionsOrientation.vertical) { @@ -249,7 +256,8 @@ class GroupedCheckbox extends StatelessWidget { return finalWidget; } - Widget item(int index) { + /// the composite of all the components for the option at index + Widget buildItem(int index) { final option = options[index]; final optionValue = option.value; final isOptionDisabled = true == disabled?.contains(optionValue); @@ -287,7 +295,7 @@ class GroupedCheckbox extends StatelessWidget { child: option, ); - return Row( + Widget compositeItem = Row( mainAxisSize: MainAxisSize.min, children: [ if (controlAffinity == ControlAffinity.leading) control, @@ -296,5 +304,20 @@ class GroupedCheckbox extends StatelessWidget { if (separator != null && index != options.length - 1) separator!, ], ); + + if (this.itemDecoration != null) { + compositeItem = Container( + decoration: this.itemDecoration, + margin: EdgeInsets.only( + bottom: + orientation == OptionsOrientation.vertical ? wrapSpacing : 0.0, + right: + orientation == OptionsOrientation.horizontal ? wrapSpacing : 0.0, + ), + child: compositeItem, + ); + } + + return compositeItem; } } diff --git a/lib/src/widgets/grouped_radio.dart b/lib/src/widgets/grouped_radio.dart index 16e0ab8b9..7e8e365a6 100644 --- a/lib/src/widgets/grouped_radio.dart +++ b/lib/src/widgets/grouped_radio.dart @@ -172,6 +172,12 @@ class GroupedRadio extends StatefulWidget { final ControlAffinity controlAffinity; + /// A BoxDecoration that is added to each item if provided + /// [wrapSpacing] is used as inter-item bottom margin for [Orientation.vertical] + /// [wrapSpacing] is used as inter-item right margin for [Orientation.horizontal]. + /// on the side opposite from the control + final BoxDecoration? itemDecoration; + const GroupedRadio({ super.key, required this.options, @@ -193,6 +199,7 @@ class GroupedRadio extends StatefulWidget { this.wrapVerticalDirection = VerticalDirection.down, this.separator, this.controlAffinity = ControlAffinity.leading, + this.itemDecoration, }); @override @@ -204,7 +211,7 @@ class _GroupedRadioState extends State> { Widget build(BuildContext context) { final widgetList = []; for (int i = 0; i < widget.options.length; i++) { - widgetList.add(_buildRadioButton(i)); + widgetList.add(buildItem(i)); } switch (widget.orientation) { @@ -239,7 +246,8 @@ class _GroupedRadioState extends State> { } } - Widget _buildRadioButton(int index) { + /// the composite of all the components for the option at index + Widget buildItem(int index) { final option = widget.options[index]; final optionValue = option.value; final isOptionDisabled = true == widget.disabled?.contains(optionValue); @@ -266,7 +274,7 @@ class _GroupedRadioState extends State> { child: option, ); - return Column( + Widget compositeItem = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -288,5 +296,22 @@ class _GroupedRadioState extends State> { widget.separator!, ], ); + + if (widget.itemDecoration != null) { + compositeItem = Container( + decoration: widget.itemDecoration, + margin: EdgeInsets.only( + bottom: widget.orientation == OptionsOrientation.vertical + ? widget.wrapSpacing + : 0.0, + right: widget.orientation == OptionsOrientation.horizontal + ? widget.wrapSpacing + : 0.0, + ), + child: compositeItem, + ); + } + + return compositeItem; } } diff --git a/test/form_builder_checkbox_group_test.dart b/test/form_builder_checkbox_group_test.dart index 20693a125..9982fe34f 100644 --- a/test/form_builder_checkbox_group_test.dart +++ b/test/form_builder_checkbox_group_test.dart @@ -28,6 +28,55 @@ void main() { expect(formSave(), isTrue); expect(formValue(widgetName), equals(const [1, 3])); }); + + testWidgets('FormBuilderCheckboxGroup -- decoration horizontal', + (WidgetTester tester) async { + const widgetName = 'cbg1'; + final testWidget = FormBuilderCheckboxGroup( + name: widgetName, + orientation: OptionsOrientation.horizontal, + wrapSpacing: 10.0, + options: const [ + FormBuilderFieldOption(key: ValueKey('1'), value: 1), + FormBuilderFieldOption(key: ValueKey('2'), value: 2), + ], + itemDecoration: + BoxDecoration(border: Border.all(color: Colors.blueAccent)), + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + // this is a brittle test knowing how we use container for a border + // there is one container for each option + expect(find.byType(Container), findsExactly(2)); + // same as wrapSpacing + Container foo = tester.firstWidget(find.byType(Container)); + expect(foo.margin, const EdgeInsets.only(right: 10.0)); + }); + + testWidgets('FormBuilderCheckboxGroup -- decoration vertical', + (WidgetTester tester) async { + const widgetName = 'cbg1'; + final testWidget = FormBuilderCheckboxGroup( + name: widgetName, + orientation: OptionsOrientation.vertical, + wrapSpacing: 10.0, + options: const [ + FormBuilderFieldOption(key: ValueKey('1'), value: 1), + FormBuilderFieldOption(key: ValueKey('2'), value: 2), + ], + itemDecoration: + BoxDecoration(border: Border.all(color: Colors.blueAccent)), + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + // this is a brittle test knowing how we use container for a border + // there is one container for each option + expect(find.byType(Container), findsExactly(2)); + // same as wrapSpacing + Container foo = tester.firstWidget(find.byType(Container)); + expect(foo.margin, const EdgeInsets.only(bottom: 10.0)); + }); + testWidgets('FormBuilderCheckboxGroup -- didChange', (WidgetTester tester) async { const fieldName = 'cbg1'; diff --git a/test/form_builder_radio_group_test.dart b/test/form_builder_radio_group_test.dart index f39cf1278..80bfae791 100644 --- a/test/form_builder_radio_group_test.dart +++ b/test/form_builder_radio_group_test.dart @@ -27,4 +27,52 @@ void main() { expect(formSave(), isTrue); expect(formValue(widgetName), equals(3)); }); + + testWidgets('FormBuilderRadioGroup -- decoration horizontal', + (WidgetTester tester) async { + const widgetName = 'rg1'; + final testWidget = FormBuilderRadioGroup( + name: widgetName, + orientation: OptionsOrientation.horizontal, + wrapSpacing: 10.0, + options: const [ + FormBuilderFieldOption(key: ValueKey('1'), value: 1), + FormBuilderFieldOption(key: ValueKey('2'), value: 2), + ], + itemDecoration: + BoxDecoration(border: Border.all(color: Colors.blueAccent)), + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + // this is a brittle test knowing how we use container for a border + // there is one container for each option + expect(find.byType(Container), findsExactly(2)); + // same as wrapSpacing + Container foo = tester.firstWidget(find.byType(Container)); + expect(foo.margin, const EdgeInsets.only(right: 10.0)); + }); + + testWidgets('FormBuilderRadioGroup -- decoration vertical', + (WidgetTester tester) async { + const widgetName = 'rg1'; + final testWidget = FormBuilderRadioGroup( + name: widgetName, + orientation: OptionsOrientation.vertical, + wrapSpacing: 10.0, + options: const [ + FormBuilderFieldOption(key: ValueKey('1'), value: 1), + FormBuilderFieldOption(key: ValueKey('2'), value: 2), + ], + itemDecoration: + BoxDecoration(border: Border.all(color: Colors.blueAccent)), + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + // this is a brittle test knowing how we use container for a border + // there is one container for each option + expect(find.byType(Container), findsExactly(2)); + // same as wrapSpacing + Container foo = tester.firstWidget(find.byType(Container)); + expect(foo.margin, const EdgeInsets.only(bottom: 10.0)); + }); }