bottleneckcrm/mobile/lib/widgets/forms/multi_select_sheet.dart
Ashwin 98c95e55b0 Add unit tests for invoice creation, notifications, and timesheet functionality
- Implement tests for contact billing rules in new_invoice_test.dart
- Validate line item payload structure and constraints in new_invoice_test.dart
- Create comprehensive tests for notifications provider in notifications_provider_test.dart
- Ensure correct handling of unread notifications and marking them as read
- Add tests for timesheet provider functionality in timesheet_provider_test.dart
- Verify time entry parsing, running timers, and week calculations
2026-08-07 19:32:32 +05:30

213 lines
6.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/theme.dart';
/// Generic searchable multi-select bottom sheet.
///
/// Open with `MultiSelectSheet.show<T>(...)`. Returns the new selection or
/// null if cancelled. Items are matched against `searchText(item)`.
class MultiSelectSheet<T> extends StatefulWidget {
final String title;
final List<T> items;
final List<T> initialSelection;
final String Function(T) labelOf;
final String Function(T) searchText;
final Widget Function(T)? leadingOf;
final String emptyMessage;
const MultiSelectSheet({
super.key,
required this.title,
required this.items,
required this.initialSelection,
required this.labelOf,
required this.searchText,
this.leadingOf,
this.emptyMessage = 'No options',
});
static Future<List<T>?> show<T>({
required BuildContext context,
required String title,
required List<T> items,
required List<T> initialSelection,
required String Function(T) labelOf,
required String Function(T) searchText,
Widget Function(T)? leadingOf,
String emptyMessage = 'No options',
}) {
return showModalBottomSheet<List<T>>(
context: context,
backgroundColor: AppColors.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (ctx, controller) => MultiSelectSheet<T>(
title: title,
items: items,
initialSelection: initialSelection,
labelOf: labelOf,
searchText: searchText,
leadingOf: leadingOf,
emptyMessage: emptyMessage,
),
),
);
}
@override
State<MultiSelectSheet<T>> createState() => _MultiSelectSheetState<T>();
}
class _MultiSelectSheetState<T> extends State<MultiSelectSheet<T>> {
late final TextEditingController _searchController;
late final Set<T> _selected;
String _query = '';
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_selected = Set<T>.from(widget.initialSelection);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<T> get _filtered {
if (_query.isEmpty) return widget.items;
final q = _query.toLowerCase();
return widget.items
.where((item) => widget.searchText(item).toLowerCase().contains(q))
.toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.gray300,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
Expanded(child: Text(widget.title, style: AppTypography.h3)),
TextButton(
// Themed buttons carry an infinite minimum width, which a Row
// does not bound. Without this the row fails to lay out and the
// screen paints nothing. See AppLayout.buttonMinSizeInRow.
style: TextButton.styleFrom(
minimumSize: AppLayout.buttonMinSizeInRow,
),
onPressed: () => Navigator.pop(context, _selected.toList()),
child: const Text('Done'),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search…',
prefixIcon: const Icon(LucideIcons.search, size: 18),
filled: true,
fillColor: AppColors.gray50,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
),
onChanged: (v) => setState(() => _query = v),
),
),
Expanded(
child: filtered.isEmpty
? Center(
child: Text(
widget.emptyMessage,
style: AppTypography.body.copyWith(
color: AppColors.textSecondary,
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final item = filtered[i];
final isChecked = _selected.contains(item);
return InkWell(
onTap: () {
setState(() {
if (isChecked) {
_selected.remove(item);
} else {
_selected.add(item);
}
});
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
if (widget.leadingOf != null) ...[
widget.leadingOf!(item),
const SizedBox(width: 12),
],
Expanded(
child: Text(
widget.labelOf(item),
style: AppTypography.body,
),
),
Checkbox(
value: isChecked,
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(item);
} else {
_selected.remove(item);
}
});
},
),
],
),
),
);
},
),
),
],
);
}
}