- Added employee attendance fetching in DashboardController. - Introduced loading state for employees in the dashboard. - Updated API endpoints to include attendance for the dashboard. - Created a new InfraProjectsMainScreen with tab navigation for task planning and progress reporting. - Improved UI components for better user experience in the dashboard. - Refactored project selection and quick actions in the dashboard. - Added permission constants for infrastructure projects.
585 lines
17 KiB
Dart
585 lines
17 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
|
|
import 'package:on_field_work/controller/project_controller.dart';
|
|
import 'package:on_field_work/helpers/services/api_service.dart';
|
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
|
import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
|
|
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
|
|
import 'package:on_field_work/model/attendance/attendance_log_model.dart';
|
|
import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
|
|
import 'package:on_field_work/model/attendance/attendance_model.dart';
|
|
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
|
|
import 'package:on_field_work/model/employees/employee_model.dart';
|
|
import 'package:on_field_work/model/project_model.dart';
|
|
import 'package:on_field_work/model/regularization_log_model.dart';
|
|
|
|
class AttendanceController extends GetxController {
|
|
// ------------------ Data Models ------------------
|
|
final List<AttendanceModel> attendances = <AttendanceModel>[];
|
|
final List<ProjectModel> projects = <ProjectModel>[];
|
|
final List<EmployeeModel> employees = <EmployeeModel>[];
|
|
final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[];
|
|
final List<RegularizationLogModel> regularizationLogs =
|
|
<RegularizationLogModel>[];
|
|
final List<AttendanceLogViewModel> attendenceLogsView =
|
|
<AttendanceLogViewModel>[];
|
|
|
|
// ------------------ Organizations ------------------
|
|
final List<Organization> organizations = <Organization>[];
|
|
Organization? selectedOrganization;
|
|
final RxBool isLoadingOrganizations = false.obs;
|
|
|
|
// ------------------ States ------------------
|
|
String selectedTab = 'todaysAttendance';
|
|
|
|
// ✅ 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 RxBool isLoading = true.obs;
|
|
final RxBool isLoadingProjects = true.obs;
|
|
final RxBool isLoadingEmployees = true.obs;
|
|
final RxBool isLoadingAttendanceLogs = true.obs;
|
|
final RxBool isLoadingRegularizationLogs = true.obs;
|
|
final RxBool isLoadingLogView = true.obs;
|
|
|
|
final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
|
|
|
final RxBool showPendingOnly = false.obs;
|
|
final RxString searchQuery = ''.obs;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_initializeDefaults();
|
|
}
|
|
|
|
void _initializeDefaults() {
|
|
_setDefaultDateRange();
|
|
}
|
|
|
|
void _setDefaultDateRange() {
|
|
final DateTime today = DateTime.now();
|
|
startDateAttendance.value = today.subtract(const Duration(days: 7));
|
|
endDateAttendance.value = today.subtract(const Duration(days: 1));
|
|
logSafe(
|
|
'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
|
|
);
|
|
}
|
|
|
|
// ------------------ Computed Filters ------------------
|
|
List<EmployeeModel> get filteredEmployees {
|
|
final String query = searchQuery.value.trim().toLowerCase();
|
|
if (query.isEmpty) return employees;
|
|
return employees
|
|
.where(
|
|
(EmployeeModel e) => e.name.toLowerCase().contains(query),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
List<AttendanceLogModel> get filteredLogs {
|
|
final String query = searchQuery.value.trim().toLowerCase();
|
|
if (query.isEmpty) return attendanceLogs;
|
|
return attendanceLogs
|
|
.where(
|
|
(AttendanceLogModel log) => log.name.toLowerCase().contains(query),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
List<RegularizationLogModel> get filteredRegularizationLogs {
|
|
final String query = searchQuery.value.trim().toLowerCase();
|
|
if (query.isEmpty) return regularizationLogs;
|
|
return regularizationLogs
|
|
.where(
|
|
(RegularizationLogModel log) =>
|
|
log.name.toLowerCase().contains(query),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
// ------------------ Project & Employee APIs ------------------
|
|
Future<void> refreshDataFromNotification({String? projectId}) async {
|
|
projectId ??= Get.find<ProjectController>().selectedProject?.id;
|
|
if (projectId == null) {
|
|
logSafe(
|
|
'No project selected for attendance refresh from notification',
|
|
level: LogLevel.warning,
|
|
);
|
|
return;
|
|
}
|
|
await fetchProjectData(projectId);
|
|
logSafe(
|
|
'Attendance data refreshed from notification for project $projectId',
|
|
);
|
|
}
|
|
|
|
Future<void> fetchTodaysAttendance(String? projectId) async {
|
|
if (projectId == null) return;
|
|
|
|
isLoadingEmployees.value = true;
|
|
|
|
final List<dynamic>? response = await ApiService.getTodaysAttendance(
|
|
projectId,
|
|
organizationId: selectedOrganization?.id,
|
|
);
|
|
if (response != null) {
|
|
employees
|
|
..clear()
|
|
..addAll(
|
|
response
|
|
.map<EmployeeModel>(
|
|
(dynamic e) => EmployeeModel.fromJson(
|
|
e as Map<String, dynamic>,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
|
|
for (final EmployeeModel emp in employees) {
|
|
uploadingStates[emp.id] = false.obs;
|
|
}
|
|
|
|
logSafe(
|
|
'Employees fetched: ${employees.length} for project $projectId',
|
|
);
|
|
} else {
|
|
logSafe(
|
|
'Failed to fetch employees for project $projectId',
|
|
level: LogLevel.error,
|
|
);
|
|
}
|
|
|
|
isLoadingEmployees.value = false;
|
|
update();
|
|
}
|
|
|
|
Future<void> fetchOrganizations(String projectId) async {
|
|
isLoadingOrganizations.value = true;
|
|
|
|
// Keep original return type inference from your ApiService
|
|
final response = await ApiService.getAssignedOrganizations(projectId);
|
|
|
|
if (response != null) {
|
|
organizations
|
|
..clear()
|
|
..addAll(response.data);
|
|
logSafe('Organizations fetched: ${organizations.length}');
|
|
} else {
|
|
logSafe(
|
|
'Failed to fetch organizations for project $projectId',
|
|
level: LogLevel.error,
|
|
);
|
|
}
|
|
|
|
isLoadingOrganizations.value = false;
|
|
update();
|
|
}
|
|
|
|
// ------------------ Attendance Capture ------------------
|
|
Future<bool> captureAndUploadAttendance(
|
|
String id,
|
|
String employeeId,
|
|
String projectId, {
|
|
String comment = 'Marked via mobile app',
|
|
required int action,
|
|
bool imageCapture = true,
|
|
String? markTime,
|
|
String? date,
|
|
}) async {
|
|
try {
|
|
_setUploading(employeeId, true);
|
|
|
|
final XFile? image = await _captureAndPrepareImage(
|
|
employeeId: employeeId,
|
|
imageCapture: imageCapture,
|
|
);
|
|
if (imageCapture && image == null) {
|
|
return false;
|
|
}
|
|
|
|
final Position? position = await _getCurrentPositionSafely();
|
|
if (position == null) return false;
|
|
|
|
final String imageName = imageCapture
|
|
? ApiService.generateImageName(
|
|
employeeId,
|
|
employees.length + 1,
|
|
)
|
|
: '';
|
|
|
|
final DateTime effectiveDate =
|
|
_resolveEffectiveDateForAction(action, employeeId);
|
|
|
|
final DateTime now = DateTime.now();
|
|
final String formattedMarkTime =
|
|
markTime ?? DateFormat('hh:mm a').format(now);
|
|
final String formattedDate =
|
|
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
|
|
|
|
final bool result = await ApiService.uploadAttendanceImage(
|
|
id,
|
|
employeeId,
|
|
image,
|
|
position.latitude,
|
|
position.longitude,
|
|
imageName: imageName,
|
|
projectId: projectId,
|
|
comment: comment,
|
|
action: action,
|
|
imageCapture: imageCapture,
|
|
markTime: formattedMarkTime,
|
|
date: formattedDate,
|
|
);
|
|
|
|
if (result) {
|
|
logSafe(
|
|
'Attendance uploaded for $employeeId, action: $action, date: $formattedDate',
|
|
);
|
|
|
|
if (Get.isRegistered<DashboardController>()) {
|
|
final DashboardController dashboardController =
|
|
Get.find<DashboardController>();
|
|
await dashboardController.fetchTodaysAttendance(projectId);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (e, stacktrace) {
|
|
logSafe(
|
|
'Error uploading attendance',
|
|
level: LogLevel.error,
|
|
error: e,
|
|
stackTrace: stacktrace,
|
|
);
|
|
return false;
|
|
} finally {
|
|
_setUploading(employeeId, false);
|
|
}
|
|
}
|
|
|
|
Future<XFile?> _captureAndPrepareImage({
|
|
required String employeeId,
|
|
required bool imageCapture,
|
|
}) async {
|
|
if (!imageCapture) return null;
|
|
|
|
final XFile? rawImage = await ImagePicker().pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 80,
|
|
);
|
|
if (rawImage == null) {
|
|
logSafe(
|
|
'Image capture cancelled.',
|
|
level: LogLevel.warning,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
final File timestampedFile = await TimestampImageHelper.addTimestamp(
|
|
imageFile: File(rawImage.path),
|
|
);
|
|
|
|
final List<int>? compressedBytes =
|
|
await compressImageToUnder100KB(timestampedFile);
|
|
if (compressedBytes == null) {
|
|
logSafe(
|
|
'Image compression failed.',
|
|
level: LogLevel.error,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// FIX: convert List<int> -> Uint8List
|
|
final Uint8List compressedUint8List = Uint8List.fromList(compressedBytes);
|
|
|
|
final File compressedFile =
|
|
await saveCompressedImageToFile(compressedUint8List);
|
|
return XFile(compressedFile.path);
|
|
}
|
|
|
|
Future<Position?> _getCurrentPositionSafely() async {
|
|
final bool permissionGranted = await _handleLocationPermission();
|
|
if (!permissionGranted) return null;
|
|
|
|
return Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high,
|
|
);
|
|
}
|
|
|
|
DateTime _resolveEffectiveDateForAction(int action, String employeeId) {
|
|
final DateTime now = DateTime.now();
|
|
if (action != 1) return now;
|
|
|
|
final AttendanceLogModel? log = attendanceLogs.firstWhereOrNull(
|
|
(AttendanceLogModel log) =>
|
|
log.employeeId == employeeId && log.checkOut == null,
|
|
);
|
|
|
|
return log?.checkIn ?? now;
|
|
}
|
|
|
|
void _setUploading(String employeeId, bool value) {
|
|
final RxBool? state = uploadingStates[employeeId];
|
|
if (state != null) {
|
|
state.value = value;
|
|
} else {
|
|
uploadingStates[employeeId] = value.obs;
|
|
}
|
|
}
|
|
|
|
Future<bool> _handleLocationPermission() async {
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
logSafe(
|
|
'Location permissions are denied',
|
|
level: LogLevel.warning,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (permission == LocationPermission.deniedForever) {
|
|
logSafe(
|
|
'Location permissions are permanently denied',
|
|
level: LogLevel.error,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ------------------ Attendance Logs ------------------
|
|
Future<void> fetchAttendanceLogs(
|
|
String? projectId, {
|
|
DateTime? dateFrom,
|
|
DateTime? dateTo,
|
|
}) async {
|
|
if (projectId == null) return;
|
|
|
|
isLoadingAttendanceLogs.value = true;
|
|
|
|
final List<dynamic>? response = await ApiService.getAttendanceLogs(
|
|
projectId,
|
|
dateFrom: dateFrom,
|
|
dateTo: dateTo,
|
|
organizationId: selectedOrganization?.id,
|
|
);
|
|
|
|
if (response != null) {
|
|
attendanceLogs
|
|
..clear()
|
|
..addAll(
|
|
response
|
|
.map<AttendanceLogModel>(
|
|
(dynamic e) => AttendanceLogModel.fromJson(
|
|
e as Map<String, dynamic>,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
|
|
} else {
|
|
logSafe(
|
|
'Failed to fetch attendance logs for project $projectId',
|
|
level: LogLevel.error,
|
|
);
|
|
}
|
|
|
|
isLoadingAttendanceLogs.value = false;
|
|
update();
|
|
}
|
|
|
|
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
|
|
final Map<String, List<AttendanceLogModel>> groupedLogs =
|
|
<String, List<AttendanceLogModel>>{};
|
|
|
|
for (final AttendanceLogModel logItem in attendanceLogs) {
|
|
final String checkInDate = logItem.checkIn != null
|
|
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
|
|
: 'Unknown';
|
|
|
|
groupedLogs.putIfAbsent(
|
|
checkInDate,
|
|
() => <AttendanceLogModel>[],
|
|
)..add(logItem);
|
|
}
|
|
|
|
final List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries =
|
|
groupedLogs.entries.toList()
|
|
..sort(
|
|
(MapEntry<String, List<AttendanceLogModel>> a,
|
|
MapEntry<String, List<AttendanceLogModel>> b) {
|
|
if (a.key == 'Unknown') return 1;
|
|
if (b.key == 'Unknown') return -1;
|
|
|
|
final DateTime dateA = DateFormat('dd MMM yyyy').parse(a.key);
|
|
final DateTime dateB = DateFormat('dd MMM yyyy').parse(b.key);
|
|
return dateB.compareTo(dateA);
|
|
},
|
|
);
|
|
|
|
return Map<String, List<AttendanceLogModel>>.fromEntries(
|
|
sortedEntries,
|
|
);
|
|
}
|
|
|
|
// ------------------ Regularization Logs ------------------
|
|
Future<void> fetchRegularizationLogs(String? projectId) async {
|
|
if (projectId == null) return;
|
|
|
|
isLoadingRegularizationLogs.value = true;
|
|
|
|
final List<dynamic>? response = await ApiService.getRegularizationLogs(
|
|
projectId,
|
|
organizationId: selectedOrganization?.id,
|
|
);
|
|
|
|
if (response != null) {
|
|
regularizationLogs
|
|
..clear()
|
|
..addAll(
|
|
response
|
|
.map<RegularizationLogModel>(
|
|
(dynamic e) => RegularizationLogModel.fromJson(
|
|
e as Map<String, dynamic>,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
logSafe(
|
|
'Regularization logs fetched: ${regularizationLogs.length}',
|
|
);
|
|
} else {
|
|
logSafe(
|
|
'Failed to fetch regularization logs for project $projectId',
|
|
level: LogLevel.error,
|
|
);
|
|
}
|
|
|
|
isLoadingRegularizationLogs.value = false;
|
|
update();
|
|
}
|
|
|
|
// ------------------ Attendance Log View ------------------
|
|
Future<void> fetchLogsView(String? id) async {
|
|
if (id == null) return;
|
|
|
|
isLoadingLogView.value = true;
|
|
|
|
final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
|
|
|
|
if (response != null) {
|
|
attendenceLogsView
|
|
..clear()
|
|
..addAll(
|
|
response
|
|
.map<AttendanceLogViewModel>(
|
|
(dynamic e) => AttendanceLogViewModel.fromJson(
|
|
e as Map<String, dynamic>,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
|
|
attendenceLogsView.sort(
|
|
(AttendanceLogViewModel a, AttendanceLogViewModel b) =>
|
|
(b.activityTime ?? DateTime(2000))
|
|
.compareTo(a.activityTime ?? DateTime(2000)),
|
|
);
|
|
|
|
logSafe('Attendance log view fetched for ID: $id');
|
|
} else {
|
|
logSafe(
|
|
'Failed to fetch attendance log view for ID $id',
|
|
level: LogLevel.error,
|
|
);
|
|
}
|
|
|
|
isLoadingLogView.value = false;
|
|
update();
|
|
}
|
|
|
|
// ------------------ Combined Load ------------------
|
|
Future<void> loadAttendanceData(String projectId) async {
|
|
isLoading.value = true;
|
|
await fetchProjectData(projectId);
|
|
isLoading.value = false;
|
|
}
|
|
|
|
Future<void> fetchProjectData(String? projectId) async {
|
|
if (projectId == null) return;
|
|
|
|
await fetchOrganizations(projectId);
|
|
|
|
switch (selectedTab) {
|
|
case 'todaysAttendance':
|
|
await fetchTodaysAttendance(projectId);
|
|
break;
|
|
case 'attendanceLogs':
|
|
await fetchAttendanceLogs(
|
|
projectId,
|
|
dateFrom: startDateAttendance.value,
|
|
dateTo: endDateAttendance.value,
|
|
);
|
|
break;
|
|
case 'regularizationRequests':
|
|
await fetchRegularizationLogs(projectId);
|
|
break;
|
|
}
|
|
|
|
logSafe(
|
|
'Project data fetched for project ID: $projectId, tab: $selectedTab',
|
|
);
|
|
update();
|
|
}
|
|
|
|
// ------------------ UI Interaction ------------------
|
|
Future<void> selectDateRangeForAttendance(
|
|
BuildContext context,
|
|
AttendanceController controller,
|
|
) async {
|
|
final DateTime today = DateTime.now();
|
|
|
|
final DateTimeRange? picked = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2022),
|
|
lastDate: today.subtract(const Duration(days: 1)),
|
|
initialDateRange: DateTimeRange(
|
|
start: startDateAttendance.value,
|
|
end: endDateAttendance.value,
|
|
),
|
|
);
|
|
|
|
if (picked != null) {
|
|
startDateAttendance.value = picked.start;
|
|
endDateAttendance.value = picked.end;
|
|
|
|
logSafe(
|
|
'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
|
|
);
|
|
|
|
await controller.fetchAttendanceLogs(
|
|
Get.find<ProjectController>().selectedProject?.id,
|
|
dateFrom: picked.start,
|
|
dateTo: picked.end,
|
|
);
|
|
}
|
|
}
|
|
}
|