import 'dart:async'; import 'package:flutter/material.dart'; import 'dropdown_search.dart'; class SelectDialog extends StatefulWidget { final T? selectedValue; final List? items; final bool showSearchBox; final bool isFilteredOnline; final ValueChanged? onChanged; final DropdownSearchOnFind? onFind; final DropdownSearchPopupItemBuilder? itemBuilder; final InputDecoration? searchBoxDecoration; final DropdownSearchItemAsString? itemAsString; final DropdownSearchFilterFn? filterFn; final String? hintText; final double? maxHeight; final double? dialogMaxWidth; final Widget? popupTitle; final bool showSelectedItem; final DropdownSearchCompareFn? compareFn; final DropdownSearchPopupItemEnabled? itemDisabled; ///custom layout for empty results final EmptyBuilder? emptyBuilder; ///custom layout for loading items final LoadingBuilder? loadingBuilder; ///custom layout for error final ErrorBuilder? errorBuilder; ///the search box will be focused if true final bool autoFocusSearchBox; ///text controller to set default search word for example final TextEditingController? searchBoxController; ///delay before searching final Duration? searchDelay; ///show or hide favorites items final bool showFavoriteItems; ///build favorites chips final FavoriteItemsBuilder? favoriteItemBuilder; ///favorite items alignment final MainAxisAlignment? favoriteItemsAlignment; ///favorites item final FavoriteItems? favoriteItems; const SelectDialog({ Key? key, this.popupTitle, this.items, this.maxHeight, this.showSearchBox = false, this.isFilteredOnline = false, this.onChanged, this.selectedValue, this.onFind, this.itemBuilder, this.searchBoxDecoration, this.hintText, this.itemAsString, this.filterFn, this.showSelectedItem = false, this.compareFn, this.emptyBuilder, this.loadingBuilder, this.errorBuilder, this.autoFocusSearchBox = false, this.dialogMaxWidth, this.itemDisabled, this.searchBoxController, this.searchDelay, this.favoriteItemBuilder, this.favoriteItems, this.showFavoriteItems = false, this.favoriteItemsAlignment = MainAxisAlignment.start, }) : super(key: key); @override _SelectDialogState createState() => _SelectDialogState(); } class _SelectDialogState extends State> { final FocusNode focusNode = new FocusNode(); final StreamController> _itemsStream = StreamController>.broadcast(); final ValueNotifier _loadingNotifier = ValueNotifier(false); final List _items = []; late Debouncer _debouncer; @override void initState() { super.initState(); _debouncer = Debouncer(delay: widget.searchDelay); Future.delayed( Duration.zero, () => manageItemsByFilter(widget.searchBoxController?.text ?? '', isFistLoad: true), ); } @override void didChangeDependencies() { super.didChangeDependencies(); if (widget.autoFocusSearchBox) FocusScope.of(context).requestFocus(focusNode); } @override void dispose() { _itemsStream.close(); super.dispose(); } @override Widget build(BuildContext context) { Size deviceSize = MediaQuery.of(context).size; bool isTablet = deviceSize.width > deviceSize.height; double maxHeight = deviceSize.height * (isTablet ? .8 : .6); double maxWidth = deviceSize.width * (isTablet ? .7 : .9); return Container( width: widget.dialogMaxWidth ?? maxWidth, constraints: BoxConstraints(maxHeight: widget.maxHeight ?? maxHeight), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ _searchField(), if (widget.showFavoriteItems == true) _favoriteItemsWidget(), Expanded( child: Stack( children: [ StreamBuilder>( stream: _itemsStream.stream, builder: (context, snapshot) { if (snapshot.hasError) { return _errorWidget(snapshot.error); } else if (!snapshot.hasData) { return _loadingWidget(); } else if (snapshot.data!.isEmpty) { if (widget.emptyBuilder != null) return widget.emptyBuilder!(context, widget.searchBoxController?.text); else return const Center( child: const Text("No data found"), ); } return ListView.builder( shrinkWrap: true, padding: EdgeInsets.symmetric(vertical: 0), itemCount: snapshot.data!.length, itemBuilder: (context, index) { var item = snapshot.data![index]; return _itemWidget(item); }, ); }, ), _loadingWidget() ], ), ), ], ), ); } void _showErrorDialog(dynamic error) { showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return AlertDialog( title: Text("Error while getting online items"), content: _errorWidget(error), actions: [ TextButton( child: new Text("OK"), onPressed: () { Navigator.of(context).pop(false); }, ) ], ); }, ); } Widget _errorWidget(dynamic error) { if (widget.errorBuilder != null) return widget.errorBuilder!(context, widget.searchBoxController?.text, error); else return Padding( padding: EdgeInsets.all(8), child: Text( error?.toString() ?? 'Error', ), ); } Widget _loadingWidget() { return ValueListenableBuilder( valueListenable: _loadingNotifier, builder: (context, bool isLoading, wid) { if (isLoading) { if (widget.loadingBuilder != null) return widget.loadingBuilder!(context, widget.searchBoxController?.text); else return Padding( padding: const EdgeInsets.all(24.0), child: const Center( child: const CircularProgressIndicator(), ), ); } return Container(); }); } void _onTextChanged(String filter) async { manageItemsByFilter(filter); } ///Function that filter item (online and offline) base on user filter ///[filter] is the filter keyword ///[isFirstLoad] true if it's the first time we load data from online, false other wises void manageItemsByFilter(String filter, {bool isFistLoad = false}) async { _loadingNotifier.value = true; String encoded(String item) { String encodedItem = ""; for (int i = 0; i < item.length; i++) { var char = item[i]; switch (char) { case 'Á': case 'á': case 'ą': case 'ä': char = 'a'; break; case 'é': case 'É': char = 'e'; break; case 'ú': case 'ű': case 'ü': case 'Ú': case 'Ű': case 'Ü': char = 'u'; break; case 'ö': case 'ő': case 'ó': case 'Ö': case 'Ő': case 'Ó': char = 'o'; break; case 'í': case 'Í': char = 'i'; break; } encodedItem += char; } return encodedItem; } List applyFilter(String filter) { return _items.where((i) { if (widget.filterFn != null) return (widget.filterFn!(i, filter)); else if (i.toString().toLowerCase().contains(filter.toLowerCase()) || encoded(i.toString()).toLowerCase().contains(encoded(filter.toLowerCase()))) { return true; } else if (widget.itemAsString != null) { bool found = (widget.itemAsString!(i)).toLowerCase().contains(filter.toLowerCase()); if (!found) { found = (encoded(widget.itemAsString!(i))).toLowerCase().contains(encoded(filter.toLowerCase())); } return found; } return false; }).toList(); } //load offline data for the first time if (isFistLoad && widget.items != null) _items.addAll(widget.items!); //manage offline items if (widget.onFind != null && (widget.isFilteredOnline || isFistLoad)) { try { final List onlineItems = []; onlineItems.addAll(await widget.onFind!(filter)); //Remove all old data _items.clear(); //add offline items if (widget.items != null) { _items.addAll(widget.items!); //if filter online we filter only local list based on entered keyword (filter) if (widget.isFilteredOnline == true) { var filteredLocalList = applyFilter(filter); _items.clear(); _items.addAll(filteredLocalList); } } //add new online items to list _items.addAll(onlineItems); //don't filter data , they are already filtred online and local data are already filtered if (widget.isFilteredOnline == true) _addDataToStream(_items); else _addDataToStream(applyFilter(filter)); } catch (e) { _addErrorToStream(e); //if offline items count > 0 , the error will be not visible for the user //As solution we show it in dialog if (widget.items != null && widget.items!.isNotEmpty) { _showErrorDialog(e); _addDataToStream(applyFilter(filter)); } } } else { _addDataToStream(applyFilter(filter)); } _loadingNotifier.value = false; } void _addDataToStream(List data) { if (_itemsStream.isClosed) return; _itemsStream.add(data); } void _addErrorToStream(Object error, [StackTrace? stackTrace]) { if (_itemsStream.isClosed) return; _itemsStream.addError(error, stackTrace); } Widget _itemWidget(T? item) { if (widget.itemBuilder != null) return InkWell( child: widget.itemBuilder!( context, item, _manageSelectedItemVisibility(item), ), onTap: widget.itemDisabled != null && (widget.itemDisabled!(item)) == true ? null : () => _handleSelectItem(item), ); else return ListTile( title: Text(_selectedItemAsString(item)), selected: _manageSelectedItemVisibility(item), onTap: widget.itemDisabled != null && (widget.itemDisabled!(item)) == true ? null : () => _handleSelectItem(item), ); } /// selected item will be highlighted only when [widget.showSelectedItem] is true, /// if our object is String [widget.compareFn] is not required , other wises it's required bool _manageSelectedItemVisibility(T? item) { if (!widget.showSelectedItem) return false; if (item is String?) { return item == widget.selectedValue; } else { return widget.compareFn!(item, widget.selectedValue); } } Widget _searchField() { return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ widget.popupTitle ?? const SizedBox.shrink(), if (widget.showSearchBox) Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: widget.searchBoxController, focusNode: focusNode, onChanged: (f) => _debouncer(() { _onTextChanged(f); }), decoration: widget.searchBoxDecoration ?? InputDecoration( hintText: widget.hintText, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 16), ), ), ) ]); } Widget _favoriteItemsWidget() { return StreamBuilder>( stream: _itemsStream.stream, builder: (context, snapshot) { if (snapshot.hasData) { return _buildFavoriteItems(widget.favoriteItems!(snapshot.data!)); } else { return Container(); } }); } Widget _buildFavoriteItems(List? favoriteItems) { if (favoriteItems != null) { return Container( padding: EdgeInsets.symmetric(horizontal: 8), child: LayoutBuilder(builder: (context, constraints) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: widget.favoriteItemsAlignment ?? MainAxisAlignment.start, children: favoriteItems .map( (f) => GestureDetector( onTap: () => _handleSelectItem(f), child: Container( margin: EdgeInsets.only(right: 4), child: widget.favoriteItemBuilder != null ? widget.favoriteItemBuilder!(context, f) : _favoriteItemDefaultWidget(f), ), ), ) .toList()), ), ); }), ); } else { return Container(); } } void _handleSelectItem(T? selectedItem) { Navigator.pop(context, selectedItem); if (widget.onChanged != null) widget.onChanged!(selectedItem); } Widget _favoriteItemDefaultWidget(T? item) { return Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration(borderRadius: BorderRadius.circular(10), color: Theme.of(context).primaryColorLight), child: Text( _selectedItemAsString(item), textAlign: TextAlign.center, style: Theme.of(context).textTheme.subtitle1, ), ); } ///function that return the String value of an object String _selectedItemAsString(T? data) { if (data == null) { return ""; } else if (widget.itemAsString != null) { return widget.itemAsString!(data); } else { return data.toString(); } } } class Debouncer { final Duration? delay; Timer? _timer; Debouncer({this.delay}); call(Function action) { _timer?.cancel(); _timer = Timer(delay ?? const Duration(milliseconds: 500), action as void Function()); } }