Refactor attendance and document controllers to use reactive date ranges, implement reusable DateRangePickerWidget, and enhance filter functionality in attendance and expense screens.

- Updated AttendanceController to use Rx<DateTime> for date ranges.
- Introduced DateRangePickerWidget for selecting date ranges in attendance and expense filters.
- Refactored attendance filter bottom sheet to utilize the new DateRangePickerWidget.
- Enhanced user document filter bottom sheet with date range selection.
- Improved expense filter bottom sheet to include date range selection and refactored UI components for better readability.
- Cleaned up unused code and improved overall code structure for maintainability.
This commit is contained in:
Vaibhav Surve 2025-11-04 14:15:45 +05:30
parent b33b3da6c0
commit 03b82764ed
8 changed files with 430 additions and 494 deletions

View File

@ -20,22 +20,27 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart'
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// Data models // ------------------ Data Models ------------------
List<AttendanceModel> attendances = []; List<AttendanceModel> attendances = [];
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = []; List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = []; List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = []; List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
List<Organization> organizations = []; List<Organization> organizations = [];
Organization? selectedOrganization; Organization? selectedOrganization;
final isLoadingOrganizations = false.obs; final isLoadingOrganizations = false.obs;
// States // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance; // Reactive date range
final Rx<DateTime> startDateAttendance =
DateTime.now().subtract(const Duration(days: 7)).obs;
final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs;
final isLoading = true.obs; final isLoading = true.obs;
final isLoadingProjects = true.obs; final isLoadingProjects = true.obs;
@ -46,6 +51,8 @@ class AttendanceController extends GetxController {
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs; var showPendingOnly = false.obs;
final searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -58,14 +65,38 @@ class AttendanceController extends GetxController {
void _setDefaultDateRange() { void _setDefaultDateRange() {
final today = DateTime.now(); final today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7)); startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1)); endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe( logSafe(
"Default date range set: $startDateAttendance to $endDateAttendance"); "Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}");
} }
// ------------------ Project & Employee ------------------ // ------------------ Computed Filters ------------------
/// Called when a notification says attendance has been updated List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// ------------------ Project & Employee APIs ------------------
Future<void> refreshDataFromNotification({String? projectId}) async { Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) { if (projectId == null) {
@ -78,36 +109,6 @@ class AttendanceController extends GetxController {
"Attendance data refreshed from notification for project $projectId"); "Attendance data refreshed from notification for project $projectId");
} }
// 🔍 Search query
final searchQuery = ''.obs;
// Computed filtered employees
List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered logs
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
Future<void> fetchTodaysAttendance(String? projectId) async { Future<void> fetchTodaysAttendance(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -127,6 +128,7 @@ class AttendanceController extends GetxController {
logSafe("Failed to fetch employees for project $projectId", logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error); level: LogLevel.error);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update(); update();
} }
@ -146,7 +148,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ Attendance Capture ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
String id, String id,
String employeeId, String employeeId,
@ -154,8 +155,8 @@ class AttendanceController extends GetxController {
String comment = "Marked via mobile app", String comment = "Marked via mobile app",
required int action, required int action,
bool imageCapture = true, bool imageCapture = true,
String? markTime, // still optional in controller String? markTime,
String? date, // new optional param String? date,
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; uploadingStates[employeeId]?.value = true;
@ -169,7 +170,6 @@ class AttendanceController extends GetxController {
return false; return false;
} }
// 🔹 Add timestamp to the image
final timestampedFile = await TimestampImageHelper.addTimestamp( final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path)); imageFile: File(image.path));
@ -192,29 +192,20 @@ class AttendanceController extends GetxController {
? ApiService.generateImageName(employeeId, employees.length + 1) ? ApiService.generateImageName(employeeId, employees.length + 1)
: ""; : "";
// ---------------- DATE / TIME LOGIC ----------------
final now = DateTime.now(); final now = DateTime.now();
// Default effectiveDate = now
DateTime effectiveDate = now; DateTime effectiveDate = now;
if (action == 1) { if (action == 1) {
// Checkout
// Try to find today's open log for this employee
final log = attendanceLogs.firstWhereOrNull( final log = attendanceLogs.firstWhereOrNull(
(log) => log.employeeId == employeeId && log.checkOut == null, (log) => log.employeeId == employeeId && log.checkOut == null,
); );
if (log?.checkIn != null) { if (log?.checkIn != null) effectiveDate = log!.checkIn!;
effectiveDate = log!.checkIn!; // use check-in date
}
} }
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now); final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
final formattedDate = final formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate); date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
// ---------------- API CALL ----------------
final result = await ApiService.uploadAttendanceImage( final result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
@ -263,7 +254,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId, Future<void> fetchAttendanceLogs(String? projectId,
{DateTime? dateFrom, DateTime? dateTo}) async { {DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return; if (projectId == null) return;
@ -312,7 +302,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ Regularization Logs ------------------ // ------------------ Regularization Logs ------------------
Future<void> fetchRegularizationLogs(String? projectId) async { Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
@ -336,7 +325,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ Attendance Log View ------------------ // ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async { Future<void> fetchLogsView(String? id) async {
if (id == null) return; if (id == null) return;
@ -359,7 +347,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ Combined Load ------------------ // ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async { Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true; isLoading.value = true;
await fetchProjectData(projectId); await fetchProjectData(projectId);
@ -371,7 +358,6 @@ class AttendanceController extends GetxController {
await fetchOrganizations(projectId); await fetchOrganizations(projectId);
// Call APIs depending on the selected tab only
switch (selectedTab) { switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await fetchTodaysAttendance(projectId); await fetchTodaysAttendance(projectId);
@ -379,8 +365,8 @@ class AttendanceController extends GetxController {
case 'attendanceLogs': case 'attendanceLogs':
await fetchAttendanceLogs( await fetchAttendanceLogs(
projectId, projectId,
dateFrom: startDateAttendance, dateFrom: startDateAttendance.value,
dateTo: endDateAttendance, dateTo: endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -394,7 +380,6 @@ class AttendanceController extends GetxController {
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance( Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async { BuildContext context, AttendanceController controller) async {
final today = DateTime.now(); final today = DateTime.now();
@ -404,16 +389,17 @@ class AttendanceController extends GetxController {
firstDate: DateTime(2022), firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)), lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange( initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)), start: startDateAttendance.value,
end: endDateAttendance ?? today.subtract(const Duration(days: 1)), end: endDateAttendance.value,
), ),
); );
if (picked != null) { if (picked != null) {
startDateAttendance = picked.start; startDateAttendance.value = picked.start;
endDateAttendance = picked.end; endDateAttendance.value = picked.end;
logSafe( logSafe(
"Date range selected: $startDateAttendance to $endDateAttendance"); "Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}");
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,

View File

@ -34,8 +34,8 @@ class DocumentController extends GetxController {
// Additional filters // Additional filters
final isUploadedAt = true.obs; final isUploadedAt = true.obs;
final isVerified = RxnBool(); final isVerified = RxnBool();
final startDate = Rxn<String>(); final startDate = Rxn<DateTime>();
final endDate = Rxn<String>(); final endDate = Rxn<DateTime>();
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================

View File

@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/utils.dart';
import 'package:marco/helpers/widgets/my_text.dart';
typedef OnDateRangeSelected = void Function(DateTime? start, DateTime? end);
class DateRangePickerWidget extends StatefulWidget {
final Rx<DateTime?> startDate;
final Rx<DateTime?> endDate;
final OnDateRangeSelected? onDateRangeSelected;
final String? startLabel;
final String? endLabel;
const DateRangePickerWidget({
Key? key,
required this.startDate,
required this.endDate,
this.onDateRangeSelected,
this.startLabel,
this.endLabel,
});
@override
State<DateRangePickerWidget> createState() => _DateRangePickerWidgetState();
}
class _DateRangePickerWidgetState extends State<DateRangePickerWidget>
with UIMixin {
Future<void> _selectDate(BuildContext context, bool isStartDate) async {
final current = isStartDate
? widget.startDate.value ?? DateTime.now()
: widget.endDate.value ?? DateTime.now();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: current,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
builder: (context, child) => Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: contentTheme.primary,
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
),
);
if (picked != null) {
if (isStartDate) {
widget.startDate.value = picked;
} else {
widget.endDate.value = picked;
}
if (widget.onDateRangeSelected != null) {
widget.onDateRangeSelected!(
widget.startDate.value, widget.endDate.value);
}
}
}
Widget _dateBox({
required BuildContext context,
required String label,
required Rx<DateTime?> date,
required bool isStart,
}) {
return Expanded(
child: Obx(() {
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _selectDate(context, isStart),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: contentTheme.primary.withOpacity(0.08),
border: Border.all(color: contentTheme.primary.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: contentTheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isStart
? Icons.calendar_today_outlined
: Icons.event_outlined,
size: 14,
color: contentTheme.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText(label, fontSize: 10, fontWeight: 500),
const SizedBox(height: 2),
MyText(
date.value != null
? Utils.formatDate(date.value!)
: 'Not selected',
fontWeight: 600,
color: contentTheme.primary,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}),
);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
_dateBox(
context: context,
label: widget.startLabel ?? 'Start Date',
date: widget.startDate,
isStart: true,
),
const SizedBox(width: 8),
_dateBox(
context: context,
label: widget.endLabel ?? 'End Date',
date: widget.endDate,
isStart: false,
),
],
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
@ -34,15 +35,11 @@ class _AttendanceFilterBottomSheetState
} }
String getLabelText() { String getLabelText() {
final startDate = widget.controller.startDateAttendance; final start = DateTimeUtils.formatDate(
final endDate = widget.controller.endDateAttendance; widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
if (startDate != null && endDate != null) { widget.controller.endDateAttendance.value, 'dd MMM yyyy');
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy'); return "$start - $end";
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end";
}
return "Date Range";
} }
List<Widget> buildMainFilters() { List<Widget> buildMainFilters() {
@ -61,6 +58,7 @@ class _AttendanceFilterBottomSheetState
}).toList(); }).toList();
final List<Widget> widgets = [ final List<Widget> widgets = [
// 🔹 View Section
Padding( Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Align( child: Align(
@ -82,8 +80,7 @@ class _AttendanceFilterBottomSheetState
); );
}), }),
]; ];
// 🔹 Date Range (only for Attendance Logs)
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
@ -94,37 +91,16 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
), ),
), ),
InkWell( // Reusable DateRangePickerWidget
borderRadius: BorderRadius.circular(10), DateRangePickerWidget(
onTap: () async { startDate: widget.controller.startDateAttendance,
await widget.controller.selectDateRangeForAttendance( endDate: widget.controller.endDateAttendance,
context, startLabel: "Start Date",
widget.controller, endLabel: "End Date",
); onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
setState(() {}); setState(() {});
}, },
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
const Icon(Icons.date_range, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: MyText.bodyMedium(
getLabelText(),
fontWeight: 500,
color: Colors.black87,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.black87),
],
),
),
), ),
]); ]);
} }

