bharat_erp/lib/shared/widgets/app_searchable_multi_select_dropdown.dart
2026-06-22 12:35:37 +05:30

330 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'app_dropdown.dart';
/// Dropdown that opens a searchable popup with multi-select checkboxes.
class AppSearchableMultiSelectDropdown<T> 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<T> values;
final List<AppDropdownOption<T>> options;
final ValueChanged<List<T>> onChanged;
final String? Function(List<T>?)? validator;
final String? hint;
final String searchHint;
final bool enabled;
final bool isDense;
@override
State<AppSearchableMultiSelectDropdown<T>> createState() =>
_AppSearchableMultiSelectDropdownState<T>();
}
class _AppSearchableMultiSelectDropdownState<T>
extends State<AppSearchableMultiSelectDropdown<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
String _displayText() {
if (widget.values.isEmpty) return '';
final labels = <String>[];
for (final value in widget.values) {
for (final option in widget.options) {
if (option.value == value) {
labels.add(option.label);
break;
}
}
}
return labels.join(', ');
}
void _removeOverlay() {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
if (mounted) setState(() {});
}
void _openPicker(FormFieldState<List<T>> 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<T>.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<T>(
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);
final displayText = _displayText();
return FormField<List<T>>(
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;
return 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()
: Text(
displayText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
),
),
),
),
),
);
},
);
}
}
class _SearchableMultiSelectPanel<T> extends StatefulWidget {
const _SearchableMultiSelectPanel({
required this.maxHeight,
required this.options,
required this.selected,
required this.searchHint,
required this.onToggle,
});
final double maxHeight;
final List<AppDropdownOption<T>> options;
final List<T> selected;
final String searchHint;
final void Function(T value, bool checked) onToggle;
@override
State<_SearchableMultiSelectPanel<T>> createState() =>
_SearchableMultiSelectPanelState<T>();
}
class _SearchableMultiSelectPanelState<T>
extends State<_SearchableMultiSelectPanel<T>> {
final _searchController = TextEditingController();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<AppDropdownOption<T>> 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),
);
},
),
),
],
);
}
}