diff --git a/example/lib/sources/complete_form.dart b/example/lib/sources/complete_form.dart index 3c41c7ad9..007f2fb84 100644 --- a/example/lib/sources/complete_form.dart +++ b/example/lib/sources/complete_form.dart @@ -26,6 +26,59 @@ class _CompleteFormState extends State { @override Widget build(BuildContext context) { + InfoModalConfig config = InfoModalConfig( + leadingIcon: const Icon( + Icons.post_add_outlined, + size: 40, + ), + description: const Opacity( + opacity: 0.79, + child: Text( + 'Don\'t know the skill? It\'s okay, we will teach you market-style skills too. and you can earn up to 5000 - 6000 per month.', + style: TextStyle( + color: Color(0xFF003B67), + fontSize: 14, + fontFamily: 'PP Pangram Sans', + fontWeight: FontWeight.w700, + ), + ), + ), + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1.30, color: Color(0x1915749D)), + borderRadius: BorderRadius.circular(13), + ), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00FAF9F9), Color(0xFF94B8D2)], + ), + ); + InfoModalConfig config2 = InfoModalConfig( + leadingIcon: const Icon( + Icons.post_add_outlined, + size: 40, + ), + description: const Opacity( + opacity: 0.79, + child: Text( + 'Don\'t know the skill? It\'s okay, we will teach you market-style skills too. ', + style: TextStyle( + color: Color(0xFF003B67), + fontSize: 14, + fontFamily: 'PP Pangram Sans', + fontWeight: FontWeight.w700, + ), + ), + ), + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1.30, color: Color(0x1915749D)), + borderRadius: BorderRadius.circular(13), + ), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00EB467A), Color(0xFFFF976E)]), + ); return SingleChildScrollView( child: Column( children: [ @@ -42,12 +95,28 @@ class _CompleteFormState extends State { 'best_language': 'Dart', 'age': '13', 'gender': 'Male', - 'languages_filter': ['Dart'] + 'languages_filter': ['Dart'], + 'languages_choice': '5000 - 6000', }, skipDisabled: true, child: Column( children: [ const SizedBox(height: 15), + FormBuilderTextField( + autovalidateMode: AutovalidateMode.onUserInteraction, + name: 'text_field', + decoration: const InputDecoration( + //labelText: 'Text Field', + hintText: 'Hint Text', + filled: true, + ), + onChanged: _onChanged, + // valueTransformer: (text) => num.tryParse(text), + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.next, + minLines: 1, + maxLines: null, + ), FormBuilderDateTimePicker( name: 'date', initialEntryMode: DatePickerEntryMode.calendar, @@ -286,37 +355,78 @@ class _CompleteFormState extends State { FormBuilderValidators.maxLength(3), ]), ), - FormBuilderChoiceChip( - autovalidateMode: AutovalidateMode.onUserInteraction, - decoration: const InputDecoration( - labelText: - 'Ok, if I had to choose one language, it would be:'), - name: 'languages_choice', - initialValue: 'Dart', - options: const [ - FormBuilderChipOption( - value: 'Dart', - avatar: CircleAvatar(child: Text('D')), - ), - FormBuilderChipOption( - value: 'Kotlin', - avatar: CircleAvatar(child: Text('K')), + Builder(builder: (context) { + return AlippoSelectionCardGroups( + autovalidateMode: AutovalidateMode.onUserInteraction, + name: 'languages_choice', + // initialValue: 'Java', + padding: + const EdgeInsets.only(top: 20, bottom: 20, left: 50), + expanded: true, + spacing: 20, + selectedLabelStyle: const TextStyle( + color: Color(0xFFFAF9F9), + fontSize: 18, + fontFamily: 'PP Pangram Sans Rounded', + fontWeight: FontWeight.w700, + letterSpacing: -0.36, ), - FormBuilderChipOption( - value: 'Java', - avatar: CircleAvatar(child: Text('J')), + unselectedLabelStyle: const TextStyle( + color: Color(0xFF1A4F76), + fontSize: 18, + fontFamily: 'PP Pangram Sans Rounded', + fontWeight: FontWeight.w400, + letterSpacing: -0.36, ), - FormBuilderChipOption( - value: 'Swift', - avatar: CircleAvatar(child: Text('S')), + selectedCardColor: const Color(0xFF1A4F76), + defaultCardColor: const Color(0xFFFAF9F9), + selectedShape: RoundedRectangleBorder( + side: BorderSide( + width: 2, + color: Colors.black.withOpacity(0.36000001430511475), + ), + borderRadius: BorderRadius.circular(17), ), - FormBuilderChipOption( - value: 'Objective-C', - avatar: CircleAvatar(child: Text('O')), + unselectedShape: RoundedRectangleBorder( + side: BorderSide( + width: 2, + color: Colors.black.withOpacity(0.07000000029802322), + ), + borderRadius: BorderRadius.circular(17), ), - ], - onChanged: _onChanged, - ), + options: [ + SelectionCardOption( + value: '5000 - 6000', + avatar: const Icon(Icons.monetization_on_outlined), + infoModalConfig: config, + ), + SelectionCardOption( + value: 'Kotlin', + avatar: const Icon(Icons.monetization_on_outlined), + infoModalConfig: config2, + ), + SelectionCardOption( + value: 'Java', + avatar: const Icon(Icons.monetization_on_outlined), + infoModalConfig: config, + ), + SelectionCardOption( + value: 'Swift', + avatar: const Icon(Icons.monetization_on_outlined), + infoModalConfig: config2, + ), + SelectionCardOption( + value: 'Objective-C', + avatar: const Icon(Icons.monetization_on_outlined), + infoModalConfig: config, + ), + ], + onChanged: _onChanged, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.minLength(1), + ]), + ); + }), ], ), ), diff --git a/lib/flutter_form_builder.dart b/lib/flutter_form_builder.dart index de3a7c521..ee5d973c4 100644 --- a/lib/flutter_form_builder.dart +++ b/lib/flutter_form_builder.dart @@ -20,3 +20,5 @@ export 'src/widgets/grouped_checkbox.dart'; export 'src/widgets/grouped_radio.dart'; export 'src/options/form_builder_chip_option.dart'; export 'src/options/display_values_enum.dart'; +export 'src/alippo_custom_form_components/fields/alippo_selection_card_group/alippo_selection_card_group.dart'; +export 'src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card_options.dart'; diff --git a/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/alippo_selection_card_group.dart b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/alippo_selection_card_group.dart new file mode 100644 index 000000000..606885380 --- /dev/null +++ b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/alippo_selection_card_group.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; + +import 'comp/selection_card.dart'; + +/// A list of `Chip`s that acts like radio buttons +class AlippoSelectionCardGroups extends FormBuilderFieldDecoration { + /// The list of items the user can select. + final List> options; + + // FilterChip Settings + /// Elevation to be applied on the chip relative to its parent. + /// + /// This controls the size of the shadow below the chip. + /// + /// Defaults to 0. The value is always non-negative. + final double? elevation; + + /// Elevation to be applied on the chip relative to its parent during the + /// press motion. + /// + /// This controls the size of the shadow below the chip. + /// + /// Defaults to 8. The value is always non-negative. + final double? pressElevation; + + /// Color to be used for the chip's background, indicating that it is + /// selected. + final Color? selectedCardColor; + + /// Color to be used for the chip's background indicating that it is disabled. + /// + /// The chip is disabled when [isEnabled] is false, or all three of + /// [SelectableChipAttributes.onSelected], [TappableChipAttributes.onPressed], + /// and [DeletableChipAttributes.onDelete] are null. + /// + /// It defaults to [Colors.black38]. + final Color? disabledColor; + + final Color? defaultCardColor; + + /// Color of the chip's shadow when the elevation is greater than 0 and the + /// chip is selected. + /// + /// The default is [Colors.black]. + final Color? selectedShadowColor; + + /// Color of the chip's shadow when the elevation is greater than 0. + /// + /// The default is [Colors.black]. + final Color? shadowColor; + + /// The [OutlinedBorder] to draw around the chip. + /// + /// Defaults to the shape in the ambient [ChipThemeData]. If the theme + /// shape resolves to null, the default is [StadiumBorder]. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. If it is a [MaterialStateOutlinedBorder], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + final OutlinedBorder? selectedShape; + + final OutlinedBorder? unselectedShape; + + /// The padding around the [label] widget. + /// + /// By default, this is 4 logical pixels at the beginning and the end of the + /// label, and zero on top and bottom. + final EdgeInsets? labelPadding; + + /// The style to be applied to the chip's label. + /// + /// If null, the value of the [ChipTheme]'s [ChipThemeData.labelStyle] is used. + // + /// This only has an effect on widgets that respect the [DefaultTextStyle], + /// such as [Text]. + /// + /// If [labelStyle.color] is a [MaterialStateProperty], [MaterialStateProperty.resolve] + /// is used for the following [MaterialState]s: + /// + /// * [MaterialState.disabled]. + /// * [MaterialState.selected]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.pressed]. + final TextStyle? selectedLabelStyle; + + final TextStyle? unselectedLabelStyle; + + /// The padding between the contents of the chip and the outside [selectedShape]. + /// + /// Defaults to 4 logical pixels on all sides. + final EdgeInsets? padding; + + // Wrap Settings + /// The direction to use as the main axis when wrapping chips. + /// + /// For example, if [direction] is [Axis.horizontal], the default, the + /// children are placed adjacent to one another in a horizontal run until the + /// available horizontal space is consumed, at which point a subsequent + /// children are placed in a new run vertically adjacent to the previous run. + final Axis direction; + + /// How the children within a run should be placed in the main axis. + /// + /// For example, if [alignment] is [WrapAlignment.center], the children in + /// each run are grouped together in the center of their run in the main axis. + /// + /// Defaults to [WrapAlignment.start]. + /// + /// See also: + /// + /// * [runAlignment], which controls how the runs are placed relative to each + /// other in the cross axis. + /// * [crossAxisAlignment], which controls how the children within each run + /// are placed relative to each other in the cross axis. + final WrapAlignment alignment; + + /// How much space to place between children in a run in the main axis. + /// + /// For example, if [spacing] is 10.0, the children will be spaced at least + /// 10.0 logical pixels apart in the main axis. + /// + /// If there is additional free space in a run (e.g., because the wrap has a + /// minimum size that is not filled or because some runs are longer than + /// others), the additional free space will be allocated according to the + /// [alignment]. + /// + /// Defaults to 0.0. + final double spacing; + + final ShapeBorder avatarBorder; + + final bool expanded; + + final Duration? animationDuration; + + final Duration? reverseAnimationDuration; + + final Curve? animationCurve; + + final Curve? reverseAnimationCurve; + + /// Creates a list of `Chip`s that acts like radio buttons + AlippoSelectionCardGroups({ + super.autovalidateMode = AutovalidateMode.disabled, + super.enabled, + super.focusNode, + super.onSaved, + super.validator, + super.decoration, + super.key, + required super.name, + required this.options, + super.initialValue, + super.restorationId, + super.onChanged, + super.valueTransformer, + super.onReset, + this.alignment = WrapAlignment.start, + this.avatarBorder = const CircleBorder(), + this.defaultCardColor, + this.direction = Axis.horizontal, + this.disabledColor, + this.elevation, + this.labelPadding, + this.selectedLabelStyle, + this.unselectedLabelStyle, + this.padding, + this.pressElevation, + this.selectedCardColor, + this.selectedShadowColor, + this.shadowColor, + this.selectedShape, + this.unselectedShape, + this.spacing = 0.0, + this.expanded = false, + this.animationDuration, + this.reverseAnimationDuration, + this.animationCurve, + this.reverseAnimationCurve, + }) : super( + builder: (FormFieldState field) { + final state = field as _AlippoSelectionCardGroupsState; + + return InputDecorator( + decoration: state.decoration, + child: SingleChildScrollView( + child: Column( + children: [ + for (SelectionCardOption option in options) + Column( + children: [ + SelectionCard( + label: option, + selected: field.value == option.value, + onSelected: state.enabled + ? (selected) { + final choice = + selected ? option.value : null; + state.didChange(choice); + } + : null, + avatar: option.avatar, + selectedIconColor: defaultCardColor, + unselectedIconColor: selectedCardColor, + selectedCardColor: selectedCardColor, + defaultCardColor: defaultCardColor, + disabledColor: disabledColor, + shadowColor: shadowColor, + selectedShadowColor: selectedShadowColor, + elevation: elevation, + pressElevation: pressElevation, + selectedLabelStyle: selectedLabelStyle, + unselectedLabelStyle: unselectedLabelStyle, + labelPadding: labelPadding, + padding: padding, + selectedShape: selectedShape, + unselectedShape: unselectedShape, + expanded: expanded, + infoModalConfig: option.infoModalConfig, + ), + if (options.last != option) SizedBox(height: spacing), + ], + ), + ], + ), + ), + ); + }, + ); + + @override + FormBuilderFieldDecorationState, T> + createState() => _AlippoSelectionCardGroupsState(); +} + +class _AlippoSelectionCardGroupsState + extends FormBuilderFieldDecorationState, T> {} diff --git a/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card.dart b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card.dart new file mode 100644 index 000000000..277dc0275 --- /dev/null +++ b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card_options.dart'; + +class SelectionCard extends StatefulWidget { + final Widget? avatar; + + final Widget label; + + final TextStyle? selectedLabelStyle; + + final TextStyle? unselectedLabelStyle; + + final EdgeInsetsGeometry? labelPadding; + + final ValueChanged? onSelected; + + final double? pressElevation; + + final bool selected; + + final Color? disabledColor; + + final Color? selectedCardColor; + + final Color? defaultCardColor; + + final OutlinedBorder? selectedShape; + + final OutlinedBorder? unselectedShape; + + final FocusNode? focusNode; + + final bool autofocus; + + final EdgeInsetsGeometry? padding; + + final double? elevation; + + final Color? shadowColor; + + final Color? selectedShadowColor; + + final Color? selectedIconColor; + + final Color? unselectedIconColor; + + final bool expanded; + + final bool disabled; + + final Duration? animationDuration; + + final Duration? reverseAnimationDuration; + + final Curve? animationCurve; + + final Curve? reverseAnimationCurve; + + /// Configuration for the info modal + final InfoModalConfig? infoModalConfig; + + /// Creates an option for fields with selection options + const SelectionCard({ + super.key, + required this.label, + this.selectedLabelStyle, + this.unselectedLabelStyle, + this.labelPadding, + this.onSelected, + this.pressElevation, + this.selected = false, + this.disabledColor, + this.selectedCardColor, + this.defaultCardColor, + this.selectedShape, + this.unselectedShape, + this.focusNode, + this.autofocus = false, + this.padding, + this.elevation, + this.shadowColor, + this.selectedShadowColor, + this.avatar, + this.selectedIconColor, + this.unselectedIconColor, + this.expanded = false, + this.disabled = false, + this.infoModalConfig, + this.animationDuration, + this.reverseAnimationDuration, + this.animationCurve, + this.reverseAnimationCurve, + }); + + @override + State createState() => _SelectionCardState(); +} + +class _SelectionCardState extends State + with SingleTickerProviderStateMixin { + bool isSelected = false; + AnimationController? _controller; + Animation? _animation; + + Duration get animationDuration => + widget.animationDuration ?? const Duration(milliseconds: 300); + + Duration get reverseAnimationDuration => + widget.reverseAnimationDuration ?? animationDuration; + + Curve get animationCurve => widget.animationCurve ?? Curves.easeInOut; + + Curve get reverseAnimationCurve => + widget.reverseAnimationCurve ?? animationCurve; + + @override + void initState() { + super.initState(); + isSelected = widget.selected; + _controller = AnimationController( + duration: animationDuration, + reverseDuration: reverseAnimationDuration, + vsync: this, + ); + _animation = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( + parent: _controller!, + curve: animationCurve, + reverseCurve: reverseAnimationCurve, + )); + + if (isSelected) { + _controller!.value = 1.0; // If initially selected, show expanded + } + } + + @override + void didUpdateWidget(covariant SelectionCard oldWidget) { + handleAnimation(oldWidget); + super.didUpdateWidget(oldWidget); + } + + Future handleAnimation(SelectionCard oldWidget) async { + if (widget.selected != oldWidget.selected) { + if (oldWidget.selected && !widget.selected) { + await Future.delayed( + reverseAnimationDuration, + ); + } + isSelected = widget.selected; + if (isSelected) { + _controller!.forward(); + } else { + _controller!.reverse(); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget content = AnimatedBuilder( + animation: _animation!, + builder: (_, __) { + return Padding( + padding: widget.padding ?? const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.avatar != null) + IconTheme( + data: IconThemeData( + color: ColorTween( + begin: widget.unselectedIconColor ?? + Theme.of(context) + .colorScheme + .onSurface, // Light theme color + end: (widget.selectedIconColor ?? + Theme.of(context) + .colorScheme + .onPrimary), // Dark theme color + ).evaluate(_animation!)), + child: Padding( + padding: const EdgeInsets.only(right: 12.0), + child: widget.avatar!, + ), + ), + DefaultTextStyle( + style: TextStyleTween( + begin: widget.unselectedLabelStyle, + end: widget.selectedLabelStyle, + ).evaluate(_animation!), + child: widget.label, + ), + ], + ), + ); + }, + ); + final Widget child = widget.expanded + ? Row( + children: [ + Expanded(child: content), + ], + ) + : content; + return AnimatedBuilder( + animation: _animation!, + builder: (_, __) { + return Stack( + children: [ + if (widget.infoModalConfig != null) + Padding( + padding: const EdgeInsets.only(top: 50), + child: SizeTransition( + sizeFactor: _animation!, + axis: Axis.vertical, + child: Transform( + transform: + Matrix4.diagonal3Values(1.0, _animation!.value, 1.0), + alignment: Alignment.topCenter, + child: Opacity( + opacity: _animation!.value, + child: Container( + decoration: ShapeDecoration( + gradient: widget.infoModalConfig!.gradient, + shape: widget.infoModalConfig!.shape, + ), + child: Padding( + padding: const EdgeInsets.only( + top: 32, + left: 20.0, + right: 20.0, + bottom: 20.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + widget.infoModalConfig!.leadingIcon, + const SizedBox(width: 20), + Expanded( + child: widget.infoModalConfig!.description, + ), + ], + ), + ), + ), + ), + ), + ), + ), + Card( + color: ColorTween( + begin: widget.defaultCardColor, // Light theme color + end: widget.selectedCardColor, // Dark theme color + ).evaluate(_animation!) ?? + widget.defaultCardColor, + elevation: widget.elevation, + shadowColor: widget.shadowColor, + shape: widget.selected + ? widget.selectedShape + : widget.unselectedShape ?? widget.selectedShape, + margin: EdgeInsets.zero, + child: InkWell( + splashColor: Colors.transparent, + onTap: () { + if (widget.onSelected != null) { + widget.onSelected!(!widget.selected); + } + }, + child: child, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card_options.dart b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card_options.dart new file mode 100644 index 000000000..dc3ac2ee1 --- /dev/null +++ b/lib/src/alippo_custom_form_components/fields/alippo_selection_card_group/comp/selection_card_options.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/src/form_builder_field_option.dart'; + + + +class InfoModalConfig { + final Widget leadingIcon; + final Widget description; + final ShapeBorder shape; + final Gradient? gradient; + + const InfoModalConfig({ + required this.leadingIcon, + required this.description, + required this.shape, + this.gradient, + }); +} + + +class SelectionCardOption extends FormBuilderFieldOption { + final Widget? avatar; + + final InfoModalConfig? infoModalConfig; + + + /// Creates an option for fields with selection options + const SelectionCardOption({ + super.key, + required super.value, + this.avatar, + this.infoModalConfig, + super.child, + }); + + @override + Widget build(BuildContext context) { + return child ?? Text(value.toString()); + } +}