View File

@ -2,24 +2,33 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart'; import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/document_filter_model.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class UserDocumentFilterBottomSheet extends StatelessWidget { class UserDocumentFilterBottomSheet extends StatefulWidget {
final String entityId; final String entityId;
final String entityTypeId; final String entityTypeId;
final DocumentController docController = Get.find<DocumentController>();
UserDocumentFilterBottomSheet({ const UserDocumentFilterBottomSheet({
super.key, super.key,
required this.entityId, required this.entityId,
required this.entityTypeId, required this.entityTypeId,
}); });
@override
State<UserDocumentFilterBottomSheet> createState() =>
_UserDocumentFilterBottomSheetState();
}
class _UserDocumentFilterBottomSheetState
extends State<UserDocumentFilterBottomSheet> with UIMixin {
final DocumentController docController = Get.find<DocumentController>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = docController.filters.value; final filterData = docController.filters.value;
@ -51,8 +60,8 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
}; };
docController.fetchDocuments( docController.fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: widget.entityTypeId,
entityId: entityId, entityId: widget.entityId,
filter: jsonEncode(combinedFilter), filter: jsonEncode(combinedFilter),
reset: true, reset: true,
); );
@ -76,144 +85,64 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
), ),
), ),
), ),
// --- Date Filter (Uploaded On / Updated On) --- // --- Date Range using Radio Buttons on Same Row ---
_buildField( _buildField(
"Choose Date", "Choose Date",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Segmented Buttons
Obx(() { Obx(() {
return Container( return Row(
decoration: BoxDecoration( children: [
border: Border.all(color: Colors.grey.shade300), // --- Upload Date ---
borderRadius: BorderRadius.circular(24), Expanded(
), child: Row(
child: Row( children: [
children: [ Radio<bool>(
Expanded( value: true,
child: GestureDetector( groupValue:
onTap: () => docController.isUploadedAt.value,
docController.isUploadedAt.value = true, onChanged: (val) => docController
child: Container( .isUploadedAt.value = val!,
padding: const EdgeInsets.symmetric( activeColor: contentTheme.primary,
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(
"Upload Date",
style: MyTextStyle.bodyMedium(
color:
docController.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
), ),
), MyText("Upload Date"),
],
), ),
Expanded( ),
child: GestureDetector( // --- Update Date ---
onTap: () => docController Expanded(
.isUploadedAt.value = false, child: Row(
child: Container( children: [
padding: const EdgeInsets.symmetric( Radio<bool>(
vertical: 10), value: false,
decoration: BoxDecoration( groupValue:
color: !docController.isUploadedAt.value docController.isUploadedAt.value,
? Colors.indigo.shade400 onChanged: (val) => docController
: Colors.transparent, .isUploadedAt.value = val!,
borderRadius: activeColor: contentTheme.primary,
const BorderRadius.horizontal(
right: Radius.circular(24),
),
),
child: Center(
child: MyText(
"Update Date",
style: MyTextStyle.bodyMedium(
color: !docController
.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
), ),
), MyText("Update Date"),
],
), ),
], ),
), ],
); );
}), }),
MySpacing.height(12), MySpacing.height(12),
// Date Range // --- Date Range Picker ---
Row( DateRangePickerWidget(
children: [ startDate: docController.startDate,
Expanded( endDate: docController.endDate,
child: Obx(() { startLabel: "From Date",
return _dateButton( endLabel: "To Date",
label: docController.startDate.value == null onDateRangeSelected: (start, end) {
? 'From Date' if (start != null && end != null) {
: DateTimeUtils.formatDate( docController.startDate.value = start;
DateTime.parse( docController.endDate.value = end;
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
? 'To 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();
}
},
);
}),
),
],
), ),
], ],
), ),
@ -251,7 +180,6 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
Obx(() { Obx(() {
return Container( return Container(
padding: MySpacing.all(12), padding: MySpacing.all(12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -263,8 +191,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: activeColor: contentTheme.primary,
Colors.indigo,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -279,7 +206,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -294,7 +221,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value, groupValue: docController.isVerified.value,
onChanged: (val) => onChanged: (val) =>
docController.isVerified.value = val, docController.isVerified.value = val,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
materialTapTargetSize: materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
), ),
@ -391,7 +318,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
(states) { (states) {
if (states if (states
.contains(MaterialState.selected)) { .contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo return contentTheme.primary;
} }
return Colors.white; // unchecked White return Colors.white; // unchecked White
}, },
@ -454,31 +381,4 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
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

@ -104,9 +104,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
// Filter logs if "pending only" // Filter logs if "pending only"
final showPendingOnly = widget.controller.showPendingOnly.value; final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly final filteredLogs = showPendingOnly
? allLogs ? allLogs.where((emp) => emp.activity == 1).toList()
.where((emp) => emp.activity == 1 )
.toList()
: allLogs; : allLogs;
// Group logs by date string // Group logs by date string
@ -126,11 +124,9 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
return db.compareTo(da); return db.compareTo(da);
}); });
final dateRangeText = widget.controller.startDateAttendance != null && final dateRangeText =
widget.controller.endDateAttendance != null '${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - ' '${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
: 'Select date range';
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -34,12 +34,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Listen for future project selection changes // 🔁 Listen for project changes
ever<String>(projectController.selectedProjectId, (projectId) async { ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId); if (projectId.isNotEmpty) await _loadData(projectId);
}); });
// Load initial data // 🚀 Load initial data only once the screen is shown
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId); if (projectId.isNotEmpty) _loadData(projectId);
}); });
@ -67,8 +67,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
case 'attendanceLogs': case 'attendanceLogs':
await attendanceController.fetchAttendanceLogs( await attendanceController.fetchAttendanceLogs(
projectId, projectId,
dateFrom: attendanceController.startDateAttendance, dateFrom: attendanceController.startDateAttendance.value,
dateTo: attendanceController.endDateAttendance, dateTo: attendanceController.endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -402,4 +402,13 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
), ),
); );
} }
@override
void dispose() {
// 🧹 Clean up the controller when user leaves this screen
if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>();
}
super.dispose();
}
} }

