diff --git a/packages/apptive_grid_form/CHANGELOG.md b/packages/apptive_grid_form/CHANGELOG.md index d57d2a05..a68007b4 100644 --- a/packages/apptive_grid_form/CHANGELOG.md +++ b/packages/apptive_grid_form/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.4.1 + + - **FIX**: Form Widgets that bring their own layout no longer get a border from the embedding app. `CheckBoxFormWidget`, `AttachmentFormWidget`, `GeolocationFormWidget`, `AddressFormWidget`, `EnumFormWidget`, `EnumCollectionFormWidget` and the date and time fields inside `DateTimeFormWidget` only set `InputDecoration.border`, but an `InputDecorator` picks `enabledBorder`, `disabledBorder`, `focusedBorder`, `errorBorder` or `focusedErrorBorder` depending on the field's state and falls back to `border` only where the matching one is `null`. Since those are filled from the app's `InputDecorationTheme`, an app that styles the individual states – a line in the resting state, for example – drew a box around a checkbox or an option list. All five are now set. + ## 2.4.0 - **FEAT**: `ApptiveGridForm` and `ApptiveGridFormData` take a `labelPosition`. With `ApptiveGridLabelPosition.above` a field's label is shown as a separate `Text` above the field instead of as a floating label inside it, which also makes the field's placeholder visible while it is empty. `labelStyle` styles those labels, defaulting to `InputDecorationTheme.labelStyle` and then `TextTheme.titleMedium`. The default stays `ApptiveGridLabelPosition.floating`, so existing forms are unchanged. diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/address_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/address_form_widget.dart index 4f0a0ada..fa1c31fa 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/address_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/address_form_widget.dart @@ -133,13 +133,14 @@ class _AddressFormWidgetState extends State Provider.value(value: const PermissionManager()), ], builder: (providerContext, __) => InputDecorator( - decoration: widget.component.baseDecoration(context).copyWith( + decoration: widget.component + .baseDecoration(context) + .copyWith( errorText: formState.errorText, contentPadding: EdgeInsets.zero, - border: InputBorder.none, - errorBorder: InputBorder.none, filled: false, - ), + ) + .withoutBorder, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/attachment_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/attachment_form_widget.dart index a0f43402..37a53ddb 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/attachment_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/attachment_form_widget.dart @@ -81,14 +81,15 @@ class _AttachmentFormWidgetState extends State initialValue: widget.component.data, builder: (formState) { return InputDecorator( - decoration: widget.component.baseDecoration(context).copyWith( + decoration: widget.component + .baseDecoration(context) + .copyWith( errorText: formState.errorText, contentPadding: EdgeInsets.zero, - border: InputBorder.none, - errorBorder: InputBorder.none, isDense: true, filled: false, - ), + ) + .withoutBorder, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/check_box_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/check_box_form_widget.dart index a66b0aec..6442de0e 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/check_box_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/check_box_form_widget.dart @@ -43,11 +43,9 @@ class _CheckBoxFormWidgetState extends State helperMaxLines: 100, errorText: state.errorText, contentPadding: EdgeInsets.zero, - border: InputBorder.none, - errorBorder: InputBorder.none, isDense: true, filled: false, - ), + ).withoutBorder, child: ColorFiltered( colorFilter: ColorFilter.mode( Theme.of(context).colorScheme.error, diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/date_time_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/date_time_form_widget.dart index 1e3f0b5a..e2165bc4 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/date_time_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/date_time_form_widget.dart @@ -101,12 +101,11 @@ class _DateTimeFormWidgetState extends State controller: _dateController, decoration: InputDecoration( hintText: localization.dateTimeFieldDate, - border: InputBorder.none, isDense: true, filled: false, contentPadding: EdgeInsets.zero, enabled: widget.component.enabled, - ), + ).withoutBorder, enabled: widget.component.enabled, ), ), @@ -148,10 +147,9 @@ class _DateTimeFormWidgetState extends State hintText: localization.dateTimeFieldTime, isDense: true, filled: false, - border: InputBorder.none, contentPadding: EdgeInsets.zero, enabled: widget.component.enabled, - ), + ).withoutBorder, enabled: widget.component.enabled, ), ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_collection_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_collection_form_widget.dart index 4ab45429..00e3ab3f 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_collection_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_collection_form_widget.dart @@ -42,12 +42,14 @@ class _EnumCollectionFormWidgetState extends State enabled: widget.component.enabled, builder: (fieldState) { return InputDecorator( - decoration: widget.component.baseDecoration(context).copyWith( + decoration: widget.component + .baseDecoration(context) + .copyWith( errorText: fieldState.errorText, contentPadding: EdgeInsets.zero, filled: false, - border: InputBorder.none, - ), + ) + .withoutBorder, child: Padding( padding: const EdgeInsets.only(top: 0), child: Builder( diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_form_widget.dart index f162bc2c..9f01efb7 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/enum_form_widget.dart @@ -34,12 +34,14 @@ class _EnumFormWidgetState extends State enabled: widget.component.enabled, builder: (fieldState) { return InputDecorator( - decoration: widget.component.baseDecoration(context).copyWith( + decoration: widget.component + .baseDecoration(context) + .copyWith( errorText: fieldState.errorText, contentPadding: EdgeInsets.zero, filled: false, - border: InputBorder.none, - ), + ) + .withoutBorder, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget_helpers.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget_helpers.dart index c81af579..ced249b9 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget_helpers.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget_helpers.dart @@ -1,5 +1,6 @@ import 'package:apptive_grid_form/apptive_grid_form.dart'; -import 'package:flutter/material.dart' show BuildContext, InputDecoration; +import 'package:flutter/material.dart' + show BuildContext, InputBorder, InputDecoration, InputDecorationTheme; /// Extension on [FormComponent] extension FormComponentX on FormComponent { @@ -28,3 +29,33 @@ extension FormComponentX on FormComponent { ); } } + +/// Extension on [InputDecoration] +extension InputDecorationX on InputDecoration { + /// Uses [border] in every state of the field + /// + /// Setting [InputDecoration.border] alone is not enough. An + /// [InputDecorator] picks [InputDecoration.enabledBorder], + /// [InputDecoration.disabledBorder], [InputDecoration.focusedBorder], + /// [InputDecoration.errorBorder] or [InputDecoration.focusedErrorBorder] + /// depending on the field's state and only falls back to + /// [InputDecoration.border] where the matching one is `null`. Since + /// [InputDecoration.applyDefaults] fills the missing ones from the + /// [InputDecorationTheme] of the embedding app, a border set through + /// [InputDecoration.border] is overridden as soon as an app styles the + /// individual states. + InputDecoration borderInEveryState(InputBorder border) => copyWith( + border: border, + enabledBorder: border, + disabledBorder: border, + focusedBorder: border, + errorBorder: border, + focusedErrorBorder: border, + ); + + /// Hides the field's border in every state + /// + /// Use this for Widgets that bring their own layout and must not be boxed in + /// by the [InputDecorationTheme] of the app they are displayed in. + InputDecoration get withoutBorder => borderInEveryState(InputBorder.none); +} diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/geolocation_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/geolocation_form_widget.dart index 0eafb073..254cb4d5 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/geolocation_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/geolocation_form_widget.dart @@ -82,13 +82,14 @@ class _GeolocationFormWidgetState extends State Provider.value(value: const PermissionManager()), ], builder: (_, __) => InputDecorator( - decoration: widget.component.baseDecoration(context).copyWith( + decoration: widget.component + .baseDecoration(context) + .copyWith( errorText: formState.errorText, contentPadding: EdgeInsets.zero, - border: InputBorder.none, - errorBorder: InputBorder.none, filled: false, - ), + ) + .withoutBorder, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/packages/apptive_grid_form/pubspec.yaml b/packages/apptive_grid_form/pubspec.yaml index 3234e92a..303d425a 100644 --- a/packages/apptive_grid_form/pubspec.yaml +++ b/packages/apptive_grid_form/pubspec.yaml @@ -1,6 +1,6 @@ name: apptive_grid_form description: A Flutter Package to display ApptiveGrid Forms inside a Flutter App. -version: 2.4.0 +version: 2.4.1 homepage: https://www.apptivegrid.de repository: https://github.com/ApptiveGrid/apptive_grid_flutter/tree/main/packages/apptive_grid_form diff --git a/packages/apptive_grid_form/test/border_suppression_test.dart b/packages/apptive_grid_form/test/border_suppression_test.dart new file mode 100644 index 00000000..4cfb04b2 --- /dev/null +++ b/packages/apptive_grid_form/test/border_suppression_test.dart @@ -0,0 +1,215 @@ +import 'package:apptive_grid_form/apptive_grid_form.dart'; +import 'package:apptive_grid_form/src/widgets/apptive_grid_form_widgets.dart'; +import 'package:apptive_grid_form/src/widgets/form_widget/form_widget_helpers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'common.dart'; + +/// An app theme that styles every border state, like an embedding app may do +/// +/// This is what used to leak into Widgets that only set +/// [InputDecoration.border] to [InputBorder.none]. +const _visible = OutlineInputBorder(borderSide: BorderSide(width: 3)); +final _themeWithBorders = ThemeData( + inputDecorationTheme: const InputDecorationTheme( + border: _visible, + enabledBorder: _visible, + disabledBorder: _visible, + focusedBorder: _visible, + errorBorder: _visible, + focusedErrorBorder: _visible, + ), +); + +/// Every border an [InputDecorator] can pick, by the state it is used for +Map _borders(InputDecoration decoration) => { + 'border (fallback)': decoration.border, + 'enabledBorder': decoration.enabledBorder, + 'disabledBorder': decoration.disabledBorder, + 'focusedBorder': decoration.focusedBorder, + 'errorBorder': decoration.errorBorder, + 'focusedErrorBorder': decoration.focusedErrorBorder, + }; + +/// Asserts that no state of the field draws a border, even after Flutter merged +/// the app's [InputDecorationTheme] into the decoration +void _expectBorderless(InputDecoration decoration, String what) { + final applied = + decoration.applyDefaults(_themeWithBorders.inputDecorationTheme); + _borders(applied).forEach((state, border) { + expect( + border, + equals(InputBorder.none), + reason: '$what still draws a border for $state', + ); + }); +} + +void main() { + group('InputDecorationX', () { + test('withoutBorder covers every state', () { + _expectBorderless(const InputDecoration().withoutBorder, 'withoutBorder'); + }); + + test('borderInEveryState covers every state', () { + final decoration = const InputDecoration().borderInEveryState(_visible); + _borders(decoration.applyDefaults(const InputDecorationTheme())) + .forEach((state, border) { + expect(border, equals(_visible), reason: state); + }); + }); + + test('a plain border does not survive a theme, which is why it is needed', + () { + // Guards the reason the helper exists: this is the behavior that put a + // box around the checkbox in an embedding app + final applied = const InputDecoration(border: InputBorder.none) + .applyDefaults(_themeWithBorders.inputDecorationTheme); + expect(applied.enabledBorder, equals(_visible)); + }); + }); + + group('Form Widgets stay borderless in an app that styles every state', () { + // Each Widget is rendered on its own: the form is a lazy ListView, so + // fields below the fold would never be built + Future pumpField( + WidgetTester tester, + DataType type, + DataEntity data, { + String? componentType, + }) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final field = GridField(id: type.name, name: type.name, type: type); + await tester.pumpWidget( + TestApp( + options: const ApptiveGridOptions( + formWidgetConfigurations: [ + GeolocationFormWidgetConfiguration(placesApiKey: ''), + ], + ), + child: Theme( + data: _themeWithBorders, + child: ApptiveGridFormData( + formData: FormData( + id: 'formId', + title: 'Title', + components: [ + FormComponent( + property: type.name, + data: data, + field: field, + type: componentType, + ), + ], + fields: [field], + links: {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// The [InputDecorator] a Widget builds itself, which is the outermost one + /// below it + InputDecoration groupDecoration(WidgetTester tester, Type widgetType) => + tester + .widget( + find + .descendant( + of: find.byType(widgetType), + matching: find.byType(InputDecorator), + ) + .first, + ) + .decoration; + + // The Widget's own type string, where it decides between a boxed single + // field and a group of options that brings its own layout + final borderlessGroups = { + 'CheckBoxFormWidget': ( + DataType.checkbox, + BooleanDataEntity(false), + CheckBoxFormWidget, + null, + ), + 'AttachmentFormWidget': ( + DataType.attachment, + AttachmentDataEntity(), + AttachmentFormWidget, + null, + ), + 'GeolocationFormWidget': ( + DataType.geolocation, + GeolocationDataEntity(), + GeolocationFormWidget, + null, + ), + 'AddressFormWidget': ( + DataType.address, + AddressDataEntity(), + AddressFormWidget, + null, + ), + 'EnumFormWidget': ( + DataType.singleSelect, + EnumDataEntity(options: {'a', 'b'}), + EnumFormWidget, + 'selectList', + ), + 'EnumCollectionFormWidget': ( + DataType.enumCollection, + EnumCollectionDataEntity(options: {'a', 'b'}), + EnumCollectionFormWidget, + null, + ), + }; + + borderlessGroups.forEach((name, setup) { + testWidgets('$name draws no box around its content', (tester) async { + final (type, data, widgetType, componentType) = setup; + await pumpField(tester, type, data, componentType: componentType); + _expectBorderless(groupDecoration(tester, widgetType), name); + }); + }); + + testWidgets('A plain single select keeps the box the app asked for', + (tester) async { + // The counterpart of the group Widgets: a dropdown is a normal field, so + // suppressing its border would be just as wrong as boxing in a checkbox + await pumpField( + tester, + DataType.singleSelect, + EnumDataEntity(options: {'a', 'b'}), + ); + + final decoration = groupDecoration(tester, EnumFormWidget) + .applyDefaults(_themeWithBorders.inputDecorationTheme); + expect(decoration.enabledBorder, equals(_visible)); + }); + + testWidgets('DateTimeFormWidget keeps its inner fields borderless', + (tester) async { + await pumpField( + tester, + DataType.dateTime, + DateTimeDataEntity(DateTime(2026, 9, 13)), + ); + + final innerFields = find.descendant( + of: find.byType(DateTimeFormWidget), + matching: find.byType(TextField), + ); + expect(tester.widgetList(innerFields), hasLength(2)); + for (var i = 0; i < 2; i++) { + _expectBorderless( + tester.widget(innerFields.at(i)).decoration!, + 'DateTimeFormWidget inner field $i', + ); + } + }); + }); +}