330 lines
10 KiB
Dart
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),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|