1270 lines
44 KiB
Dart
1270 lines
44 KiB
Dart
library multiselect_dropdown;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:sk_base_mobile/app_theme.dart';
|
|
import 'package:sk_base_mobile/util/screen_adaper_util.dart';
|
|
import './models/network_config.dart';
|
|
import './widgets/hint_text.dart';
|
|
import './widgets/selection_chip.dart';
|
|
import './widgets/single_selected_item.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'models/chip_config.dart';
|
|
import 'models/value_item.dart';
|
|
import 'enum/app_enums.dart';
|
|
|
|
export 'enum/app_enums.dart';
|
|
export 'models/chip_config.dart';
|
|
export 'models/value_item.dart';
|
|
export 'models/network_config.dart';
|
|
|
|
typedef OnOptionSelected<T> = void Function(List<ValueItem<T>> selectedOptions);
|
|
|
|
class MultiSelectDropDown<T> extends StatefulWidget {
|
|
// selection type of the dropdown
|
|
final SelectionType selectionType;
|
|
|
|
// Hint
|
|
final String hint;
|
|
final Color? hintColor;
|
|
final double? hintFontSize;
|
|
final TextStyle? hintStyle;
|
|
final EdgeInsetsGeometry? hintPadding;
|
|
|
|
// Options
|
|
final List<ValueItem<T>> options;
|
|
final List<ValueItem<T>> selectedOptions;
|
|
final List<ValueItem<T>> disabledOptions;
|
|
final OnOptionSelected<T>? onOptionSelected;
|
|
|
|
/// [onOptionRemoved] is the callback that is called when an option is removed.The callback takes two arguments, the index of the removed option and the removed option.
|
|
/// This will be called only when the delete icon is clicked on the option chip.
|
|
///
|
|
/// This will not be called when the option is removed programmatically.
|
|
///
|
|
/// ```index``` is the index of the removed option.
|
|
///
|
|
/// ```option``` is the removed option.
|
|
final void Function(int index, ValueItem<T> option)? onOptionRemoved;
|
|
|
|
// selected option
|
|
final Icon? selectedOptionIcon;
|
|
final Color? selectedOptionTextColor;
|
|
final Color? selectedOptionBackgroundColor;
|
|
final Widget Function(BuildContext, ValueItem<T>)? selectedItemBuilder;
|
|
|
|
// chip configuration
|
|
final bool showChipInSingleSelectMode;
|
|
final ChipConfig chipConfig;
|
|
|
|
// options configuration
|
|
final Color? optionsBackgroundColor;
|
|
final TextStyle? optionTextStyle;
|
|
final double dropdownHeight;
|
|
final Widget? optionSeparator;
|
|
final bool alwaysShowOptionIcon;
|
|
|
|
/// option builder
|
|
/// [optionBuilder] is the builder that is used to build the option item.
|
|
/// The builder takes three arguments, the context, the option and the selected status of the option.
|
|
/// The builder returns a widget.
|
|
///
|
|
|
|
final Widget Function(BuildContext ctx, ValueItem<T> item, bool selected)?
|
|
optionBuilder;
|
|
|
|
// dropdownfield configuration
|
|
final Color? fieldBackgroundColor;
|
|
final Icon suffixIcon;
|
|
final bool animateSuffixIcon;
|
|
final Icon? clearIcon;
|
|
final Decoration? inputDecoration;
|
|
final double? borderRadius;
|
|
final BorderRadiusGeometry? radiusGeometry;
|
|
final Color? borderColor;
|
|
final Color? focusedBorderColor;
|
|
final double? borderWidth;
|
|
final double? focusedBorderWidth;
|
|
final EdgeInsets? padding;
|
|
|
|
final TextStyle? singleSelectItemStyle;
|
|
|
|
final int? maxItems;
|
|
|
|
final Color? dropdownBackgroundColor;
|
|
final Color? searchBackgroundColor;
|
|
|
|
// dropdown border radius
|
|
final double? dropdownBorderRadius;
|
|
final double? dropdownMargin;
|
|
|
|
// network configuration
|
|
final NetworkConfig? networkConfig;
|
|
final Future<List<ValueItem<T>>> Function(dynamic)? responseParser;
|
|
final Widget Function(BuildContext, dynamic)? responseErrorBuilder;
|
|
|
|
/// focus node
|
|
final FocusNode? focusNode;
|
|
|
|
/// Controller for the dropdown
|
|
/// [controller] is the controller for the dropdown. It can be used to programmatically open and close the dropdown.
|
|
final MultiSelectController<T>? controller;
|
|
|
|
/// Enable search
|
|
/// [searchEnabled] is the flag to enable search in dropdown. It is used to show search bar in dropdown.
|
|
final bool searchEnabled;
|
|
|
|
/// Search label
|
|
/// [searchLabel] is the label for search bar in dropdown.
|
|
final String? searchLabel;
|
|
|
|
/// MultiSelectDropDown is a widget that allows the user to select multiple options from a list of options. It is a dropdown that allows the user to select multiple options.
|
|
///
|
|
/// **Selection Type**
|
|
///
|
|
/// [selectionType] is the type of selection that the user can make. The default is [SelectionType.single].
|
|
/// * [SelectionType.single] - allows the user to select only one option.
|
|
/// * [SelectionType.multi] - allows the user to select multiple options.
|
|
///
|
|
/// **Options**
|
|
///
|
|
/// [options] is the list of options that the user can select from. The options need to be of type [ValueItem].
|
|
///
|
|
/// [selectedOptions] is the list of options that are pre-selected when the widget is first displayed. The options need to be of type [ValueItem].
|
|
///
|
|
/// [disabledOptions] is the list of options that the user cannot select. The options need to be of type [ValueItem]. If the items in this list are not available in options, will be ignored.
|
|
///
|
|
/// [onOptionSelected] is the callback that is called when an option is selected or unselected. The callback takes one argument of type `List<ValueItem>`.
|
|
///
|
|
/// **Selected Option**
|
|
///
|
|
/// [selectedOptionIcon] is the icon that is used to indicate the selected option.
|
|
///
|
|
/// [selectedOptionTextColor] is the color of the selected option.
|
|
///
|
|
/// [selectedOptionBackgroundColor] is the background color of the selected option.
|
|
///
|
|
/// [selectedItemBuilder] is the builder that is used to build the selected option. If this is not provided, the default builder is used.
|
|
///
|
|
/// **Chip Configuration**
|
|
///
|
|
/// [showChipInSingleSelectMode] is used to show the chip in single select mode. The default is false.
|
|
///
|
|
/// [chipConfig] is the configuration for the chip.
|
|
///
|
|
/// **Options Configuration**
|
|
///
|
|
/// [optionsBackgroundColor] is the background color of the options. The default is [Colors.white].
|
|
///
|
|
/// [optionTextStyle] is the text style of the options.
|
|
///
|
|
/// [optionSeparator] is the seperator between the options.
|
|
///
|
|
/// [dropdownHeight] is the height of the dropdown options. The default is 200.
|
|
///
|
|
/// **Dropdown Configuration**
|
|
///
|
|
/// [fieldBackgroundColor] is the background color of the dropdown. The default is [Colors.white].
|
|
///
|
|
/// [suffixIcon] is the icon that is used to indicate the dropdown. The default is [Icons.arrow_drop_down].
|
|
///
|
|
/// [inputDecoration] is the decoration of the dropdown.
|
|
///
|
|
/// [dropdownHeight] is the height of the dropdown. The default is 200.
|
|
///
|
|
/// **Hint**
|
|
///
|
|
/// [hint] is the hint text to be displayed when no option is selected.
|
|
///
|
|
/// [hintColor] is the color of the hint text. The default is [Colors.grey.shade300].
|
|
///
|
|
/// [hintFontSize] is the font size of the hint text. The default is 14.0.
|
|
///
|
|
/// [hintStyle] is the style of the hint text.
|
|
///
|
|
/// [animateSuffixIcon] is the flag to enable animation for the suffix icon. The default is true.
|
|
///
|
|
/// **Example**
|
|
///
|
|
/// ```dart
|
|
/// final List<ValueItem> options = [
|
|
/// ValueItem(label: 'Option 1', value: '1'),
|
|
/// ValueItem(label: 'Option 2', value: '2'),
|
|
/// ValueItem(label: 'Option 3', value: '3'),
|
|
/// ];
|
|
///
|
|
/// final List<ValueItem> selectedOptions = [
|
|
/// ValueItem(label: 'Option 1', value: '1'),
|
|
/// ];
|
|
///
|
|
/// final List<ValueItem> disabledOptions = [
|
|
/// ValueItem(label: 'Option 2', value: '2'),
|
|
/// ];
|
|
///
|
|
/// MultiSelectDropDown(
|
|
/// onOptionSelected: (option) {},
|
|
/// options: const <ValueItem>[
|
|
/// ValueItem(label: 'Option 1', value: '1'),
|
|
/// ValueItem(label: 'Option 2', value: '2'),
|
|
/// ValueItem(label: 'Option 3', value: '3'),
|
|
/// ValueItem(label: 'Option 4', value: '4'),
|
|
/// ValueItem(label: 'Option 5', value: '5'),
|
|
/// ValueItem(label: 'Option 6', value: '6'),
|
|
/// ],
|
|
/// selectionType: SelectionType.multi,
|
|
/// selectedOptions: selectedOptions,
|
|
/// disabledOptions: disabledOptions,
|
|
/// onOptionSelected: (List<ValueItem> selectedOptions) {
|
|
/// debugPrint('onOptionSelected: $option');
|
|
/// },
|
|
/// chipConfig: const ChipConfig(wrapType: WrapType.scroll),
|
|
/// );
|
|
/// ```
|
|
|
|
const MultiSelectDropDown(
|
|
{Key? key,
|
|
required this.onOptionSelected,
|
|
required this.options,
|
|
this.onOptionRemoved,
|
|
this.selectedOptionTextColor,
|
|
this.chipConfig = const ChipConfig(),
|
|
this.selectionType = SelectionType.multi,
|
|
this.hint = 'Select',
|
|
this.hintColor = Colors.grey,
|
|
this.hintFontSize = 14.0,
|
|
this.hintPadding = HintText.hintPaddingDefault,
|
|
this.selectedOptions = const [],
|
|
this.disabledOptions = const [],
|
|
this.alwaysShowOptionIcon = false,
|
|
this.optionTextStyle,
|
|
this.selectedOptionIcon = const Icon(Icons.check),
|
|
this.selectedOptionBackgroundColor,
|
|
this.optionsBackgroundColor,
|
|
this.fieldBackgroundColor = Colors.white,
|
|
this.dropdownHeight = 200,
|
|
this.showChipInSingleSelectMode = false,
|
|
this.suffixIcon = const Icon(Icons.arrow_drop_down),
|
|
this.clearIcon = const Icon(Icons.close_outlined, size: 20),
|
|
this.selectedItemBuilder,
|
|
this.optionSeparator,
|
|
this.inputDecoration,
|
|
this.hintStyle,
|
|
this.padding,
|
|
this.focusedBorderColor = Colors.black54,
|
|
this.borderColor = Colors.grey,
|
|
this.borderWidth = 0.4,
|
|
this.focusedBorderWidth = 0.4,
|
|
this.borderRadius = 12.0,
|
|
this.radiusGeometry,
|
|
this.maxItems,
|
|
this.focusNode,
|
|
this.controller,
|
|
this.searchEnabled = false,
|
|
this.dropdownBorderRadius,
|
|
this.dropdownMargin,
|
|
this.dropdownBackgroundColor,
|
|
this.searchBackgroundColor,
|
|
this.animateSuffixIcon = true,
|
|
this.singleSelectItemStyle,
|
|
this.optionBuilder,
|
|
this.searchLabel = 'Search'})
|
|
: networkConfig = null,
|
|
responseParser = null,
|
|
responseErrorBuilder = null,
|
|
super(key: key);
|
|
|
|
/// Constructor for MultiSelectDropDown that fetches the options from a network call.
|
|
/// [networkConfig] is the configuration for the network call.
|
|
/// [responseParser] is the parser that is used to parse the response from the network call.
|
|
/// [responseErrorBuilder] is the builder that is used to build the error widget when the network call fails.
|
|
|
|
const MultiSelectDropDown.network(
|
|
{Key? key,
|
|
required this.onOptionSelected,
|
|
required this.networkConfig,
|
|
required this.responseParser,
|
|
this.onOptionRemoved,
|
|
this.responseErrorBuilder,
|
|
this.selectedOptionTextColor,
|
|
this.chipConfig = const ChipConfig(),
|
|
this.selectionType = SelectionType.multi,
|
|
this.hint = 'Select',
|
|
this.hintColor = Colors.grey,
|
|
this.hintFontSize = 14.0,
|
|
this.selectedOptions = const [],
|
|
this.disabledOptions = const [],
|
|
this.alwaysShowOptionIcon = false,
|
|
this.optionTextStyle,
|
|
this.selectedOptionIcon = const Icon(Icons.check),
|
|
this.selectedOptionBackgroundColor,
|
|
this.optionsBackgroundColor,
|
|
this.fieldBackgroundColor = Colors.white,
|
|
this.dropdownHeight = 200,
|
|
this.showChipInSingleSelectMode = false,
|
|
this.suffixIcon = const Icon(Icons.arrow_drop_down),
|
|
this.clearIcon = const Icon(Icons.close_outlined, size: 14),
|
|
this.selectedItemBuilder,
|
|
this.optionSeparator,
|
|
this.inputDecoration,
|
|
this.hintStyle,
|
|
this.hintPadding = HintText.hintPaddingDefault,
|
|
this.padding,
|
|
this.borderColor = Colors.grey,
|
|
this.focusedBorderColor = Colors.black54,
|
|
this.borderWidth = 0.4,
|
|
this.focusedBorderWidth = 0.4,
|
|
this.borderRadius = 12.0,
|
|
this.radiusGeometry,
|
|
this.maxItems,
|
|
this.focusNode,
|
|
this.controller,
|
|
this.searchEnabled = false,
|
|
this.dropdownBorderRadius,
|
|
this.dropdownMargin,
|
|
this.dropdownBackgroundColor,
|
|
this.searchBackgroundColor,
|
|
this.animateSuffixIcon = true,
|
|
this.singleSelectItemStyle,
|
|
this.optionBuilder,
|
|
this.searchLabel = 'Search'})
|
|
: options = const [],
|
|
super(key: key);
|
|
|
|
@override
|
|
State<MultiSelectDropDown<T>> createState() => _MultiSelectDropDownState<T>();
|
|
}
|
|
|
|
class _MultiSelectDropDownState<T> extends State<MultiSelectDropDown<T>> {
|
|
/// Options list that is used to display the options.
|
|
final List<ValueItem<T>> _options = [];
|
|
|
|
/// Selected options list that is used to display the selected options.
|
|
final List<ValueItem<T>> _selectedOptions = [];
|
|
|
|
/// Disabled options list that is used to display the disabled options.
|
|
final List<ValueItem<T>> _disabledOptions = [];
|
|
|
|
/// The controller for the dropdown.
|
|
OverlayState? _overlayState;
|
|
OverlayEntry? _overlayEntry;
|
|
bool _selectionMode = false;
|
|
|
|
late final FocusNode _focusNode;
|
|
final LayerLink _layerLink = LayerLink();
|
|
|
|
/// Response from the network call.
|
|
dynamic _reponseBody;
|
|
|
|
/// value notifier that is used for controller.
|
|
late MultiSelectController<T> _controller;
|
|
|
|
/// search field focus node
|
|
FocusNode? _searchFocusNode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_initialize();
|
|
});
|
|
_focusNode = widget.focusNode ?? FocusNode();
|
|
_controller = widget.controller ?? MultiSelectController<T>();
|
|
}
|
|
|
|
/// Initializes the options, selected options and disabled options.
|
|
/// If the options are fetched from the network, then the network call is made.
|
|
/// If the options are passed as a parameter, then the options are initialized.
|
|
void _initialize() async {
|
|
if (!mounted) return;
|
|
if (widget.networkConfig?.url != null) {
|
|
await _fetchNetwork();
|
|
} else {
|
|
_options.addAll(_controller.options.isNotEmpty == true
|
|
? _controller.options
|
|
: widget.options);
|
|
}
|
|
_addOptions();
|
|
if (mounted) {
|
|
_initializeOverlay();
|
|
} else {
|
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
|
_initializeOverlay();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _initializeOverlay() {
|
|
_overlayState ??= Overlay.of(context);
|
|
|
|
_focusNode.addListener(_handleFocusChange);
|
|
|
|
if (widget.searchEnabled) {
|
|
_searchFocusNode = FocusNode();
|
|
_searchFocusNode!.addListener(_handleFocusChange);
|
|
}
|
|
}
|
|
|
|
/// Adds the selected options and disabled options to the options list.
|
|
void _addOptions() {
|
|
setState(() {
|
|
_selectedOptions.addAll(_controller.selectedOptions.isNotEmpty == true
|
|
? _controller.selectedOptions
|
|
: widget.selectedOptions);
|
|
_disabledOptions.addAll(_controller.disabledOptions.isNotEmpty == true
|
|
? _controller.disabledOptions
|
|
: widget.disabledOptions);
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_controller._isDisposed == false) {
|
|
_controller.setOptions(_options);
|
|
_controller.setSelectedOptions(_selectedOptions);
|
|
_controller.setDisabledOptions(_disabledOptions);
|
|
|
|
_controller.addListener(_handleControllerChange);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Handles the focus change to show/hide the dropdown.
|
|
void _handleFocusChange() {
|
|
if (_focusNode.hasFocus && mounted) {
|
|
_overlayEntry = _reponseBody != null && widget.networkConfig != null
|
|
? _buildNetworkErrorOverlayEntry()
|
|
: _buildOverlayEntry();
|
|
Overlay.of(context).insert(_overlayEntry!);
|
|
_updateSelection();
|
|
return;
|
|
}
|
|
|
|
if ((_searchFocusNode == null || _searchFocusNode?.hasFocus == false) &&
|
|
_overlayEntry != null) {
|
|
_overlayEntry?.remove();
|
|
}
|
|
|
|
if (mounted) _updateSelection();
|
|
|
|
_controller.value._isDropdownOpen =
|
|
_focusNode.hasFocus || _searchFocusNode?.hasFocus == true;
|
|
}
|
|
|
|
void _updateSelection() {
|
|
setState(() {
|
|
_selectionMode =
|
|
_focusNode.hasFocus || _searchFocusNode?.hasFocus == true;
|
|
});
|
|
}
|
|
|
|
/// Calculate offset size for dropdown.
|
|
List _calculateOffsetSize() {
|
|
RenderBox? renderBox = context.findRenderObject() as RenderBox?;
|
|
|
|
var size = renderBox?.size ?? Size.zero;
|
|
var offset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
|
|
|
final availableHeight = MediaQuery.of(context).size.height - offset.dy;
|
|
|
|
return [size, availableHeight < widget.dropdownHeight];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
enabled: true,
|
|
child: CompositedTransformTarget(
|
|
link: _layerLink,
|
|
child: Focus(
|
|
canRequestFocus: true,
|
|
skipTraversal: true,
|
|
focusNode: _focusNode,
|
|
child: InkWell(
|
|
splashColor: null,
|
|
splashFactory: null,
|
|
onTap: _toggleFocus,
|
|
child: Container(
|
|
height: widget.chipConfig.wrapType == WrapType.wrap ? null : 52,
|
|
constraints: BoxConstraints(
|
|
minWidth: MediaQuery.of(context).size.width,
|
|
minHeight: 52,
|
|
),
|
|
padding: _getContainerPadding(),
|
|
decoration: _getContainerDecoration(),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _getContainerContent(),
|
|
),
|
|
if (widget.clearIcon != null && _anyItemSelected) ...[
|
|
const SizedBox(width: 4),
|
|
InkWell(
|
|
onTap: () => clear(),
|
|
child: widget.clearIcon,
|
|
),
|
|
const SizedBox(width: 4)
|
|
],
|
|
_buildSuffixIcon(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSuffixIcon() {
|
|
if (widget.animateSuffixIcon) {
|
|
return AnimatedRotation(
|
|
turns: _selectionMode ? 0.5 : 0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: widget.suffixIcon,
|
|
);
|
|
}
|
|
return widget.suffixIcon;
|
|
}
|
|
|
|
/// Container Content for the dropdown.
|
|
Widget _getContainerContent() {
|
|
if (_selectedOptions.isEmpty) {
|
|
return HintText(
|
|
hintText: widget.hint,
|
|
hintColor: widget.hintColor,
|
|
hintStyle: widget.hintStyle,
|
|
hintPadding: widget.hintPadding,
|
|
);
|
|
}
|
|
|
|
if (widget.selectionType == SelectionType.single &&
|
|
!widget.showChipInSingleSelectMode) {
|
|
return SingleSelectedItem(
|
|
label: _selectedOptions.first.label,
|
|
style: widget.singleSelectItemStyle);
|
|
}
|
|
|
|
return _buildSelectedItems();
|
|
}
|
|
|
|
/// return true if any item is selected.
|
|
bool get _anyItemSelected => _selectedOptions.isNotEmpty;
|
|
|
|
/// Container decoration for the dropdown.
|
|
Decoration _getContainerDecoration() {
|
|
return widget.inputDecoration ??
|
|
BoxDecoration(
|
|
color: widget.fieldBackgroundColor ?? Colors.white,
|
|
borderRadius: widget.radiusGeometry ??
|
|
BorderRadius.circular(widget.borderRadius ?? 12.0),
|
|
border: _selectionMode
|
|
? Border.all(
|
|
color: widget.focusedBorderColor ?? Colors.grey,
|
|
width: widget.focusedBorderWidth ?? 0.4,
|
|
)
|
|
: Border.all(
|
|
color: widget.borderColor ?? Colors.grey,
|
|
width: widget.borderWidth ?? 0.4,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Dispose the focus node and overlay entry.
|
|
@override
|
|
void dispose() {
|
|
if (_overlayEntry?.mounted == true) {
|
|
if (_overlayState != null && _overlayEntry != null) {
|
|
_overlayEntry?.remove();
|
|
}
|
|
_overlayEntry = null;
|
|
_overlayState?.dispose();
|
|
}
|
|
_focusNode.removeListener(_handleFocusChange);
|
|
_searchFocusNode?.removeListener(_handleFocusChange);
|
|
_focusNode.dispose();
|
|
_searchFocusNode?.dispose();
|
|
_controller.removeListener(_handleControllerChange);
|
|
|
|
if (widget.controller == null || widget.controller?.isDisposed == true) {
|
|
_controller.dispose();
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
/// Build the selected items for the dropdown.
|
|
Widget _buildSelectedItems() {
|
|
if (widget.chipConfig.wrapType == WrapType.scroll) {
|
|
return ListView.separated(
|
|
separatorBuilder: (context, index) =>
|
|
_getChipSeparator(widget.chipConfig),
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _selectedOptions.length,
|
|
shrinkWrap: true,
|
|
itemBuilder: (context, index) {
|
|
final option = _selectedOptions[index];
|
|
if (widget.selectedItemBuilder != null) {
|
|
return widget.selectedItemBuilder!(context, option);
|
|
}
|
|
return _buildChip(option, widget.chipConfig);
|
|
},
|
|
);
|
|
}
|
|
return Wrap(
|
|
spacing: widget.chipConfig.spacing,
|
|
runSpacing: widget.chipConfig.runSpacing,
|
|
children: mapIndexed(_selectedOptions, (index, item) {
|
|
if (widget.selectedItemBuilder != null) {
|
|
return widget.selectedItemBuilder!(
|
|
context, _selectedOptions[index]);
|
|
}
|
|
return _buildChip(_selectedOptions[index], widget.chipConfig);
|
|
}).toList());
|
|
}
|
|
|
|
/// Util method to map with index.
|
|
Iterable<E> mapIndexed<E, F>(
|
|
Iterable<F> items, E Function(int index, F item) f) sync* {
|
|
var index = 0;
|
|
|
|
for (final item in items) {
|
|
yield f(index, item);
|
|
index = index + 1;
|
|
}
|
|
}
|
|
|
|
/// Get the chip separator.
|
|
Widget _getChipSeparator(ChipConfig chipConfig) {
|
|
if (chipConfig.separator != null) {
|
|
return chipConfig.separator!;
|
|
}
|
|
|
|
return SizedBox(
|
|
width: chipConfig.spacing,
|
|
);
|
|
}
|
|
|
|
/// Handle the focus change on tap outside of the dropdown.
|
|
void _onOutSideTap() {
|
|
if (_searchFocusNode != null) {
|
|
_searchFocusNode!.unfocus();
|
|
}
|
|
_focusNode.unfocus();
|
|
}
|
|
|
|
/// Buid the selected item chip.
|
|
Widget _buildChip(ValueItem<T> item, ChipConfig chipConfig) {
|
|
return SelectionChip<T>(
|
|
item: item,
|
|
chipConfig: chipConfig,
|
|
onItemDelete: (removedItem) {
|
|
widget.onOptionRemoved?.call(_options.indexOf(removedItem),
|
|
_selectedOptions[_selectedOptions.indexOf(removedItem)]);
|
|
|
|
_controller.clearSelection(removedItem);
|
|
if (_focusNode.hasFocus) _focusNode.unfocus();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Method to toggle the focus of the dropdown.
|
|
void _toggleFocus() {
|
|
if (_focusNode.hasFocus) {
|
|
_focusNode.unfocus();
|
|
} else {
|
|
_focusNode.requestFocus();
|
|
}
|
|
}
|
|
|
|
/// Get the selectedItem icon for the dropdown
|
|
Widget? _getSelectedIcon(bool isSelected, Color primaryColor) {
|
|
if (isSelected) {
|
|
return widget.selectedOptionIcon ??
|
|
Icon(
|
|
Icons.check,
|
|
color: primaryColor,
|
|
);
|
|
}
|
|
if (!widget.alwaysShowOptionIcon) {
|
|
return null;
|
|
}
|
|
|
|
final Icon icon = widget.selectedOptionIcon ??
|
|
Icon(
|
|
Icons.check,
|
|
color: widget.optionTextStyle?.color ?? Colors.grey,
|
|
);
|
|
|
|
return icon;
|
|
}
|
|
|
|
/// Create the overlay entry for the dropdown.
|
|
OverlayEntry _buildOverlayEntry() {
|
|
// Calculate the offset and the size of the dropdown button
|
|
final values = _calculateOffsetSize();
|
|
// Get the size from the first item in the values list
|
|
final size = values[0] as Size;
|
|
// Get the showOnTop value from the second item in the values list
|
|
final showOnTop = values[1] as bool;
|
|
|
|
return OverlayEntry(builder: (context) {
|
|
List<ValueItem<T>> options = _options;
|
|
List<ValueItem<T>> selectedOptions = [..._selectedOptions];
|
|
final searchController = TextEditingController();
|
|
|
|
return StatefulBuilder(builder: ((context, dropdownState) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: _onOutSideTap,
|
|
child: const ColoredBox(
|
|
color: Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
CompositedTransformFollower(
|
|
link: _layerLink,
|
|
showWhenUnlinked: true,
|
|
targetAnchor:
|
|
showOnTop ? Alignment.topLeft : Alignment.bottomLeft,
|
|
followerAnchor:
|
|
showOnTop ? Alignment.bottomLeft : Alignment.topLeft,
|
|
offset: widget.dropdownMargin != null
|
|
? Offset(
|
|
0,
|
|
showOnTop
|
|
? -widget.dropdownMargin!
|
|
: widget.dropdownMargin!)
|
|
: Offset.zero,
|
|
child: Material(
|
|
color: widget.dropdownBackgroundColor ?? Colors.white,
|
|
// borderRadius: widget.dropdownBorderRadius != null
|
|
// ? BorderRadius.circular(widget.dropdownBorderRadius!)
|
|
// : null,
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(
|
|
Radius.circular(widget.dropdownBorderRadius ?? 0),
|
|
),
|
|
),
|
|
shadowColor: Colors.black,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
backgroundBlendMode: BlendMode.dstATop,
|
|
color: widget.dropdownBackgroundColor ?? Colors.white,
|
|
borderRadius: widget.dropdownBorderRadius != null
|
|
? BorderRadius.circular(widget.dropdownBorderRadius!)
|
|
: null,
|
|
),
|
|
constraints: widget.searchEnabled
|
|
? BoxConstraints.loose(
|
|
Size(size.width, widget.dropdownHeight + 50))
|
|
: BoxConstraints.loose(
|
|
Size(size.width, widget.dropdownHeight)),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (widget.searchEnabled) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: TextFormField(
|
|
controller: searchController,
|
|
onTapOutside: (_) {},
|
|
scrollPadding: EdgeInsets.only(
|
|
bottom:
|
|
MediaQuery.of(context).viewInsets.bottom),
|
|
focusNode: _searchFocusNode,
|
|
decoration: InputDecoration(
|
|
fillColor: widget.searchBackgroundColor ??
|
|
Colors.grey.shade200,
|
|
isDense: true,
|
|
filled: widget.searchBackgroundColor != null,
|
|
hintText: widget.searchLabel,
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: Colors.grey.shade300,
|
|
width: 0.8,
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: Theme.of(context).primaryColor,
|
|
width: 0.8,
|
|
),
|
|
),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () {
|
|
searchController.clear();
|
|
dropdownState(() {
|
|
options = _options;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
onChanged: (value) {
|
|
dropdownState(() {
|
|
options = _options
|
|
.where((element) => element.label
|
|
.toLowerCase()
|
|
.contains(value.toLowerCase()))
|
|
.toList();
|
|
});
|
|
},
|
|
),
|
|
),
|
|
const Divider(
|
|
height: 1,
|
|
),
|
|
],
|
|
Expanded(
|
|
child: ListView.separated(
|
|
separatorBuilder: (_, __) =>
|
|
widget.optionSeparator ??
|
|
const SizedBox(height: 0),
|
|
shrinkWrap: true,
|
|
padding: EdgeInsets.zero,
|
|
itemCount: options.length,
|
|
itemBuilder: (context, index) {
|
|
final option = options[index];
|
|
final isSelected =
|
|
selectedOptions.contains(option);
|
|
|
|
onTap() {
|
|
if (widget.selectionType ==
|
|
SelectionType.multi) {
|
|
if (isSelected) {
|
|
dropdownState(() {
|
|
selectedOptions.remove(option);
|
|
});
|
|
setState(() {
|
|
_selectedOptions.remove(option);
|
|
});
|
|
} else {
|
|
final bool hasReachMax =
|
|
widget.maxItems == null
|
|
? false
|
|
: (_selectedOptions.length + 1) >
|
|
widget.maxItems!;
|
|
if (hasReachMax) return;
|
|
|
|
dropdownState(() {
|
|
selectedOptions.add(option);
|
|
});
|
|
setState(() {
|
|
_selectedOptions.add(option);
|
|
});
|
|
}
|
|
} else {
|
|
dropdownState(() {
|
|
selectedOptions.clear();
|
|
selectedOptions.add(option);
|
|
});
|
|
setState(() {
|
|
_selectedOptions.clear();
|
|
_selectedOptions.add(option);
|
|
});
|
|
_focusNode.unfocus();
|
|
}
|
|
|
|
_controller.value._selectedOptions.clear();
|
|
_controller.value._selectedOptions
|
|
.addAll(_selectedOptions);
|
|
|
|
widget.onOptionSelected?.call(_selectedOptions);
|
|
}
|
|
|
|
if (widget.optionBuilder != null) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: widget.optionBuilder!(
|
|
context, option, isSelected),
|
|
);
|
|
}
|
|
|
|
final primaryColor =
|
|
Theme.of(context).primaryColor;
|
|
|
|
return _buildOption(
|
|
option: option,
|
|
primaryColor: primaryColor,
|
|
isSelected: isSelected,
|
|
dropdownState: dropdownState,
|
|
onTap: onTap,
|
|
selectedOptions: selectedOptions,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)),
|
|
),
|
|
],
|
|
);
|
|
}));
|
|
});
|
|
}
|
|
|
|
ListTile _buildOption(
|
|
{required ValueItem<T> option,
|
|
required Color primaryColor,
|
|
required bool isSelected,
|
|
required StateSetter dropdownState,
|
|
required void Function() onTap,
|
|
required List<ValueItem<T>> selectedOptions}) =>
|
|
ListTile(
|
|
title: Text(option.label,
|
|
style: widget.optionTextStyle ??
|
|
TextStyle(
|
|
fontSize: widget.hintFontSize,
|
|
)),
|
|
subtitle: option.subLabel == null
|
|
? null
|
|
: Text(option.subLabel!,
|
|
style: TextStyle(
|
|
fontSize: ScreenAdaper.height(25), color: AppTheme.grey)),
|
|
selectedColor: widget.selectedOptionTextColor ?? primaryColor,
|
|
selected: isSelected,
|
|
autofocus: true,
|
|
dense: true,
|
|
tileColor: widget.optionsBackgroundColor ?? Colors.white,
|
|
selectedTileColor:
|
|
widget.selectedOptionBackgroundColor ?? Colors.grey.shade200,
|
|
enabled: !_disabledOptions.contains(option),
|
|
onTap: onTap,
|
|
trailing: _getSelectedIcon(isSelected, primaryColor));
|
|
|
|
/// Make a request to the provided url.
|
|
/// The response then is parsed to a list of ValueItem objects.
|
|
Future<void> _fetchNetwork() async {
|
|
final result = await _performNetworkRequest();
|
|
http.get(Uri.parse(widget.networkConfig!.url));
|
|
if (result.statusCode == 200) {
|
|
final data = json.decode(result.body);
|
|
final List<ValueItem<T>> parsedOptions =
|
|
await widget.responseParser!(data);
|
|
_reponseBody = null;
|
|
_options.addAll(parsedOptions);
|
|
} else {
|
|
_reponseBody = result.body;
|
|
}
|
|
}
|
|
|
|
/// Perform the network request according to the provided configuration.
|
|
Future<http.Response> _performNetworkRequest() async {
|
|
switch (widget.networkConfig!.method) {
|
|
case RequestMethod.get:
|
|
return await http.get(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
case RequestMethod.post:
|
|
return await http.post(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
body: widget.networkConfig!.body,
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
case RequestMethod.put:
|
|
return await http.put(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
body: widget.networkConfig!.body,
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
case RequestMethod.patch:
|
|
return await http.patch(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
body: widget.networkConfig!.body,
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
case RequestMethod.delete:
|
|
return await http.delete(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
default:
|
|
return await http.get(
|
|
Uri.parse(widget.networkConfig!.url),
|
|
headers: widget.networkConfig!.headers,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Builds overlay entry for showing error when fetching data from network fails.
|
|
OverlayEntry _buildNetworkErrorOverlayEntry() {
|
|
final values = _calculateOffsetSize();
|
|
final size = values[0] as Size;
|
|
final showOnTop = values[1] as bool;
|
|
|
|
// final offsetY = showOnTop ? -(size.height + 5) : size.height + 5;
|
|
|
|
return OverlayEntry(builder: (context) {
|
|
return StatefulBuilder(builder: ((context, dropdownState) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: _onOutSideTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
),
|
|
)),
|
|
CompositedTransformFollower(
|
|
link: _layerLink,
|
|
targetAnchor:
|
|
showOnTop ? Alignment.topLeft : Alignment.bottomLeft,
|
|
followerAnchor:
|
|
showOnTop ? Alignment.bottomLeft : Alignment.topLeft,
|
|
offset: widget.dropdownMargin != null
|
|
? Offset(
|
|
0,
|
|
showOnTop
|
|
? -widget.dropdownMargin!
|
|
: widget.dropdownMargin!)
|
|
: Offset.zero,
|
|
child: Material(
|
|
borderRadius: widget.dropdownBorderRadius != null
|
|
? BorderRadius.circular(widget.dropdownBorderRadius!)
|
|
: null,
|
|
elevation: 4,
|
|
child: Container(
|
|
width: size.width,
|
|
constraints: BoxConstraints.loose(
|
|
Size(size.width, widget.dropdownHeight)),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
widget.responseErrorBuilder != null
|
|
? widget.responseErrorBuilder!(
|
|
context, _reponseBody)
|
|
: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Text(
|
|
'Error fetching data: $_reponseBody'),
|
|
),
|
|
],
|
|
))))
|
|
],
|
|
);
|
|
}));
|
|
});
|
|
}
|
|
|
|
/// Clear the selected options.
|
|
/// [MultiSelectController] is used to clear the selected options.
|
|
void clear() {
|
|
if (!_controller._isDisposed) {
|
|
_controller.clearAllSelection();
|
|
} else {
|
|
setState(() {
|
|
_selectedOptions.clear();
|
|
});
|
|
widget.onOptionSelected?.call(_selectedOptions);
|
|
}
|
|
if (_focusNode.hasFocus) _focusNode.unfocus();
|
|
}
|
|
|
|
/// handle the controller change.
|
|
void _handleControllerChange() {
|
|
// if the controller is null, return.
|
|
if (_controller.isDisposed == true) return;
|
|
|
|
// if current disabled options are not equal to the controller's disabled options, update the state.
|
|
if (_disabledOptions != _controller.value._disabledOptions) {
|
|
setState(() {
|
|
_disabledOptions.clear();
|
|
_disabledOptions.addAll(_controller.value._disabledOptions);
|
|
});
|
|
}
|
|
|
|
// if current options are not equal to the controller's options, update the state.
|
|
if (_options != _controller.value._options) {
|
|
setState(() {
|
|
_options.clear();
|
|
_options.addAll(_controller.value._options);
|
|
});
|
|
}
|
|
|
|
// if current selected options are not equal to the controller's selected options, update the state.
|
|
if (_selectedOptions != _controller.value._selectedOptions) {
|
|
setState(() {
|
|
_selectedOptions.clear();
|
|
_selectedOptions.addAll(_controller.value._selectedOptions);
|
|
});
|
|
widget.onOptionSelected?.call(_selectedOptions);
|
|
}
|
|
|
|
if (_selectionMode != _controller.value._isDropdownOpen) {
|
|
if (_controller.value._isDropdownOpen) {
|
|
_focusNode.requestFocus();
|
|
} else {
|
|
_focusNode.unfocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
// get the container padding.
|
|
EdgeInsetsGeometry _getContainerPadding() {
|
|
if (widget.padding != null) {
|
|
return widget.padding!;
|
|
}
|
|
return widget.selectionType == SelectionType.single
|
|
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0)
|
|
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0);
|
|
}
|
|
}
|
|
|
|
/// MultiSelect Controller class.
|
|
/// This class is used to control the state of the MultiSelectDropdown widget.
|
|
/// This is just base class. The implementation of this class is in the MultiSelectController class.
|
|
/// The implementation of this class is hidden from the user.
|
|
class _MultiSelectController<T> {
|
|
final List<ValueItem<T>> _disabledOptions = [];
|
|
final List<ValueItem<T>> _options = [];
|
|
final List<ValueItem<T>> _selectedOptions = [];
|
|
bool _isDropdownOpen = false;
|
|
}
|
|
|
|
/// implementation of the MultiSelectController class.
|
|
class MultiSelectController<T>
|
|
extends ValueNotifier<_MultiSelectController<T>> {
|
|
MultiSelectController() : super(_MultiSelectController());
|
|
|
|
bool _isDisposed = false;
|
|
|
|
bool get isDisposed => _isDisposed;
|
|
|
|
/// set the dispose method.
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_isDisposed = true;
|
|
}
|
|
|
|
/// Clear the selected options.
|
|
/// [MultiSelectController] is used to clear the selected options.
|
|
void clearAllSelection() {
|
|
value._selectedOptions.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// clear specific selected option
|
|
/// [MultiSelectController] is used to clear specific selected option.
|
|
void clearSelection(ValueItem<T> option) {
|
|
if (!value._selectedOptions.contains(option)) return;
|
|
|
|
if (value._disabledOptions.contains(option)) {
|
|
throw Exception('Cannot clear selection of a disabled option');
|
|
}
|
|
|
|
if (!value._options.contains(option)) {
|
|
throw Exception(
|
|
'Cannot clear selection of an option that is not in the options list');
|
|
}
|
|
|
|
value._selectedOptions.remove(option);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// select the options
|
|
/// [MultiSelectController] is used to select the options.
|
|
void setSelectedOptions(List<ValueItem<T>> options) {
|
|
if (options.any((element) => value._disabledOptions.contains(element))) {
|
|
throw Exception('Cannot select disabled options');
|
|
}
|
|
|
|
if (options.any((element) => !value._options.contains(element))) {
|
|
throw Exception('Cannot select options that are not in the options list');
|
|
}
|
|
|
|
value._selectedOptions.clear();
|
|
value._selectedOptions.addAll(options);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// add selected option
|
|
/// [MultiSelectController] is used to add selected option.
|
|
void addSelectedOption(ValueItem<T> option) {
|
|
if (value._disabledOptions.contains(option)) {
|
|
throw Exception('Cannot select disabled option');
|
|
}
|
|
|
|
if (!value._options.contains(option)) {
|
|
throw Exception('Cannot select option that is not in the options list');
|
|
}
|
|
|
|
value._selectedOptions.add(option);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// set disabled options
|
|
/// [MultiSelectController] is used to set disabled options.
|
|
void setDisabledOptions(List<ValueItem<T>> disabledOptions) {
|
|
if (disabledOptions.any((element) => !value._options.contains(element))) {
|
|
throw Exception(
|
|
'Cannot disable options that are not in the options list');
|
|
}
|
|
|
|
value._disabledOptions.clear();
|
|
value._disabledOptions.addAll(disabledOptions);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// setDisabledOption method
|
|
/// [MultiSelectController] is used to set disabled option.
|
|
void setDisabledOption(ValueItem<T> disabledOption) {
|
|
if (!value._options.contains(disabledOption)) {
|
|
throw Exception('Cannot disable option that is not in the options list');
|
|
}
|
|
|
|
value._disabledOptions.add(disabledOption);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// set options
|
|
/// [MultiSelectController] is used to set options.
|
|
void setOptions(List<ValueItem<T>> options) {
|
|
value._options.clear();
|
|
value._options.addAll(options);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// get disabled options
|
|
List<ValueItem<T>> get disabledOptions => value._disabledOptions;
|
|
|
|
/// get enabled options
|
|
List<ValueItem<T>> get enabledOptions => value._options
|
|
.where((element) => !value._disabledOptions.contains(element))
|
|
.toList();
|
|
|
|
/// get options
|
|
List<ValueItem<T>> get options => value._options;
|
|
|
|
/// get selected options
|
|
List<ValueItem<T>> get selectedOptions => value._selectedOptions;
|
|
|
|
/// get is dropdown open
|
|
bool get isDropdownOpen => value._isDropdownOpen;
|
|
|
|
/// show dropdown
|
|
/// [MultiSelectController] is used to show dropdown.
|
|
void showDropdown() {
|
|
if (value._isDropdownOpen) return;
|
|
value._isDropdownOpen = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// hide dropdown
|
|
/// [MultiSelectController] is used to hide dropdown.
|
|
void hideDropdown() {
|
|
if (!value._isDropdownOpen) return;
|
|
value._isDropdownOpen = false;
|
|
notifyListeners();
|
|
}
|
|
}
|