feat: Refactor DirectoryFilterBottomSheet to manage state and improve filter functionality

This commit is contained in:
Vaibhav Surve 2025-08-01 17:11:34 +05:30
parent 7ce07c9b47
commit 0f14fda83a

View File

@ -1,170 +1,275 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class DirectoryFilterBottomSheet extends StatelessWidget {
class DirectoryFilterBottomSheet extends StatefulWidget {
const DirectoryFilterBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<DirectoryController>();
State<DirectoryFilterBottomSheet> createState() =>
_DirectoryFilterBottomSheetState();
}
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
top: 12,
left: 16,
right: 16,
),
child: Obx(() {
return SingleChildScrollView(
class _DirectoryFilterBottomSheetState
extends State<DirectoryFilterBottomSheet> {
final DirectoryController controller = Get.find<DirectoryController>();
final _categorySearchQuery = ''.obs;
final _bucketSearchQuery = ''.obs;
final _categoryExpanded = false.obs;
final _bucketExpanded = false.obs;
late final RxList<String> _tempSelectedCategories;
late final RxList<String> _tempSelectedBuckets;
@override
void initState() {
super.initState();
_tempSelectedCategories = controller.selectedCategories.toList().obs;
_tempSelectedBuckets = controller.selectedBuckets.toList().obs;
}
void _toggleCategory(String id) {
_tempSelectedCategories.contains(id)
? _tempSelectedCategories.remove(id)
: _tempSelectedCategories.add(id);
}
void _toggleBucket(String id) {
_tempSelectedBuckets.contains(id)
? _tempSelectedBuckets.remove(id)
: _tempSelectedBuckets.add(id);
}
void _resetFilters() {
_tempSelectedCategories.clear();
_tempSelectedBuckets.clear();
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Filter Contacts",
onSubmit: () {
controller.selectedCategories.value = _tempSelectedCategories;
controller.selectedBuckets.value = _tempSelectedBuckets;
controller.applyFilters();
Get.back();
},
onCancel: Get.back,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Drag handle
Center(
child: Container(
height: 5,
width: 50,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2.5),
),
),
),
/// Title
Center(
child: MyText.titleMedium(
"Filter Contacts",
fontWeight: 700,
),
),
const SizedBox(height: 24),
/// Categories
if (controller.contactCategories.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Obx(() {
final hasSelections = _tempSelectedCategories.isNotEmpty ||
_tempSelectedBuckets.isNotEmpty;
if (!hasSelections) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium("Categories", fontWeight: 600),
MyText("Selected Filters:", fontWeight: 600),
const SizedBox(height: 4),
_buildChips(_tempSelectedCategories,
controller.contactCategories, _toggleCategory),
_buildChips(_tempSelectedBuckets, controller.contactBuckets,
_toggleBucket),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 2,
runSpacing: 0,
children: controller.contactCategories.map((category) {
final selected =
controller.selectedCategories.contains(category.id);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FilterChip(
label: MyText.bodySmall(
category.name,
color: selected ? Colors.white : Colors.black87,
),
selected: selected,
onSelected: (_) =>
controller.toggleCategory(category.id),
selectedColor: Colors.indigo,
backgroundColor: Colors.grey.shade200,
checkmarkColor: Colors.white,
),
);
}).toList(),
),
const SizedBox(height: 12),
],
/// Buckets
if (controller.contactBuckets.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium("Buckets", fontWeight: 600),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 2,
runSpacing: 0,
children: controller.contactBuckets.map((bucket) {
final selected =
controller.selectedBuckets.contains(bucket.id);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: FilterChip(
label: MyText.bodySmall(
bucket.name,
color: selected ? Colors.white : Colors.black87,
),
selected: selected,
onSelected: (_) => controller.toggleBucket(bucket.id),
selectedColor: Colors.teal,
backgroundColor: Colors.grey.shade200,
checkmarkColor: Colors.white,
),
);
}).toList(),
),
],
const SizedBox(height: 12),
/// Action Buttons
);
}),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () {
controller.selectedCategories.clear();
controller.selectedBuckets.clear();
controller.searchQuery.value = '';
controller.applyFilters();
Get.back();
},
icon: const Icon(Icons.refresh, color: Colors.red),
label: MyText.bodyMedium("Clear", color: Colors.red),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 7),
),
),
ElevatedButton.icon(
onPressed: () {
controller.applyFilters();
Get.back();
},
icon: const Icon(Icons.check_circle_outline),
label: MyText.bodyMedium("Apply", color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 7),
TextButton.icon(
onPressed: _resetFilters,
icon: const Icon(Icons.restart_alt, size: 18),
label: MyText("Reset All", color: Colors.red),
style: TextButton.styleFrom(
foregroundColor: Colors.red.shade400,
),
),
],
),
const SizedBox(height: 10),
if (controller.contactCategories.isNotEmpty)
Obx(() => _buildExpandableFilterSection(
title: "Categories",
expanded: _categoryExpanded,
searchQuery: _categorySearchQuery,
allItems: controller.contactCategories,
selectedItems: _tempSelectedCategories,
onToggle: _toggleCategory,
)),
if (controller.contactBuckets.isNotEmpty)
Obx(() => _buildExpandableFilterSection(
title: "Buckets",
expanded: _bucketExpanded,
searchQuery: _bucketSearchQuery,
allItems: controller.contactBuckets,
selectedItems: _tempSelectedBuckets,
onToggle: _toggleBucket,
)),
],
),
);
}),
),
),
);
}
Widget _buildChips(RxList<String> selectedIds, List<dynamic> allItems,
Function(String) onRemoved) {
final idToName = {for (var item in allItems) item.id: item.name};
return Wrap(
spacing: 4,
runSpacing: 4,
children: selectedIds
.map((id) => Chip(
label: MyText(idToName[id] ?? "", color: Colors.black87),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () => onRemoved(id),
backgroundColor: Colors.blue.shade50,
))
.toList(),
);
}
Widget _buildExpandableFilterSection({
required String title,
required RxBool expanded,
required RxString searchQuery,
required List<dynamic> allItems,
required RxList<String> selectedItems,
required Function(String) onToggle,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
children: [
GestureDetector(
onTap: () => expanded.toggle(),
child: Row(
children: [
Icon(
expanded.value
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_right,
size: 20,
),
const SizedBox(width: 4),
MyText(
"$title (${selectedItems.length})",
fontWeight: 600,
fontSize: 16,
),
],
),
),
if (expanded.value)
_buildFilterSection(
searchQuery: searchQuery,
allItems: allItems,
selectedItems: selectedItems,
onToggle: onToggle,
title: title,
),
],
),
);
}
Widget _buildFilterSection({
required String title,
required RxString searchQuery,
required List<dynamic> allItems,
required RxList<String> selectedItems,
required Function(String) onToggle,
}) {
final filteredList = allItems.where((item) {
if (searchQuery.isEmpty) return true;
return item.name.toLowerCase().contains(searchQuery.value.toLowerCase());
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
TextField(
onChanged: (value) => searchQuery.value = value,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
hintText: "Search $title...",
hintStyle: const TextStyle(fontSize: 13),
prefixIcon: const Icon(Icons.search, size: 18),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
filled: true,
fillColor: Colors.grey.shade100,
),
),
const SizedBox(height: 8),
if (filteredList.isEmpty)
Row(
children: [
const Icon(Icons.sentiment_dissatisfied, color: Colors.grey),
const SizedBox(width: 10),
MyText("No results found.",
color: Colors.grey.shade600, fontSize: 14),
],
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 230),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: filteredList.length,
itemBuilder: (context, index) {
final item = filteredList[index];
final isSelected = selectedItems.contains(item.id);
return GestureDetector(
onTap: () => onToggle(item.id),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color:
isSelected ? Colors.blueAccent : Colors.white,
border: Border.all(
color: Colors.black,
width: 1.2,
),
borderRadius: BorderRadius.circular(4),
),
child: isSelected
? const Icon(Icons.check,
size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 8),
MyText(item.name, fontSize: 14),
],
),
),
);
},
),
)
],
);
}
}