feat: Enhance document filtering; implement multi-select support and add date range filters

This commit is contained in:
Vaibhav Surve 2025-09-18 12:30:44 +05:30
parent 25b20fedda
commit 1fafe77211
3 changed files with 373 additions and 79 deletions

View File

@ -10,12 +10,11 @@ class DocumentController extends GetxController {
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// Selected filters
var selectedFilter = "".obs;
var selectedUploadedBy = "".obs;
var selectedCategory = "".obs;
var selectedType = "".obs;
var selectedTag = "".obs;
// Selected filters (multi-select support)
var selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs;
var selectedType = <String>[].obs;
var selectedTag = <String>[].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<String>();
var endDate = Rxn<String>();
// ------------------ 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;
}
}

View File

@ -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<Widget>().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,42 +295,95 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
);
}
Widget? _buildDynamicField({
Widget _multiSelectField({
required String label,
required List<FilterItem> items,
required String fallback,
required RxString selectedValue,
required RxList<String> 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),
MySpacing.height(16),
],
);
}
Obx(() {
final selectedNames = items
.where((f) => selectedValues.contains(f.id))
.map((f) => f.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
Widget _popupSelector(
List<FilterItem> items,
String fallback, {
required RxString selectedValue,
}) {
return Obx(() {
final currentValue = _getCurrentName(selectedValue.value, items, fallback);
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (val) => selectedValue.value = val,
itemBuilder: (context) => items
.map(
(f) => PopupMenuItem<String>(
value: f.id,
child: MyText(f.name),
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,
),
)
.toList(),
items: items.map(
(f) {
return PopupMenuItem<String>(
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<Color>(
(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(
@ -158,7 +396,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
children: [
Expanded(
child: MyText(
currentValue,
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
@ -168,12 +406,50 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
),
),
);
});
},
);
}),
MySpacing.height(16),
],
);
}
String _getCurrentName(String selectedId, List<FilterItem> list, String fallback) {
if (selectedId.isEmpty) return fallback;
final match = list.firstWhereOrNull((f) => f.id == selectedId);
return match?.name ?? fallback;
Widget _buildField(String label, Widget child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
child,
MySpacing.height(8),
],
);
}
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,
),
),
],
),
),
);
}
}

View File

@ -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<UserDocumentsPage> {
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<UserDocumentsPage> {
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"],