feat: Enhance Dashboard with Attendance and Infra Projects
- 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.
This commit is contained in:
parent
cf85c17d75
commit
03e3e7b5db
@ -1,37 +1,41 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:on_field_work/helpers/services/app_logger.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_model.dart';
|
||||
import 'package:on_field_work/model/project_model.dart';
|
||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||
import 'package:on_field_work/model/attendance/attendance_log_model.dart';
|
||||
import 'package:on_field_work/model/regularization_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/controller/project_controller.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 ------------------
|
||||
List<AttendanceModel> attendances = [];
|
||||
List<ProjectModel> projects = [];
|
||||
List<EmployeeModel> employees = [];
|
||||
List<AttendanceLogModel> attendanceLogs = [];
|
||||
List<RegularizationLogModel> regularizationLogs = [];
|
||||
List<AttendanceLogViewModel> attendenceLogsView = [];
|
||||
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 ------------------
|
||||
List<Organization> organizations = [];
|
||||
final List<Organization> organizations = <Organization>[];
|
||||
Organization? selectedOrganization;
|
||||
final isLoadingOrganizations = false.obs;
|
||||
final RxBool isLoadingOrganizations = false.obs;
|
||||
|
||||
// ------------------ States ------------------
|
||||
String selectedTab = 'todaysAttendance';
|
||||
@ -42,16 +46,17 @@ class AttendanceController extends GetxController {
|
||||
final Rx<DateTime> endDateAttendance =
|
||||
DateTime.now().subtract(const Duration(days: 1)).obs;
|
||||
|
||||
final isLoading = true.obs;
|
||||
final isLoadingProjects = true.obs;
|
||||
final isLoadingEmployees = true.obs;
|
||||
final isLoadingAttendanceLogs = true.obs;
|
||||
final isLoadingRegularizationLogs = true.obs;
|
||||
final isLoadingLogView = true.obs;
|
||||
final uploadingStates = <String, RxBool>{}.obs;
|
||||
var showPendingOnly = false.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 searchQuery = ''.obs;
|
||||
final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
final RxBool showPendingOnly = false.obs;
|
||||
final RxString searchQuery = ''.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -64,35 +69,43 @@ class AttendanceController extends GetxController {
|
||||
}
|
||||
|
||||
void _setDefaultDateRange() {
|
||||
final today = DateTime.now();
|
||||
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}");
|
||||
'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------ Computed Filters ------------------
|
||||
List<EmployeeModel> get filteredEmployees {
|
||||
if (searchQuery.value.isEmpty) return employees;
|
||||
final String query = searchQuery.value.trim().toLowerCase();
|
||||
if (query.isEmpty) return employees;
|
||||
return employees
|
||||
.where((e) =>
|
||||
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.where(
|
||||
(EmployeeModel e) => e.name.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<AttendanceLogModel> get filteredLogs {
|
||||
if (searchQuery.value.isEmpty) return attendanceLogs;
|
||||
final String query = searchQuery.value.trim().toLowerCase();
|
||||
if (query.isEmpty) return attendanceLogs;
|
||||
return attendanceLogs
|
||||
.where((log) =>
|
||||
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.where(
|
||||
(AttendanceLogModel log) => log.name.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<RegularizationLogModel> get filteredRegularizationLogs {
|
||||
if (searchQuery.value.isEmpty) return regularizationLogs;
|
||||
final String query = searchQuery.value.trim().toLowerCase();
|
||||
if (query.isEmpty) return regularizationLogs;
|
||||
return regularizationLogs
|
||||
.where((log) =>
|
||||
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.where(
|
||||
(RegularizationLogModel log) =>
|
||||
log.name.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@ -100,13 +113,16 @@ class AttendanceController extends GetxController {
|
||||
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);
|
||||
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");
|
||||
'Attendance data refreshed from notification for project $projectId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchTodaysAttendance(String? projectId) async {
|
||||
@ -114,19 +130,35 @@ class AttendanceController extends GetxController {
|
||||
|
||||
isLoadingEmployees.value = true;
|
||||
|
||||
final response = await ApiService.getTodaysAttendance(
|
||||
final List<dynamic>? response = await ApiService.getTodaysAttendance(
|
||||
projectId,
|
||||
organizationId: selectedOrganization?.id,
|
||||
);
|
||||
if (response != null) {
|
||||
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
|
||||
for (var emp in employees) {
|
||||
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");
|
||||
|
||||
logSafe(
|
||||
'Employees fetched: ${employees.length} for project $projectId',
|
||||
);
|
||||
} else {
|
||||
logSafe("Failed to fetch employees for project $projectId",
|
||||
level: LogLevel.error);
|
||||
logSafe(
|
||||
'Failed to fetch employees for project $projectId',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
isLoadingEmployees.value = false;
|
||||
@ -135,14 +167,22 @@ class AttendanceController extends GetxController {
|
||||
|
||||
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 = response.data;
|
||||
logSafe("Organizations fetched: ${organizations.length}");
|
||||
organizations
|
||||
..clear()
|
||||
..addAll(response.data);
|
||||
logSafe('Organizations fetched: ${organizations.length}');
|
||||
} else {
|
||||
logSafe("Failed to fetch organizations for project $projectId",
|
||||
level: LogLevel.error);
|
||||
logSafe(
|
||||
'Failed to fetch organizations for project $projectId',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
isLoadingOrganizations.value = false;
|
||||
update();
|
||||
}
|
||||
@ -152,61 +192,43 @@ class AttendanceController extends GetxController {
|
||||
String id,
|
||||
String employeeId,
|
||||
String projectId, {
|
||||
String comment = "Marked via mobile app",
|
||||
String comment = 'Marked via mobile app',
|
||||
required int action,
|
||||
bool imageCapture = true,
|
||||
String? markTime,
|
||||
String? date,
|
||||
}) async {
|
||||
try {
|
||||
uploadingStates[employeeId]?.value = true;
|
||||
_setUploading(employeeId, true);
|
||||
|
||||
XFile? image;
|
||||
if (imageCapture) {
|
||||
image = await ImagePicker()
|
||||
.pickImage(source: ImageSource.camera, imageQuality: 80);
|
||||
if (image == null) {
|
||||
logSafe("Image capture cancelled.", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
final timestampedFile = await TimestampImageHelper.addTimestamp(
|
||||
imageFile: File(image.path));
|
||||
|
||||
final compressedBytes =
|
||||
await compressImageToUnder100KB(timestampedFile);
|
||||
if (compressedBytes == null) {
|
||||
logSafe("Image compression failed.", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
final compressedFile = await saveCompressedImageToFile(compressedBytes);
|
||||
image = XFile(compressedFile.path);
|
||||
}
|
||||
|
||||
if (!await _handleLocationPermission()) return false;
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
|
||||
final imageName = imageCapture
|
||||
? ApiService.generateImageName(employeeId, employees.length + 1)
|
||||
: "";
|
||||
|
||||
final now = DateTime.now();
|
||||
DateTime effectiveDate = now;
|
||||
|
||||
if (action == 1) {
|
||||
final log = attendanceLogs.firstWhereOrNull(
|
||||
(log) => log.employeeId == employeeId && log.checkOut == null,
|
||||
final XFile? image = await _captureAndPrepareImage(
|
||||
employeeId: employeeId,
|
||||
imageCapture: imageCapture,
|
||||
);
|
||||
if (log?.checkIn != null) effectiveDate = log!.checkIn!;
|
||||
if (imageCapture && image == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
|
||||
final formattedDate =
|
||||
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 result = await ApiService.uploadAttendanceImage(
|
||||
final bool result = await ApiService.uploadAttendanceImage(
|
||||
id,
|
||||
employeeId,
|
||||
image,
|
||||
@ -221,15 +243,99 @@ class AttendanceController extends GetxController {
|
||||
date: formattedDate,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
logSafe(
|
||||
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate");
|
||||
'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);
|
||||
logSafe(
|
||||
'Error uploading attendance',
|
||||
level: LogLevel.error,
|
||||
error: e,
|
||||
stackTrace: stacktrace,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
uploadingStates[employeeId]?.value = false;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -239,14 +345,19 @@ class AttendanceController extends GetxController {
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
logSafe('Location permissions are denied', level: LogLevel.warning);
|
||||
logSafe(
|
||||
'Location permissions are denied',
|
||||
level: LogLevel.warning,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
logSafe('Location permissions are permanently denied',
|
||||
level: LogLevel.error);
|
||||
logSafe(
|
||||
'Location permissions are permanently denied',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -254,25 +365,40 @@ class AttendanceController extends GetxController {
|
||||
}
|
||||
|
||||
// ------------------ Attendance Logs ------------------
|
||||
Future<void> fetchAttendanceLogs(String? projectId,
|
||||
{DateTime? dateFrom, DateTime? dateTo}) async {
|
||||
Future<void> fetchAttendanceLogs(
|
||||
String? projectId, {
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
}) async {
|
||||
if (projectId == null) return;
|
||||
|
||||
isLoadingAttendanceLogs.value = true;
|
||||
|
||||
final response = await ApiService.getAttendanceLogs(
|
||||
final List<dynamic>? response = await ApiService.getAttendanceLogs(
|
||||
projectId,
|
||||
dateFrom: dateFrom,
|
||||
dateTo: dateTo,
|
||||
organizationId: selectedOrganization?.id,
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
attendanceLogs =
|
||||
response.map((e) => AttendanceLogModel.fromJson(e)).toList();
|
||||
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
|
||||
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);
|
||||
logSafe(
|
||||
'Failed to fetch attendance logs for project $projectId',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
isLoadingAttendanceLogs.value = false;
|
||||
@ -280,25 +406,37 @@ class AttendanceController extends GetxController {
|
||||
}
|
||||
|
||||
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
|
||||
final groupedLogs = <String, List<AttendanceLogModel>>{};
|
||||
final Map<String, List<AttendanceLogModel>> groupedLogs =
|
||||
<String, List<AttendanceLogModel>>{};
|
||||
|
||||
for (var logItem in attendanceLogs) {
|
||||
final checkInDate = logItem.checkIn != null
|
||||
for (final AttendanceLogModel logItem in attendanceLogs) {
|
||||
final String checkInDate = logItem.checkIn != null
|
||||
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
|
||||
: 'Unknown';
|
||||
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
|
||||
|
||||
groupedLogs.putIfAbsent(
|
||||
checkInDate,
|
||||
() => <AttendanceLogModel>[],
|
||||
)..add(logItem);
|
||||
}
|
||||
|
||||
final sortedEntries = groupedLogs.entries.toList()
|
||||
..sort((a, b) {
|
||||
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 dateA = DateFormat('dd MMM yyyy').parse(a.key);
|
||||
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
|
||||
return dateB.compareTo(dateA);
|
||||
});
|
||||
|
||||
return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
|
||||
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 ------------------
|
||||
@ -307,17 +445,31 @@ class AttendanceController extends GetxController {
|
||||
|
||||
isLoadingRegularizationLogs.value = true;
|
||||
|
||||
final response = await ApiService.getRegularizationLogs(
|
||||
final List<dynamic>? response = await ApiService.getRegularizationLogs(
|
||||
projectId,
|
||||
organizationId: selectedOrganization?.id,
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
regularizationLogs =
|
||||
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
|
||||
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
|
||||
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);
|
||||
logSafe(
|
||||
'Failed to fetch regularization logs for project $projectId',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
isLoadingRegularizationLogs.value = false;
|
||||
@ -330,16 +482,33 @@ class AttendanceController extends GetxController {
|
||||
|
||||
isLoadingLogView.value = true;
|
||||
|
||||
final response = await ApiService.getAttendanceLogView(id);
|
||||
final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
|
||||
|
||||
if (response != null) {
|
||||
attendenceLogsView =
|
||||
response.map((e) => AttendanceLogViewModel.fromJson(e)).toList();
|
||||
attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000))
|
||||
.compareTo(a.activityTime ?? DateTime(2000)));
|
||||
logSafe("Attendance log view fetched for ID: $id");
|
||||
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);
|
||||
logSafe(
|
||||
'Failed to fetch attendance log view for ID $id',
|
||||
level: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
isLoadingLogView.value = false;
|
||||
@ -375,16 +544,19 @@ class AttendanceController extends GetxController {
|
||||
}
|
||||
|
||||
logSafe(
|
||||
"Project data fetched for project ID: $projectId, tab: $selectedTab");
|
||||
'Project data fetched for project ID: $projectId, tab: $selectedTab',
|
||||
);
|
||||
update();
|
||||
}
|
||||
|
||||
// ------------------ UI Interaction ------------------
|
||||
Future<void> selectDateRangeForAttendance(
|
||||
BuildContext context, AttendanceController controller) async {
|
||||
final today = DateTime.now();
|
||||
BuildContext context,
|
||||
AttendanceController controller,
|
||||
) async {
|
||||
final DateTime today = DateTime.now();
|
||||
|
||||
final picked = await showDateRangePicker(
|
||||
final DateTimeRange? picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2022),
|
||||
lastDate: today.subtract(const Duration(days: 1)),
|
||||
@ -399,7 +571,8 @@ class AttendanceController extends GetxController {
|
||||
endDateAttendance.value = picked.end;
|
||||
|
||||
logSafe(
|
||||
"Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}");
|
||||
'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
|
||||
);
|
||||
|
||||
await controller.fetchAttendanceLogs(
|
||||
Get.find<ProjectController>().selectedProject?.id,
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:on_field_work/model/dashboard/pending_expenses_model.dart';
|
||||
import 'package:on_field_work/model/dashboard/expense_type_report_model.dart';
|
||||
import 'package:on_field_work/model/dashboard/monthly_expence_model.dart';
|
||||
import 'package:on_field_work/model/expense/expense_type_model.dart';
|
||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||
|
||||
class DashboardController extends GetxController {
|
||||
// =========================
|
||||
@ -81,6 +82,10 @@ class DashboardController extends GetxController {
|
||||
final RxInt selectedMonthsCount = 12.obs;
|
||||
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
||||
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||
final isLoadingEmployees = true.obs;
|
||||
// DashboardController
|
||||
final RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||
final uploadingStates = <String, RxBool>{}.obs;
|
||||
|
||||
void updateSelectedExpenseType(ExpenseTypeModel? type) {
|
||||
selectedExpenseType.value = type;
|
||||
@ -100,24 +105,35 @@ class DashboardController extends GetxController {
|
||||
super.onInit();
|
||||
|
||||
logSafe(
|
||||
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
|
||||
'DashboardController initialized',
|
||||
level: LogLevel.info,
|
||||
);
|
||||
|
||||
fetchAllDashboardData();
|
||||
|
||||
// React to project change
|
||||
// React to project selection
|
||||
ever<String>(projectController.selectedProjectId, (id) {
|
||||
if (id.isNotEmpty) {
|
||||
logSafe('Project selected: $id', level: LogLevel.info);
|
||||
fetchAllDashboardData();
|
||||
fetchTodaysAttendance(id);
|
||||
} else {
|
||||
logSafe('No project selected yet.', level: LogLevel.warning);
|
||||
}
|
||||
});
|
||||
|
||||
// React to expense report date changes
|
||||
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
||||
fetchExpenseTypeReport(
|
||||
startDate: expenseReportStartDate.value,
|
||||
endDate: expenseReportEndDate.value,
|
||||
);
|
||||
}
|
||||
});
|
||||
// React to range changes
|
||||
|
||||
// React to attendance range changes
|
||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||
|
||||
// React to project range changes
|
||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||
}
|
||||
|
||||
@ -208,6 +224,26 @@ class DashboardController extends GetxController {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||
isLoadingEmployees.value = true;
|
||||
|
||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||
if (response != null) {
|
||||
employees.value = response;
|
||||
for (var emp in employees) {
|
||||
uploadingStates[emp.id] = false.obs;
|
||||
}
|
||||
logSafe(
|
||||
"Dashboard Attendance fetched: ${employees.length} for project $projectId");
|
||||
} else {
|
||||
logSafe("Failed to fetch Dashboard Attendance for project $projectId",
|
||||
level: LogLevel.error);
|
||||
}
|
||||
|
||||
isLoadingEmployees.value = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
||||
selectedMonthlyExpenseDuration.value = duration;
|
||||
|
||||
@ -359,7 +395,8 @@ class DashboardController extends GetxController {
|
||||
level: LogLevel.info);
|
||||
} else {
|
||||
expenseTypeReportData.value = null;
|
||||
logSafe('Failed to fetch Expense Category Report.', level: LogLevel.error);
|
||||
logSafe('Failed to fetch Expense Category Report.',
|
||||
level: LogLevel.error);
|
||||
}
|
||||
} catch (e, st) {
|
||||
expenseTypeReportData.value = null;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
class ApiEndpoints {
|
||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://api.onfieldwork.com/api";
|
||||
// static const String baseUrl = "https://api.onfieldwork.com/api";
|
||||
|
||||
|
||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||
@ -44,6 +44,7 @@ class ApiEndpoints {
|
||||
static const String getProjects = "/project/list";
|
||||
static const String getGlobalProjects = "/project/list/basic";
|
||||
static const String getTodaysAttendance = "/attendance/project/team";
|
||||
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
|
||||
static const String getAttendanceLogs = "/attendance/project/log";
|
||||
static const String getAttendanceLogView = "/attendance/log/attendance";
|
||||
static const String getRegularizationLogs = "/attendance/regularize";
|
||||
|
||||
@ -42,6 +42,7 @@ import 'package:on_field_work/model/service_project/job_allocation_model.dart';
|
||||
import 'package:on_field_work/model/service_project/service_project_branches_model.dart';
|
||||
import 'package:on_field_work/model/service_project/job_status_response.dart';
|
||||
import 'package:on_field_work/model/service_project/job_comments.dart';
|
||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||
|
||||
class ApiService {
|
||||
static const bool enableLogs = true;
|
||||
@ -3320,6 +3321,30 @@ class ApiService {
|
||||
res != null ? _parseResponse(res, label: 'Employees') : null);
|
||||
}
|
||||
|
||||
static Future<List<EmployeeModel>?> getAttendanceForDashboard(
|
||||
String projectId) async {
|
||||
String endpoint = ApiEndpoints.getAttendanceForDashboard.replaceFirst(
|
||||
':projectId',
|
||||
projectId,
|
||||
);
|
||||
|
||||
final res = await _getRequest(endpoint);
|
||||
|
||||
if (res == null) return null;
|
||||
|
||||
final data = _parseResponse(res, label: 'Dashboard Attendance');
|
||||
if (data == null) return null;
|
||||
|
||||
// Wrap single object in a list if needed
|
||||
if (data is Map<String, dynamic>) {
|
||||
return [EmployeeModel.fromJson(data)];
|
||||
} else if (data is List) {
|
||||
return data.map((e) => EmployeeModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> getRegularizationLogs(
|
||||
String projectId, {
|
||||
String? organizationId,
|
||||
|
||||
@ -163,6 +163,9 @@ class MenuItems {
|
||||
|
||||
/// Service Projects
|
||||
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b";
|
||||
|
||||
/// Infrastructure Projects
|
||||
static const String infraProjects = "d3b5f3e3-3f7c-4f2b-99f1-1c9e4b8e6c2a";
|
||||
}
|
||||
|
||||
/// Contains all job status IDs used across the application.
|
||||
|
||||
@ -271,13 +271,9 @@ class AttendanceActionButtonUI extends StatelessWidget {
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
child: isUploading
|
||||
? Container(
|
||||
width: 60,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
? const Text(
|
||||
'Loading...',
|
||||
style: TextStyle(fontSize: 12, color: Colors.white),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@ -38,11 +38,12 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Watch permissions loaded
|
||||
ever(permissionController.permissionsLoaded, (loaded) {
|
||||
if (loaded == true && !_tabsInitialized) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeTabs();
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/controller/attendance/attendance_screen_controller.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/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||
import 'package:on_field_work/controller/project_controller.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||
import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||
import 'package:on_field_work/model/attendance/attendence_action_button.dart';
|
||||
import 'package:on_field_work/model/attendance/log_details_view.dart';
|
||||
import 'package:on_field_work/view/layouts/layout.dart';
|
||||
import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart ';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@ -24,7 +29,10 @@ class DashboardScreen extends StatefulWidget {
|
||||
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
final DashboardController dashboardController =
|
||||
Get.put(DashboardController(), permanent: true);
|
||||
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
||||
final AttendanceController attendanceController =
|
||||
Get.put(AttendanceController());
|
||||
final DynamicMenuController menuController =
|
||||
Get.put(DynamicMenuController());
|
||||
final ProjectController projectController = Get.find<ProjectController>();
|
||||
|
||||
bool hasMpin = true;
|
||||
@ -37,12 +45,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
|
||||
Future<void> _checkMpinStatus() async {
|
||||
hasMpin = await LocalStorage.getIsMpin();
|
||||
if (mounted) setState(() {});
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// REUSABLE CARD (smaller, minimal)
|
||||
//---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _cardWrapper({required Widget child}) {
|
||||
return Container(
|
||||
@ -56,17 +66,13 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
color: Colors.black12.withOpacity(.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// SECTION TITLE
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
@ -81,13 +87,48 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _conditionalQuickActionCard() {
|
||||
String status = "1"; // <-- change as needed
|
||||
bool isCheckedIn = status == "O";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Button color remains the same
|
||||
Color buttonColor =
|
||||
isCheckedIn ? Colors.red.shade700 : Colors.green.shade700;
|
||||
Widget _quickActions() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle('Quick Action'),
|
||||
Obx(() {
|
||||
final employees = dashboardController.employees;
|
||||
final employee = employees.isNotEmpty ? employees.first : null;
|
||||
|
||||
if (employee == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
contentTheme.primary.withOpacity(0.3),
|
||||
contentTheme.primary.withOpacity(0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'No attendance data available',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bool isCheckedIn = employee.checkIn != null;
|
||||
final bool isCheckedOut = employee.checkOut != null;
|
||||
|
||||
final String statusText = !isCheckedIn
|
||||
? 'Check In Pending'
|
||||
: isCheckedIn && !isCheckedOut
|
||||
? 'Checked In'
|
||||
: 'Checked Out';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -95,175 +136,101 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
contentTheme.primary.withOpacity(0.3), // lighter/faded
|
||||
contentTheme.primary.withOpacity(0.6), // slightly stronger
|
||||
contentTheme.primary.withOpacity(0.3),
|
||||
contentTheme.primary.withOpacity(0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(0.05),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title & Status
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
isCheckedIn ? "Checked-In" : "Not Checked-In",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
Avatar(
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
size: 30,
|
||||
),
|
||||
MySpacing.width(10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(
|
||||
employee.name,
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isCheckedIn ? LucideIcons.log_out : LucideIcons.log_in,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
MyText.labelSmall(
|
||||
employee.designation,
|
||||
fontWeight: 500,
|
||||
color: Colors.white70,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Description
|
||||
Text(
|
||||
isCheckedIn
|
||||
? "You are currently checked-in. Don't forget to check-out after your work."
|
||||
: "You are not checked-in yet. Please check-in to start your work.",
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
MyText.bodySmall(
|
||||
statusText,
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
!isCheckedIn
|
||||
? 'You are not checked-in yet. Please check-in to start your work.'
|
||||
: !isCheckedOut
|
||||
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
|
||||
: 'You have checked-out for today.',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Action Button (solid color)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Check-In / Check-Out action
|
||||
},
|
||||
icon: Icon(
|
||||
isCheckedIn ? LucideIcons.log_out : LucideIcons.log_in,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(isCheckedIn ? "Check-Out" : "Check-In"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: buttonColor,
|
||||
foregroundColor: Colors.white,
|
||||
AttendanceActionButton(
|
||||
employee: employee,
|
||||
attendanceController: attendanceController,
|
||||
),
|
||||
if (isCheckedIn) ...[
|
||||
MySpacing.width(8),
|
||||
AttendanceLogViewButton(
|
||||
employee: employee,
|
||||
attendanceController: attendanceController,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// QUICK ACTIONS (updated to use the single card)
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
Widget _quickActions() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle("Quick Action"),
|
||||
_conditionalQuickActionCard(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
//---------------------------------------------------------------------------
|
||||
// PROJECT DROPDOWN (clean compact)
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
Widget _projectSelector() {
|
||||
return Obx(() {
|
||||
final isLoading = projectController.isLoading.value;
|
||||
final expanded = projectController.isProjectSelectionExpanded.value;
|
||||
final projects = projectController.projects;
|
||||
final selectedId = projectController.selectedProjectId.value;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard Modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (isLoading) {
|
||||
// Use skeleton instead of CircularProgressIndicator
|
||||
return SkeletonLoaders.dashboardCardsSkeleton(
|
||||
maxWidth: MediaQuery.of(context).size.width);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle("Project"),
|
||||
|
||||
// Compact Selector
|
||||
GestureDetector(
|
||||
onTap: () => projectController.isProjectSelectionExpanded.toggle(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: Colors.black12.withOpacity(.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(.04),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.work_outline, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
projects
|
||||
.firstWhereOrNull((p) => p.id == selectedId)
|
||||
?.name ??
|
||||
"Select Project",
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 26,
|
||||
color: Colors.black54,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (expanded) _projectDropdownList(projects, selectedId),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// DASHBOARD MODULE CARDS (UPDATED FOR MINIMAL PADDING / SLL SIZE)
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
Widget _dashboardCards() {
|
||||
Widget _dashboardModules() {
|
||||
return Obx(() {
|
||||
if (menuController.isLoading.value) {
|
||||
// Show skeleton instead of CircularProgressIndicator
|
||||
return SkeletonLoaders.dashboardCardsSkeleton(
|
||||
maxWidth: MediaQuery.of(context).size.width);
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
);
|
||||
}
|
||||
|
||||
final projectSelected = projectController.selectedProject != null;
|
||||
final bool projectSelected = projectController.selectedProject != null;
|
||||
|
||||
final cardOrder = [
|
||||
// these are String constants from permission_constants.dart
|
||||
final List<String> cardOrder = [
|
||||
MenuItems.attendance,
|
||||
MenuItems.employees,
|
||||
MenuItems.dailyTaskPlanning,
|
||||
@ -272,9 +239,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
MenuItems.finance,
|
||||
MenuItems.documents,
|
||||
MenuItems.serviceProjects,
|
||||
MenuItems.infraProjects,
|
||||
];
|
||||
|
||||
final meta = {
|
||||
final Map<String, _DashboardCardMeta> meta = {
|
||||
MenuItems.attendance:
|
||||
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
|
||||
MenuItems.employees:
|
||||
@ -291,93 +259,138 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
_DashboardCardMeta(LucideIcons.file_text, contentTheme.info),
|
||||
MenuItems.serviceProjects:
|
||||
_DashboardCardMeta(LucideIcons.package, contentTheme.info),
|
||||
MenuItems.infraProjects:
|
||||
_DashboardCardMeta(LucideIcons.building_2, contentTheme.primary),
|
||||
};
|
||||
|
||||
final allowed = {
|
||||
for (var m in menuController.menuItems)
|
||||
if (m.available && meta.containsKey(m.id)) m.id: m
|
||||
final Map<String, dynamic> allowed = {
|
||||
for (final m in menuController.menuItems)
|
||||
if (m.available && meta.containsKey(m.id)) m.id: m,
|
||||
};
|
||||
|
||||
final filtered = cardOrder.where(allowed.containsKey).toList();
|
||||
final List<String> filtered =
|
||||
cardOrder.where((id) => allowed.containsKey(id)).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle("Modules"),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Modules',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
if (!projectSelected)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Select Project',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
|
||||
// **More compact grid**
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 6,
|
||||
childAspectRatio: 1.2, // smaller & tighter
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.15,
|
||||
),
|
||||
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = filtered[index];
|
||||
final String id = filtered[index];
|
||||
final item = allowed[id]!;
|
||||
final cardMeta = meta[id]!;
|
||||
final _DashboardCardMeta cardMeta = meta[id]!;
|
||||
|
||||
final isEnabled =
|
||||
item.name == "Attendance" ? true : projectSelected;
|
||||
final bool isEnabled =
|
||||
item.name == 'Attendance' ? true : projectSelected;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText: "Please select a project first.",
|
||||
Get.snackbar(
|
||||
'Required',
|
||||
'Please select a project first',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.black87,
|
||||
colorText: Colors.white,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
Get.toNamed(item.mobileLink);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
// **Reduced padding**
|
||||
padding: const EdgeInsets.all(4),
|
||||
|
||||
decoration: BoxDecoration(
|
||||
color: isEnabled ? Colors.white : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Colors.black12.withOpacity(.1),
|
||||
width: 0.7,
|
||||
color: isEnabled
|
||||
? Colors.black12.withOpacity(0.06)
|
||||
: Colors.transparent,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(.05),
|
||||
color: Colors.black.withOpacity(0.03),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
cardMeta.icon,
|
||||
size: 20,
|
||||
color:
|
||||
isEnabled ? cardMeta.color : Colors.grey.shade400,
|
||||
color: isEnabled
|
||||
? cardMeta.color
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
item.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
isEnabled ? Colors.black87 : Colors.grey.shade600,
|
||||
fontSize: 10,
|
||||
fontWeight: isEnabled
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isEnabled
|
||||
? Colors.black87
|
||||
: Colors.grey.shade400,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -389,7 +402,85 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _projectDropdownList(projects, selectedId) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project Selector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _projectSelector() {
|
||||
return Obx(() {
|
||||
final bool isLoading = projectController.isLoading.value;
|
||||
final bool expanded = projectController.isProjectSelectionExpanded.value;
|
||||
final projects = projectController.projects;
|
||||
final String? selectedId = projectController.selectedProjectId.value;
|
||||
|
||||
if (isLoading) {
|
||||
return SkeletonLoaders.dashboardCardsSkeleton(
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle('Project'),
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
projectController.isProjectSelectionExpanded.toggle(),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: Colors.black12.withOpacity(.15)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(.04),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.work_outline,
|
||||
color: Colors.blue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
projects
|
||||
.firstWhereOrNull(
|
||||
(p) => p.id == selectedId,
|
||||
)
|
||||
?.name ??
|
||||
'Select Project',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 26,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) _projectDropdownList(projects, selectedId),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _projectDropdownList(List projects, String? selectedId) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
@ -405,17 +496,19 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
),
|
||||
],
|
||||
),
|
||||
constraints:
|
||||
BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.33),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.33,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search project...",
|
||||
hintText: 'Search project...',
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border:
|
||||
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@ -428,9 +521,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
dense: true,
|
||||
value: project.id,
|
||||
groupValue: selectedId,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
projectController.updateSelectedProject(v);
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
projectController.updateSelectedProject(value);
|
||||
projectController.isProjectSelectionExpanded.value =
|
||||
false;
|
||||
}
|
||||
@ -445,9 +538,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------
|
||||
// MAIN UI
|
||||
//---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -460,18 +553,23 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_projectSelector(),
|
||||
// MySpacing.height(20),
|
||||
// _quickActions(),
|
||||
MySpacing.height(20),
|
||||
// The updated module cards
|
||||
_dashboardCards(),
|
||||
_quickActions(),
|
||||
MySpacing.height(20),
|
||||
_sectionTitle("Reports & Analytics"),
|
||||
_cardWrapper(child: ExpenseTypeReportChart()),
|
||||
_dashboardModules(),
|
||||
MySpacing.height(20),
|
||||
_sectionTitle('Reports & Analytics'),
|
||||
_cardWrapper(
|
||||
child:
|
||||
ExpenseByStatusWidget(controller: dashboardController)),
|
||||
_cardWrapper(child: MonthlyExpenseDashboardChart()),
|
||||
child: ExpenseTypeReportChart(),
|
||||
),
|
||||
_cardWrapper(
|
||||
child: ExpenseByStatusWidget(
|
||||
controller: dashboardController,
|
||||
),
|
||||
),
|
||||
_cardWrapper(
|
||||
child: MonthlyExpenseDashboardChart(),
|
||||
),
|
||||
MySpacing.height(20),
|
||||
],
|
||||
),
|
||||
@ -484,5 +582,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
||||
class _DashboardCardMeta {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
_DashboardCardMeta(this.icon, this.color);
|
||||
|
||||
const _DashboardCardMeta(this.icon, this.color);
|
||||
}
|
||||
|
||||
134
lib/view/infraProject/infra_project_screen.dart
Normal file
134
lib/view/infraProject/infra_project_screen.dart
Normal file
@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
|
||||
|
||||
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||
import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
||||
|
||||
// === Your 3 Screens ===
|
||||
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
|
||||
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
|
||||
|
||||
class InfraProjectsMainScreen extends StatefulWidget {
|
||||
const InfraProjectsMainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<InfraProjectsMainScreen> createState() =>
|
||||
_InfraProjectsMainScreenState();
|
||||
}
|
||||
|
||||
class _InfraProjectsMainScreenState extends State<InfraProjectsMainScreen>
|
||||
with SingleTickerProviderStateMixin, UIMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
final DynamicMenuController menuController = Get.find<DynamicMenuController>();
|
||||
|
||||
// Final tab list after filtering
|
||||
final List<_InfraTab> _tabs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prepareTabs();
|
||||
}
|
||||
|
||||
void _prepareTabs() {
|
||||
// Use the same permission logic used in your dashboard_cards
|
||||
final allowedMenu = menuController.menuItems.where((m) => m.available);
|
||||
|
||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
|
||||
_tabs.add(
|
||||
_InfraTab(
|
||||
name: "Task Planning",
|
||||
view: DailyTaskPlanningScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
|
||||
_tabs.add(
|
||||
_InfraTab(
|
||||
name: "Task Progress",
|
||||
view: DailyProgressReportScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color appBarColor = contentTheme.primary;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF1F1F1),
|
||||
appBar: CustomAppBar(
|
||||
title: "Infra Projects",
|
||||
onBackPressed: () => Get.back(),
|
||||
backgroundColor: appBarColor,
|
||||
),
|
||||
|
||||
body: Stack(
|
||||
children: [
|
||||
// Top faded gradient
|
||||
Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
appBarColor,
|
||||
appBarColor.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: Column(
|
||||
children: [
|
||||
// PILL TABS
|
||||
PillTabBar(
|
||||
controller: _tabController,
|
||||
tabs: _tabs.map((e) => e.name).toList(),
|
||||
selectedColor: contentTheme.primary,
|
||||
unselectedColor: Colors.grey.shade600,
|
||||
indicatorColor: contentTheme.primary,
|
||||
),
|
||||
|
||||
// TAB CONTENT
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: _tabs.map((e) => e.view).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// INTERNAL MODEL
|
||||
class _InfraTab {
|
||||
final String name;
|
||||
final Widget view;
|
||||
|
||||
_InfraTab({required this.name, required this.view});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user