View File

@ -1,15 +1,18 @@
// ignore_for_file: must_be_immutable
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class ExpenseFilterBottomSheet extends StatelessWidget { class ExpenseFilterBottomSheet extends StatefulWidget {
final ExpenseController expenseController; final ExpenseController expenseController;
final ScrollController scrollController; final ScrollController scrollController;
@ -19,12 +22,18 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
required this.scrollController, required this.scrollController,
}); });
// FIX: create search adapter @override
State<ExpenseFilterBottomSheet> createState() =>
_ExpenseFilterBottomSheetState();
}
class _ExpenseFilterBottomSheetState extends State<ExpenseFilterBottomSheet>
with UIMixin {
/// Search employees for Paid By / Created By filters
Future<List<EmployeeModel>> searchEmployeesForBottomSheet( Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
String query) async { String query) async {
await expenseController await widget.expenseController.searchEmployees(query);
.searchEmployees(query); // async method, returns void return widget.expenseController.employeeSearchResults.toList();
return expenseController.employeeSearchResults.toList();
} }
@override @override
@ -34,20 +43,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
title: 'Filter Expenses', title: 'Filter Expenses',
onCancel: () => Get.back(), onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
expenseController.fetchExpenses(); widget.expenseController.fetchExpenses();
Get.back(); Get.back();
}, },
submitText: 'Submit', submitText: 'Submit',
submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline, submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, controller: widget.scrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(
onPressed: () => expenseController.clearFilters(), onPressed: () => widget.expenseController.clearFilters(),
child: MyText( child: MyText(
"Reset Filter", "Reset Filter",
style: MyTextStyle.labelMedium( style: MyTextStyle.labelMedium(
@ -58,15 +68,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
_buildProjectFilter(context), _buildProjectFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildStatusFilter(context), _buildStatusFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildDateRangeFilter(context), _buildDateRangeFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildPaidByFilter(context), _buildPaidByFilter(),
MySpacing.height(16), MySpacing.height(16),
_buildCreatedByFilter(context), _buildCreatedByFilter(),
], ],
), ),
), ),
@ -85,190 +95,145 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
Widget _buildProjectFilter(BuildContext context) { Widget _buildProjectFilter() {
return _buildField( return _buildField(
"Project", "Project",
_popupSelector( _popupSelector(
context, currentValue: widget.expenseController.selectedProject.value.isEmpty
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project' ? 'Select Project'
: expenseController.selectedProject.value, : widget.expenseController.selectedProject.value,
items: expenseController.globalProjects, items: widget.expenseController.globalProjects,
onSelected: (value) => expenseController.selectedProject.value = value, onSelected: (value) =>
widget.expenseController.selectedProject.value = value,
), ),
); );
} }
Widget _buildStatusFilter(BuildContext context) { Widget _buildStatusFilter() {
return _buildField( return _buildField(
"Expense Status", "Expense Status",
_popupSelector( _popupSelector(
context, currentValue: widget.expenseController.selectedStatus.value.isEmpty
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status' ? 'Select Expense Status'
: expenseController.expenseStatuses : widget.expenseController.expenseStatuses
.firstWhereOrNull( .firstWhereOrNull((e) =>
(e) => e.id == expenseController.selectedStatus.value) e.id == widget.expenseController.selectedStatus.value)
?.name ?? ?.name ??
'Select Expense Status', 'Select Expense Status',
items: expenseController.expenseStatuses.map((e) => e.name).toList(), items: widget.expenseController.expenseStatuses
.map((e) => e.name)
.toList(),
onSelected: (name) { onSelected: (name) {
final status = expenseController.expenseStatuses final status = widget.expenseController.expenseStatuses
.firstWhere((e) => e.name == name); .firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id; widget.expenseController.selectedStatus.value = status.id;
}, },
), ),
); );
} }
Widget _buildDateRangeFilter(BuildContext context) { Widget _buildDateRangeFilter() {
return _buildField( return _buildField(
"Date Filter", "Date Filter",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- Radio Buttons for Transaction Date / Created At ---
Obx(() { Obx(() {
return SizedBox( return Row(
width: double.infinity, // Make it full width children: [
child: SegmentedButton<String>( // --- Transaction Date ---
segments: expenseController.dateTypes Expanded(
.map( child: Row(
(type) => ButtonSegment( children: [
value: type, Radio<String>(
label: Center( value: "Transaction Date",
// Center label text groupValue:
child: MyText( widget.expenseController.selectedDateType.value,
type, onChanged: (val) {
style: MyTextStyle.bodySmall( if (val != null) {
fontWeight: 600, widget.expenseController.selectedDateType.value =
fontSize: 13, val;
height: 1.2, }
), },
), activeColor: contentTheme.primary,
),
Flexible(
child: MyText(
"Transaction Date",
), ),
), ),
) ],
.toList(),
selected: {expenseController.selectedDateType.value},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty) {
expenseController.selectedDateType.value =
newSelection.first;
}
},
style: ButtonStyle(
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
backgroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo.shade100
: Colors.grey.shade100,
),
foregroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo
: Colors.black87,
),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
side: MaterialStateProperty.resolveWith(
(states) => BorderSide(
color: states.contains(MaterialState.selected)
? Colors.indigo
: Colors.grey.shade300,
width: 1,
),
), ),
), ),
), // --- Created At ---
Expanded(
child: Row(
children: [
Radio<String>(
value: "Created At",
groupValue:
widget.expenseController.selectedDateType.value,
onChanged: (val) {
if (val != null) {
widget.expenseController.selectedDateType.value =
val;
}
},
activeColor: contentTheme.primary,
),
Flexible(
child: MyText(
"Created At",
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
); );
}), }),
MySpacing.height(16), MySpacing.height(16),
Row( // --- Reusable Date Range Picker ---
children: [ DateRangePickerWidget(
Expanded( startDate: widget.expenseController.startDate,
child: _dateButton( endDate: widget.expenseController.endDate,
label: expenseController.startDate.value == null startLabel: "Start Date",
? 'Start Date' endLabel: "End Date",
: DateTimeUtils.formatDate( onDateRangeSelected: (start, end) {
expenseController.startDate.value!, 'dd MMM yyyy'), widget.expenseController.startDate.value = start;
onTap: () => _selectDate( widget.expenseController.endDate.value = end;
context, },
expenseController.startDate,
lastDate: expenseController.endDate.value,
),
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.endDate,
firstDate: expenseController.startDate.value,
),
),
),
],
), ),
], ],
), ),
); );
} }
Widget _buildPaidByFilter(BuildContext context) { Widget _buildPaidByFilter() {
return _buildField( return _buildField(
"Paid By", "Paid By",
_employeeSelector( _employeeSelector(
context: context, selectedEmployees: widget.expenseController.selectedPaidByEmployees,
selectedEmployees: expenseController.selectedPaidByEmployees, searchEmployees: searchEmployeesForBottomSheet,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Paid By', title: 'Search Paid By',
), ),
); );
} }
Widget _buildCreatedByFilter(BuildContext context) { Widget _buildCreatedByFilter() {
return _buildField( return _buildField(
"Created By", "Created By",
_employeeSelector( _employeeSelector(
context: context, selectedEmployees: widget.expenseController.selectedCreatedByEmployees,
selectedEmployees: expenseController.selectedCreatedByEmployees, searchEmployees: searchEmployeesForBottomSheet,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Created By', title: 'Search Created By',
), ),
); );
} }
Future<void> _selectDate( Widget _popupSelector({
BuildContext context,
Rx<DateTime?> dateNotifier, {
DateTime? firstDate,
DateTime? lastDate,
}) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: dateNotifier.value ?? DateTime.now(),
firstDate: firstDate ?? DateTime(2020),
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)),
);
if (picked != null && picked != dateNotifier.value) {
dateNotifier.value = picked;
}
}
Widget _popupSelector(
BuildContext context, {
required String currentValue, required String currentValue,
required List<String> items, required List<String> items,
required ValueChanged<String> onSelected, required ValueChanged<String> onSelected,
@ -306,59 +271,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
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,
),
),
],
),
),
);
}
Future<void> _showEmployeeSelectorBottomSheet({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Select Employee',
}) async {
final List<EmployeeModel>? result =
await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
);
if (result != null) {
selectedEmployees.assignAll(result);
}
}
Widget _employeeSelector({ Widget _employeeSelector({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees, required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees, required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Search Employee', String title = 'Search Employee',
@ -367,27 +280,37 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Obx(() { Obx(() {
if (selectedEmployees.isEmpty) { if (selectedEmployees.isEmpty) return const SizedBox.shrink();
return const SizedBox.shrink();
}
return Wrap( return Wrap(
spacing: 8, spacing: 8,
children: selectedEmployees children: selectedEmployees
.map((emp) => Chip( .map(
label: MyText(emp.name), (emp) => Chip(
onDeleted: () => selectedEmployees.remove(emp), label: MyText(emp.name),
)) onDeleted: () => selectedEmployees.remove(emp),
),
)
.toList(), .toList(),
); );
}), }),
MySpacing.height(8), MySpacing.height(8),
GestureDetector( GestureDetector(
onTap: () => _showEmployeeSelectorBottomSheet( onTap: () async {
context: context, final List<EmployeeModel>? result =
selectedEmployees: selectedEmployees, await showModalBottomSheet<List<EmployeeModel>>(
searchEmployees: searchEmployees, context: context,
title: title, isScrollControlled: true,
), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
);
if (result != null) selectedEmployees.assignAll(result);
},
child: Container( child: Container(
padding: MySpacing.all(12), padding: MySpacing.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -407,5 +330,4 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
} }