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

View File

@ -34,11 +34,11 @@ class DocumentController extends GetxController {
// Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<String>();
final endDate = Rxn<String>();
final startDate = Rxn<DateTime>();
final endDate = Rxn<DateTime>();
// ==================== Lifecycle ====================
@override
void onClose() {
// Don't dispose searchController here - it's managed by the page
@ -74,7 +74,7 @@ class DocumentController extends GetxController {
}) async {
try {
isLoading.value = true;
final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,

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/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget {
final AttendanceController controller;
@ -34,15 +35,11 @@ class _AttendanceFilterBottomSheetState
}
String getLabelText() {
final startDate = widget.controller.startDateAttendance;
final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) {
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end";
}
return "Date Range";
final start = DateTimeUtils.formatDate(
widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
widget.controller.endDateAttendance.value, 'dd MMM yyyy');
return "$start - $end";
}
List<Widget> buildMainFilters() {
@ -61,6 +58,7 @@ class _AttendanceFilterBottomSheetState
}).toList();
final List<Widget> widgets = [
// 🔹 View Section
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Align(
@ -82,8 +80,7 @@ class _AttendanceFilterBottomSheetState
);
}),
];
// 🔹 Date Range only for attendanceLogs
// 🔹 Date Range (only for Attendance Logs)
if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([
const Divider(),
@ -94,37 +91,16 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600),
),
),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
await widget.controller.selectDateRangeForAttendance(
context,
widget.controller,
);
// Reusable DateRangePickerWidget
DateRangePickerWidget(
startDate: widget.controller.startDateAttendance,
endDate: widget.controller.endDateAttendance,
startLabel: "Start Date",
endLabel: "End Date",
onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
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: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';
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 entityTypeId;
final DocumentController docController = Get.find<DocumentController>();
UserDocumentFilterBottomSheet({
const UserDocumentFilterBottomSheet({
super.key,
required this.entityId,
required this.entityTypeId,
});
@override
State<UserDocumentFilterBottomSheet> createState() =>
_UserDocumentFilterBottomSheetState();
}
class _UserDocumentFilterBottomSheetState
extends State<UserDocumentFilterBottomSheet> with UIMixin {
final DocumentController docController = Get.find<DocumentController>();
@override
Widget build(BuildContext context) {
final filterData = docController.filters.value;
@ -51,8 +60,8 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
};
docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
entityTypeId: widget.entityTypeId,
entityId: widget.entityId,
filter: jsonEncode(combinedFilter),
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(
"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(
"Upload Date",
style: MyTextStyle.bodyMedium(
color:
docController.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
return Row(
children: [
// --- Upload Date ---
Expanded(
child: Row(
children: [
Radio<bool>(
value: true,
groupValue:
docController.isUploadedAt.value,
onChanged: (val) => docController
.isUploadedAt.value = val!,
activeColor: contentTheme.primary,
),
),
MyText("Upload Date"),
],
),
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(
"Update Date",
style: MyTextStyle.bodyMedium(
color: !docController
.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
),
// --- Update Date ---
Expanded(
child: Row(
children: [
Radio<bool>(
value: false,
groupValue:
docController.isUploadedAt.value,
onChanged: (val) => docController
.isUploadedAt.value = val!,
activeColor: contentTheme.primary,
),
),
MyText("Update Date"),
],
),
],
),
),
],
);
}),
MySpacing.height(12),
// Date Range
Row(
children: [
Expanded(
child: Obx(() {
return _dateButton(
label: docController.startDate.value == null
? 'From 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
? '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();
}
},
);
}),
),
],
// --- Date Range Picker ---
DateRangePickerWidget(
startDate: docController.startDate,
endDate: docController.endDate,
startLabel: "From Date",
endLabel: "To Date",
onDateRangeSelected: (start, end) {
if (start != null && end != null) {
docController.startDate.value = start;
docController.endDate.value = end;
}
},
),
],
),
@ -251,7 +180,6 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
Obx(() {
return Container(
padding: MySpacing.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -263,8 +191,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor:
Colors.indigo,
activeColor: contentTheme.primary,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
@ -279,7 +206,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor: Colors.indigo,
activeColor: contentTheme.primary,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
@ -294,7 +221,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
groupValue: docController.isVerified.value,
onChanged: (val) =>
docController.isVerified.value = val,
activeColor: Colors.indigo,
activeColor: contentTheme.primary,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
@ -391,7 +318,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
(states) {
if (states
.contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo
return contentTheme.primary;
}
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"
final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly
? allLogs
.where((emp) => emp.activity == 1 )
.toList()
? allLogs.where((emp) => emp.activity == 1).toList()
: allLogs;
// Group logs by date string
@ -126,11 +124,9 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
return db.compareTo(da);
});
final dateRangeText = widget.controller.startDateAttendance != null &&
widget.controller.endDateAttendance != null
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
: 'Select date range';
final dateRangeText =
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -34,12 +34,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Listen for future project selection changes
// 🔁 Listen for project changes
ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId);
});
// Load initial data
// 🚀 Load initial data only once the screen is shown
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId);
});
@ -47,7 +47,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
@ -67,8 +67,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
case 'attendanceLogs':
await attendanceController.fetchAttendanceLogs(
projectId,
dateFrom: attendanceController.startDateAttendance,
dateTo: attendanceController.endDateAttendance,
dateFrom: attendanceController.startDateAttendance.value,
dateTo: attendanceController.endDateAttendance.value,
);
break;
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:get/get.dart';
import 'package:marco/controller/expense/expense_screen_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/employees/employee_model.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 ScrollController scrollController;
@ -19,12 +22,18 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
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(
String query) async {
await expenseController
.searchEmployees(query); // async method, returns void
return expenseController.employeeSearchResults.toList();
await widget.expenseController.searchEmployees(query);
return widget.expenseController.employeeSearchResults.toList();
}
@override
@ -34,20 +43,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
title: 'Filter Expenses',
onCancel: () => Get.back(),
onSubmit: () {
expenseController.fetchExpenses();
widget.expenseController.fetchExpenses();
Get.back();
},
submitText: 'Submit',
submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: scrollController,
controller: widget.scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => expenseController.clearFilters(),
onPressed: () => widget.expenseController.clearFilters(),
child: MyText(
"Reset Filter",
style: MyTextStyle.labelMedium(
@ -58,15 +68,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
),
),
MySpacing.height(8),
_buildProjectFilter(context),
_buildProjectFilter(),
MySpacing.height(16),
_buildStatusFilter(context),
_buildStatusFilter(),
MySpacing.height(16),
_buildDateRangeFilter(context),
_buildDateRangeFilter(),
MySpacing.height(16),
_buildPaidByFilter(context),
_buildPaidByFilter(),
MySpacing.height(16),
_buildCreatedByFilter(context),
_buildCreatedByFilter(),
],
),
),
@ -85,190 +95,145 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
Widget _buildProjectFilter(BuildContext context) {
Widget _buildProjectFilter() {
return _buildField(
"Project",
_popupSelector(
context,
currentValue: expenseController.selectedProject.value.isEmpty
currentValue: widget.expenseController.selectedProject.value.isEmpty
? 'Select Project'
: expenseController.selectedProject.value,
items: expenseController.globalProjects,
onSelected: (value) => expenseController.selectedProject.value = value,
: widget.expenseController.selectedProject.value,
items: widget.expenseController.globalProjects,
onSelected: (value) =>
widget.expenseController.selectedProject.value = value,
),
);
}
Widget _buildStatusFilter(BuildContext context) {
Widget _buildStatusFilter() {
return _buildField(
"Expense Status",
_popupSelector(
context,
currentValue: expenseController.selectedStatus.value.isEmpty
currentValue: widget.expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status'
: expenseController.expenseStatuses
.firstWhereOrNull(
(e) => e.id == expenseController.selectedStatus.value)
: widget.expenseController.expenseStatuses
.firstWhereOrNull((e) =>
e.id == widget.expenseController.selectedStatus.value)
?.name ??
'Select Expense Status',
items: expenseController.expenseStatuses.map((e) => e.name).toList(),
items: widget.expenseController.expenseStatuses
.map((e) => e.name)
.toList(),
onSelected: (name) {
final status = expenseController.expenseStatuses
final status = widget.expenseController.expenseStatuses
.firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id;
widget.expenseController.selectedStatus.value = status.id;
},
),
);
}
Widget _buildDateRangeFilter(BuildContext context) {
Widget _buildDateRangeFilter() {
return _buildField(
"Date Filter",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- Radio Buttons for Transaction Date / Created At ---
Obx(() {
return SizedBox(
width: double.infinity, // Make it full width
child: SegmentedButton<String>(
segments: expenseController.dateTypes
.map(
(type) => ButtonSegment(
value: type,
label: Center(
// Center label text
child: MyText(
type,
style: MyTextStyle.bodySmall(
fontWeight: 600,
fontSize: 13,
height: 1.2,
),
),
return Row(
children: [
// --- Transaction Date ---
Expanded(
child: Row(
children: [
Radio<String>(
value: "Transaction Date",
groupValue:
widget.expenseController.selectedDateType.value,
onChanged: (val) {
if (val != null) {
widget.expenseController.selectedDateType.value =
val;
}
},
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),
Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
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,
),
),
),
],
// --- Reusable Date Range Picker ---
DateRangePickerWidget(
startDate: widget.expenseController.startDate,
endDate: widget.expenseController.endDate,
startLabel: "Start Date",
endLabel: "End Date",
onDateRangeSelected: (start, end) {
widget.expenseController.startDate.value = start;
widget.expenseController.endDate.value = end;
},
),
],
),
);
}
Widget _buildPaidByFilter(BuildContext context) {
Widget _buildPaidByFilter() {
return _buildField(
"Paid By",
_employeeSelector(
context: context,
selectedEmployees: expenseController.selectedPaidByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
selectedEmployees: widget.expenseController.selectedPaidByEmployees,
searchEmployees: searchEmployeesForBottomSheet,
title: 'Search Paid By',
),
);
}
Widget _buildCreatedByFilter(BuildContext context) {
Widget _buildCreatedByFilter() {
return _buildField(
"Created By",
_employeeSelector(
context: context,
selectedEmployees: expenseController.selectedCreatedByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
selectedEmployees: widget.expenseController.selectedCreatedByEmployees,
searchEmployees: searchEmployeesForBottomSheet,
title: 'Search Created By',
),
);
}
Future<void> _selectDate(
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, {
Widget _popupSelector({
required String currentValue,
required List<String> items,
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({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Search Employee',
@ -367,27 +280,37 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
if (selectedEmployees.isEmpty) {
return const SizedBox.shrink();
}
if (selectedEmployees.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
children: selectedEmployees
.map((emp) => Chip(
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
))
.map(
(emp) => Chip(
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
),
)
.toList(),
);
}),
MySpacing.height(8),
GestureDetector(
onTap: () => _showEmployeeSelectorBottomSheet(
context: context,
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
onTap: () 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);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
@ -407,5 +330,4 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
],
);
}
}