import 'package:flutter/material.dart'; import 'app_dropdown.dart'; /// Dropdown that opens a searchable popup with multi-select checkboxes. class AppSearchableMultiSelectDropdown extends StatefulWidget { const AppSearchableMultiSelectDropdown({ super.key, required this.label, required this.values, required this.options, required this.onChanged, this.validator, this.hint, this.searchHint = 'Search...', this.enabled = true, this.isDense = false, }); final String label; final List values; final List> options; final ValueChanged> onChanged; final String? Function(List?)? validator; final String? hint; final String searchHint; final bool enabled; final bool isDense; @override State> createState() => _AppSearchableMultiSelectDropdownState(); } class _AppSearchableMultiSelectDropdownState extends State> { final _layerLink = LayerLink(); final _fieldKey = GlobalKey(); OverlayEntry? _overlayEntry; @override void dispose() { _removeOverlay(); super.dispose(); } List _selectedLabels() { final labels = []; for (final value in widget.values) { for (final option in widget.options) { if (option.value == value) { labels.add(option.label); break; } } } return labels; } void _removeOverlay() { if (_overlayEntry == null) return; _overlayEntry!.remove(); _overlayEntry = null; if (mounted) setState(() {}); } void _openPicker(FormFieldState> field) { if (!widget.enabled || widget.options.isEmpty) return; if (_overlayEntry != null) { _removeOverlay(); return; } final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null) return; final fieldSize = renderBox.size; final fieldTopLeft = renderBox.localToGlobal(Offset.zero); final screenSize = MediaQuery.sizeOf(context); final viewInsets = MediaQuery.viewInsetsOf(context); final spaceBelow = screenSize.height - viewInsets.bottom - fieldTopLeft.dy - fieldSize.height; final spaceAbove = fieldTopLeft.dy - viewInsets.top; final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow; final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8; final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45); var selected = List.from(widget.values); _overlayEntry = OverlayEntry( builder: (overlayContext) { final theme = Theme.of(overlayContext); return Stack( children: [ Positioned.fill( child: GestureDetector( onTap: () { _removeOverlay(); field.didChange(selected); widget.onChanged(selected); }, behavior: HitTestBehavior.translucent, ), ), CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, targetAnchor: showAbove ? Alignment.topLeft : Alignment.bottomLeft, followerAnchor: showAbove ? Alignment.bottomLeft : Alignment.topLeft, offset: Offset(0, showAbove ? -4 : 4), child: TapRegion( onTapOutside: (_) { _removeOverlay(); field.didChange(selected); widget.onChanged(selected); }, child: Material( elevation: 8, borderRadius: BorderRadius.circular(8), clipBehavior: Clip.antiAlias, color: theme.colorScheme.surface, shadowColor: Colors.black45, child: SizedBox( width: fieldSize.width, child: _SearchableMultiSelectPanel( maxHeight: maxPanelHeight, options: widget.options, selected: selected, searchHint: widget.searchHint, onToggle: (value, checked) { if (checked) { if (!selected.contains(value)) { selected = [...selected, value]; } } else { selected = selected.where((v) => v != value).toList(); } _overlayEntry?.markNeedsBuild(); }, ), ), ), ), ), ], ); }, ); Overlay.of(context).insert(_overlayEntry!); setState(() {}); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return FormField>( key: ValueKey(widget.values.join('\u0000')), initialValue: widget.values, validator: widget.validator, builder: (field) { final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}'; final canOpen = widget.enabled && widget.options.isNotEmpty; final colors = theme.colorScheme; final labels = _selectedLabels(); final displayText = labels.join(', '); final tooltipMessage = labels.length > 1 ? labels.join('\n') : displayText; return Padding( padding: const EdgeInsets.only(top: 8), child: CompositedTransformTarget( link: _layerLink, child: KeyedSubtree( key: _fieldKey, child: InkWell( onTap: canOpen ? () => _openPicker(field) : null, borderRadius: BorderRadius.circular(8), child: InputDecorator( isFocused: _overlayEntry != null, isEmpty: displayText.isEmpty, decoration: InputDecoration( labelText: widget.label, hintText: displayText.isEmpty ? effectiveHint : null, floatingLabelBehavior: FloatingLabelBehavior.always, isDense: widget.isDense, errorText: field.errorText, suffixIcon: Icon( _overlayEntry != null ? Icons.arrow_drop_up : Icons.arrow_drop_down, color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, ), enabled: canOpen, ), child: displayText.isEmpty ? const SizedBox.shrink() : Tooltip( message: tooltipMessage, preferBelow: true, waitDuration: const Duration(milliseconds: 250), child: Text( displayText, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge?.copyWith( color: colors.onSurface, ), ), ), ), ), ), ), ); }, ); } } class _SearchableMultiSelectPanel extends StatefulWidget { const _SearchableMultiSelectPanel({ required this.maxHeight, required this.options, required this.selected, required this.searchHint, required this.onToggle, }); final double maxHeight; final List> options; final List selected; final String searchHint; final void Function(T value, bool checked) onToggle; @override State<_SearchableMultiSelectPanel> createState() => _SearchableMultiSelectPanelState(); } class _SearchableMultiSelectPanelState extends State<_SearchableMultiSelectPanel> { final _searchController = TextEditingController(); String _query = ''; @override void dispose() { _searchController.dispose(); super.dispose(); } List> get _filtered { final q = _query.trim().toLowerCase(); if (q.isEmpty) return widget.options; return widget.options .where((option) => option.label.toLowerCase().contains(q)) .toList(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _filtered; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: TextField( controller: _searchController, autofocus: true, decoration: InputDecoration( hintText: widget.searchHint, prefixIcon: const Icon(Icons.search, size: 20), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), onChanged: (value) => setState(() => _query = value), ), ), ConstrainedBox( constraints: BoxConstraints(maxHeight: widget.maxHeight - 56), child: filtered.isEmpty ? Padding( padding: const EdgeInsets.all(20), child: Text( 'No options found', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) : ListView.separated( shrinkWrap: true, padding: const EdgeInsets.only(bottom: 4), itemCount: filtered.length, separatorBuilder: (_, __) => Divider( height: 1, color: theme.colorScheme.outlineVariant.withValues( alpha: 0.5, ), ), itemBuilder: (context, index) { final option = filtered[index]; final isSelected = widget.selected.contains(option.value); return CheckboxListTile( dense: true, visualDensity: VisualDensity.compact, controlAffinity: ListTileControlAffinity.leading, contentPadding: const EdgeInsets.symmetric(horizontal: 8), title: Text( option.label, overflow: TextOverflow.ellipsis, ), value: isSelected, onChanged: (checked) => widget.onToggle(option.value, checked ?? false), ); }, ), ), ], ); } }