feat: Refactor DirectoryFilterBottomSheet to manage state and improve filter functionality
This commit is contained in:
parent
7ce07c9b47
commit
0f14fda83a
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user