315 lines
12 KiB
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|