From 1fafe77211560a961d4b5ec193e3706ede959e48 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 18 Sep 2025 12:30:44 +0530 Subject: [PATCH] feat: Enhance document filtering; implement multi-select support and add date range filters --- .../document/user_document_controller.dart | 36 +- .../user_document_filter_bottom_sheet.dart | 402 +++++++++++++++--- lib/view/document/user_document_screen.dart | 14 +- 3 files changed, 373 insertions(+), 79 deletions(-) diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index c93b5c7..f5f070e 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -10,12 +10,11 @@ class DocumentController extends GetxController { var documents = [].obs; var filters = Rxn(); - // Selected filters - var selectedFilter = "".obs; - var selectedUploadedBy = "".obs; - var selectedCategory = "".obs; - var selectedType = "".obs; - var selectedTag = "".obs; + // ✅ Selected filters (multi-select support) + var selectedUploadedBy = [].obs; + var selectedCategory = [].obs; + var selectedType = [].obs; + var selectedTag = [].obs; // Pagination state var pageNumber = 1.obs; @@ -31,6 +30,11 @@ class DocumentController extends GetxController { // NEW: search var searchQuery = ''.obs; var searchController = TextEditingController(); +// New filter fields + var isUploadedAt = true.obs; + var isVerified = RxnBool(); + var startDate = Rxn(); + var endDate = Rxn(); // ------------------ API Calls ----------------------- @@ -157,17 +161,21 @@ class DocumentController extends GetxController { /// Clear selected filters void clearFilters() { - selectedUploadedBy.value = ""; - selectedCategory.value = ""; - selectedType.value = ""; - selectedTag.value = ""; + selectedUploadedBy.clear(); + selectedCategory.clear(); + selectedType.clear(); + selectedTag.clear(); + isUploadedAt.value = true; + isVerified.value = null; + startDate.value = null; + endDate.value = null; } /// Check if any filters are active (for red dot indicator) bool hasActiveFilters() { - return selectedUploadedBy.value.isNotEmpty || - selectedCategory.value.isNotEmpty || - selectedType.value.isNotEmpty || - selectedTag.value.isNotEmpty; + return selectedUploadedBy.isNotEmpty || + selectedCategory.isNotEmpty || + selectedType.isNotEmpty || + selectedTag.isNotEmpty; } } diff --git a/lib/model/document/user_document_filter_bottom_sheet.dart b/lib/model/document/user_document_filter_bottom_sheet.dart index 0eedf48..d6fa01f 100644 --- a/lib/model/document/user_document_filter_bottom_sheet.dart +++ b/lib/model/document/user_document_filter_bottom_sheet.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/document/document_filter_model.dart'; +import 'dart:convert'; class UserDocumentFilterBottomSheet extends StatelessWidget { final String entityId; @@ -36,15 +38,21 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { onCancel: () => Get.back(), onSubmit: () { final combinedFilter = { - 'uploadedBy': docController.selectedUploadedBy.value, - 'category': docController.selectedCategory.value, - 'type': docController.selectedType.value, - 'tag': docController.selectedTag.value, + 'uploadedByIds': docController.selectedUploadedBy.toList(), + 'documentCategoryIds': docController.selectedCategory.toList(), + 'documentTypeIds': docController.selectedType.toList(), + 'documentTagIds': docController.selectedTag.toList(), + 'isUploadedAt': docController.isUploadedAt.value, + 'startDate': docController.startDate.value, + 'endDate': docController.endDate.value, + if (docController.isVerified.value != null) + 'isVerified': docController.isVerified.value, }; + docController.fetchDocuments( entityTypeId: entityTypeId, entityId: entityId, - filter: combinedFilter.toString(), + filter: jsonEncode(combinedFilter), reset: true, ); Get.back(); @@ -67,32 +75,209 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ), ), ), + // --- Date Filter (Uploaded On / Updated On) --- + _buildField( + "Choose Date", + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Segmented Buttons + Obx(() { + return Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => + docController.isUploadedAt.value = true, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10), + decoration: BoxDecoration( + color: docController.isUploadedAt.value + ? Colors.indigo.shade400 + : Colors.transparent, + borderRadius: + const BorderRadius.horizontal( + left: Radius.circular(24), + ), + ), + child: Center( + child: MyText( + "Uploaded On", + style: MyTextStyle.bodyMedium( + color: + docController.isUploadedAt.value + ? Colors.white + : Colors.black87, + fontWeight: 600, + ), + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => docController + .isUploadedAt.value = false, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10), + decoration: BoxDecoration( + color: !docController.isUploadedAt.value + ? Colors.indigo.shade400 + : Colors.transparent, + borderRadius: + const BorderRadius.horizontal( + right: Radius.circular(24), + ), + ), + child: Center( + child: MyText( + "Updated On", + style: MyTextStyle.bodyMedium( + color: !docController + .isUploadedAt.value + ? Colors.white + : Colors.black87, + fontWeight: 600, + ), + ), + ), + ), + ), + ), + ], + ), + ); + }), + MySpacing.height(12), + + // Date Range + Row( + children: [ + Expanded( + child: Obx(() { + return _dateButton( + label: docController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + DateTime.parse( + docController.startDate.value!), + 'dd MMM yyyy', + ), + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + if (picked != null) { + docController.startDate.value = + picked.toIso8601String(); + } + }, + ); + }), + ), + MySpacing.width(12), + Expanded( + child: Obx(() { + return _dateButton( + label: docController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + DateTime.parse( + docController.endDate.value!), + 'dd MMM yyyy', + ), + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + if (picked != null) { + docController.endDate.value = + picked.toIso8601String(); + } + }, + ); + }), + ), + ], + ), + ], + ), + ), + MySpacing.height(8), - _buildDynamicField( + _multiSelectField( label: "Uploaded By", items: filterData.uploadedBy, fallback: "Select Uploaded By", - selectedValue: docController.selectedUploadedBy, + selectedValues: docController.selectedUploadedBy, ), - _buildDynamicField( + _multiSelectField( label: "Category", items: filterData.documentCategory, fallback: "Select Category", - selectedValue: docController.selectedCategory, + selectedValues: docController.selectedCategory, ), - _buildDynamicField( + _multiSelectField( label: "Type", items: filterData.documentType, fallback: "Select Type", - selectedValue: docController.selectedType, + selectedValues: docController.selectedType, ), - _buildDynamicField( + _multiSelectField( label: "Tag", items: filterData.documentTag, fallback: "Select Tag", - selectedValue: docController.selectedTag, + selectedValues: docController.selectedTag, ), - ].where((w) => w != null).cast().toList(), + + // --- Verified Toggle --- + _buildField( + "Only Verified", + Obx(() { + return Container( + padding: MySpacing.xy(12, 6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText( + "Verified Documents Only", + style: MyTextStyle.bodyMedium(), + ), + Switch( + value: docController.isVerified.value ?? false, + onChanged: (val) { + docController.isVerified.value = + val ? true : null; + }, + activeColor: Colors.indigo, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ); + }), + ), + ], ) : Center( child: Padding( @@ -110,70 +295,161 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ); } - Widget? _buildDynamicField({ + Widget _multiSelectField({ required String label, required List items, required String fallback, - required RxString selectedValue, + required RxList selectedValues, }) { - if (items.isEmpty) return null; + if (items.isEmpty) return const SizedBox.shrink(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium(label), MySpacing.height(8), - _popupSelector(items, fallback, selectedValue: selectedValue), + Obx(() { + final selectedNames = items + .where((f) => selectedValues.contains(f.id)) + .map((f) => f.name) + .join(", "); + final displayText = + selectedNames.isNotEmpty ? selectedNames : fallback; + + return Builder( + builder: (context) { + return GestureDetector( + onTap: () async { + final RenderBox button = + context.findRenderObject() as RenderBox; + final RenderBox overlay = Overlay.of(context) + .context + .findRenderObject() as RenderBox; + + final position = button.localToGlobal(Offset.zero); + + await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ), + items: items.map( + (f) { + return PopupMenuItem( + enabled: false, // prevent auto-close + child: StatefulBuilder( + builder: (context, setState) { + final isChecked = selectedValues.contains(f.id); + return CheckboxListTile( + dense: true, + value: isChecked, + contentPadding: EdgeInsets.zero, + controlAffinity: + ListTileControlAffinity.leading, + title: MyText(f.name), + + // --- Styles --- + checkColor: Colors.white, // tick color + side: const BorderSide( + color: Colors.black, + width: 1.5), // border when unchecked + + fillColor: + MaterialStateProperty.resolveWith( + (states) { + if (states + .contains(MaterialState.selected)) { + return Colors.indigo; // checked → Indigo + } + return Colors.white; // unchecked → White + }, + ), + + onChanged: (val) { + if (val == true) { + selectedValues.add(f.id); + } else { + selectedValues.remove(f.id); + } + setState(() {}); // refresh UI + }, + ); + }, + ), + ); + }, + ).toList(), + ); + }, + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText( + displayText, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + }, + ); + }), MySpacing.height(16), ], ); } - Widget _popupSelector( - List items, - String fallback, { - required RxString selectedValue, - }) { - return Obx(() { - final currentValue = _getCurrentName(selectedValue.value, items, fallback); - return PopupMenuButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onSelected: (val) => selectedValue.value = val, - itemBuilder: (context) => items - .map( - (f) => PopupMenuItem( - value: f.id, - child: MyText(f.name), - ), - ) - .toList(), - child: Container( - padding: MySpacing.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: MyText( - currentValue, - style: const TextStyle(color: Colors.black87), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.arrow_drop_down, color: Colors.grey), - ], - ), - ), - ); - }); + Widget _buildField(String label, Widget child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + child, + MySpacing.height(8), + ], + ); } - String _getCurrentName(String selectedId, List list, String fallback) { - if (selectedId.isEmpty) return fallback; - final match = list.firstWhereOrNull((f) => f.id == selectedId); - return match?.name ?? fallback; + Widget _dateButton({required String label, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + MySpacing.width(8), + Expanded( + child: MyText( + label, + style: MyTextStyle.bodyMedium(), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); } } diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index a33651e..dadada1 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -17,6 +17,8 @@ import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/permission_controller.dart'; +import 'dart:convert'; + class UserDocumentsPage extends StatefulWidget { final String? entityId; @@ -504,10 +506,18 @@ class _UserDocumentsPageState extends State { Expanded( child: MyRefreshIndicator( onRefresh: () async { + final combinedFilter = { + 'uploadedByIds': docController.selectedUploadedBy.toList(), + 'documentCategoryIds': + docController.selectedCategory.toList(), + 'documentTypeIds': docController.selectedType.toList(), + 'documentTagIds': docController.selectedTag.toList(), + }; + await docController.fetchDocuments( entityTypeId: entityTypeId, entityId: resolvedEntityId, - filter: docController.selectedFilter.value, + filter: jsonEncode(combinedFilter), reset: true, ); }, @@ -577,7 +587,7 @@ class _UserDocumentsPageState extends State { backgroundColor: Colors.transparent, builder: (_) => DocumentUploadBottomSheet( isEmployee: - widget.isEmployee, // 👈 Pass the employee flag here + widget.isEmployee, onSubmit: (data) async { final success = await uploadController.uploadDocument( name: data["name"],