tasq/lib/widgets/multi_select_picker.dart

315 lines
12 KiB
Dart

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<T> 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<T> items;
final List<String> selectedIds;
final String Function(T) getId;
final String Function(T) getLabel;
final ValueChanged<List<String>> onChanged;
@override
State<MultiSelectPicker<T>> createState() => _MultiSelectPickerState<T>();
}
class _MultiSelectPickerState<T> extends State<MultiSelectPicker<T>> {
late List<String> _selectedIds;
String _search = '';
@override
void initState() {
super.initState();
_selectedIds = List<String>.from(widget.selectedIds);
}
Future<void> _openPicker() async {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth <= 600;
const double kPickerMaxWidth = 720;
List<String>? result;
if (isMobile) {
result = await showModalBottomSheet<List<String>>(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
List<String> working = List<String>.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<List<String>>(
context: context,
useRootNavigator: true,
builder: (dialogContext) {
List<String> working = List<String>.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<String>) {
setState(() {
_selectedIds = List<String>.from(result!);
_search = '';
});
widget.onChanged(_selectedIds);
}
}
@override
void didUpdateWidget(covariant MultiSelectPicker<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedIds != widget.selectedIds) {
_selectedIds = List<String>.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,
),
],
),
),
),
);
}
}