import 'package:flutter/material.dart'; /// Lightweight, bounds-safe multi-select picker used in dialogs. /// - Renders chips for selected items and a `Select` ActionChip. /// - Opens a root dialog with search + select-all + checkbox list. class MultiSelectPicker extends StatefulWidget { const MultiSelectPicker({ super.key, required this.label, required this.items, required this.selectedIds, required this.getId, required this.getLabel, required this.onChanged, }); final String label; final List items; final List selectedIds; final String Function(T) getId; final String Function(T) getLabel; final ValueChanged> onChanged; @override State> createState() => _MultiSelectPickerState(); } class _MultiSelectPickerState extends State> { late List _selectedIds; String _search = ''; @override void initState() { super.initState(); _selectedIds = List.from(widget.selectedIds); } Future _openPicker() async { final screenWidth = MediaQuery.of(context).size.width; final isMobile = screenWidth <= 600; const double kPickerMaxWidth = 720; List? result; if (isMobile) { result = await showModalBottomSheet>( context: context, isScrollControlled: true, builder: (sheetContext) { List working = List.from(_selectedIds); bool workingSelectAll = working.length == widget.items.length && widget.items.isNotEmpty; String localSearch = _search; return StatefulBuilder( builder: (sheetContext, setState) { final filtered = widget.items .where( (item) => widget .getLabel(item) .toLowerCase() .contains(localSearch.toLowerCase()), ) .toList(); return SafeArea( child: Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 12, bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 12, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Select ${widget.label}', style: Theme.of(sheetContext).textTheme.titleLarge, ), const SizedBox(height: 8), TextField( decoration: const InputDecoration( hintText: 'Search...', ), onChanged: (v) => setState(() => localSearch = v), ), CheckboxListTile( value: workingSelectAll, title: const Text('Select All'), onChanged: (checked) { setState(() { workingSelectAll = checked ?? false; if (workingSelectAll) { working = widget.items.map(widget.getId).toList(); } else { working = []; } }); }, ), SizedBox( height: 320, child: ListView( children: [ for (final item in filtered) CheckboxListTile( value: working.contains(widget.getId(item)), title: Text(widget.getLabel(item)), onChanged: (checked) { setState(() { final id = widget.getId(item); if (checked == true) { working.add(id); } else { working.remove(id); } workingSelectAll = working.length == widget.items.length && widget.items.isNotEmpty; }); }, ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), ElevatedButton( onPressed: () => Navigator.of(sheetContext).pop(working), child: const Text('Done'), ), ], ), ], ), ), ); }, ); }, ); } else { result = await showDialog>( context: context, useRootNavigator: true, builder: (dialogContext) { List working = List.from(_selectedIds); bool workingSelectAll = working.length == widget.items.length && widget.items.isNotEmpty; String localSearch = _search; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: kPickerMaxWidth), child: StatefulBuilder( builder: (dialogContext, setState) { final filtered = widget.items .where( (item) => widget .getLabel(item) .toLowerCase() .contains(localSearch.toLowerCase()), ) .toList(); return AlertDialog( title: Text('Select ${widget.label}'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( decoration: const InputDecoration( hintText: 'Search...', ), onChanged: (v) => setState(() => localSearch = v), ), CheckboxListTile( value: workingSelectAll, title: const Text('Select All'), onChanged: (checked) { setState(() { workingSelectAll = checked ?? false; if (workingSelectAll) { working = widget.items .map(widget.getId) .toList(); } else { working = []; } }); }, ), SizedBox( height: 220, width: double.maxFinite, child: ListView( children: [ for (final item in filtered) CheckboxListTile( value: working.contains(widget.getId(item)), title: Text(widget.getLabel(item)), onChanged: (checked) { setState(() { final id = widget.getId(item); if (checked == true) { working.add(id); } else { working.remove(id); } workingSelectAll = working.length == widget.items.length && widget.items.isNotEmpty; }); }, ), ], ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: () => Navigator.of(dialogContext).pop(working), child: const Text('Done'), ), ], ); }, ), ), ); }, ); } if (result is List) { setState(() { _selectedIds = List.from(result!); _search = ''; }); widget.onChanged(_selectedIds); } } @override void didUpdateWidget(covariant MultiSelectPicker oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.selectedIds != widget.selectedIds) { _selectedIds = List.from(widget.selectedIds); } } @override Widget build(BuildContext context) { // Avoid using LayoutBuilder inside InputDecorator because AlertDialog (and // other parents) may request intrinsic dimensions which causes LayoutBuilder // to throw. Use a MediaQuery-based maxWidth instead — it is intrinsic-safe. final double screenMaxWidth = MediaQuery.of(context).size.width - 48.0; return InputDecorator( decoration: InputDecoration(labelText: widget.label), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: screenMaxWidth), child: Align( alignment: Alignment.centerLeft, child: Wrap( spacing: 8, runSpacing: 4, children: [ for (final id in _selectedIds) Builder( builder: (context) { final item = widget.items.firstWhere( (e) => widget.getId(e) == id, orElse: () => null as T, ); if (item == null) return const SizedBox.shrink(); return Chip( label: Text(widget.getLabel(item)), onDeleted: () { setState(() { _selectedIds.remove(id); }); widget.onChanged(_selectedIds); }, ); }, ), ActionChip( label: const Text('Select'), avatar: const Icon(Icons.arrow_drop_down), onPressed: _openPicker, ), ], ), ), ), ); } }