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:
Vaibhav Surve 2025-12-03 13:09:48 +05:30
parent cf85c17d75
commit 03e3e7b5db
9 changed files with 877 additions and 408 deletions

View File

@ -1,37 +1,41 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.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/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/my_image_compressor.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.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/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_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/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 { class AttendanceController extends GetxController {
// ------------------ Data Models ------------------ // ------------------ Data Models ------------------
List<AttendanceModel> attendances = []; final List<AttendanceModel> attendances = <AttendanceModel>[];
List<ProjectModel> projects = []; final List<ProjectModel> projects = <ProjectModel>[];
List<EmployeeModel> employees = []; final List<EmployeeModel> employees = <EmployeeModel>[];
List<AttendanceLogModel> attendanceLogs = []; final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[];
List<RegularizationLogModel> regularizationLogs = []; final List<RegularizationLogModel> regularizationLogs =
List<AttendanceLogViewModel> attendenceLogsView = []; <RegularizationLogModel>[];
final List<AttendanceLogViewModel> attendenceLogsView =
<AttendanceLogViewModel>[];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
List<Organization> organizations = []; final List<Organization> organizations = <Organization>[];
Organization? selectedOrganization; Organization? selectedOrganization;
final isLoadingOrganizations = false.obs; final RxBool isLoadingOrganizations = false.obs;
// ------------------ States ------------------ // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
@ -42,16 +46,17 @@ class AttendanceController extends GetxController {
final Rx<DateTime> endDateAttendance = final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs; DateTime.now().subtract(const Duration(days: 1)).obs;
final isLoading = true.obs; final RxBool isLoading = true.obs;
final isLoadingProjects = true.obs; final RxBool isLoadingProjects = true.obs;
final isLoadingEmployees = true.obs; final RxBool isLoadingEmployees = true.obs;
final isLoadingAttendanceLogs = true.obs; final RxBool isLoadingAttendanceLogs = true.obs;
final isLoadingRegularizationLogs = true.obs; final RxBool isLoadingRegularizationLogs = true.obs;
final isLoadingLogView = true.obs; final RxBool isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs;
final searchQuery = ''.obs; final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
final RxBool showPendingOnly = false.obs;
final RxString searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
@ -64,35 +69,43 @@ class AttendanceController extends GetxController {
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
final today = DateTime.now(); final DateTime today = DateTime.now();
startDateAttendance.value = today.subtract(const Duration(days: 7)); startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance.value = today.subtract(const Duration(days: 1)); endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe( logSafe(
"Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}"); 'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
} }
// ------------------ Computed Filters ------------------ // ------------------ Computed Filters ------------------
List<EmployeeModel> get filteredEmployees { List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees; final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return employees;
return employees return employees
.where((e) => .where(
e.name.toLowerCase().contains(searchQuery.value.toLowerCase())) (EmployeeModel e) => e.name.toLowerCase().contains(query),
)
.toList(); .toList();
} }
List<AttendanceLogModel> get filteredLogs { List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs; final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return attendanceLogs;
return attendanceLogs return attendanceLogs
.where((log) => .where(
log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) (AttendanceLogModel log) => log.name.toLowerCase().contains(query),
)
.toList(); .toList();
} }
List<RegularizationLogModel> get filteredRegularizationLogs { List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs; final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return regularizationLogs;
return regularizationLogs return regularizationLogs
.where((log) => .where(
log.name.toLowerCase().contains(searchQuery.value.toLowerCase())) (RegularizationLogModel log) =>
log.name.toLowerCase().contains(query),
)
.toList(); .toList();
} }
@ -100,13 +113,16 @@ class AttendanceController extends GetxController {
Future<void> refreshDataFromNotification({String? projectId}) async { Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) { if (projectId == null) {
logSafe("No project selected for attendance refresh from notification", logSafe(
level: LogLevel.warning); 'No project selected for attendance refresh from notification',
level: LogLevel.warning,
);
return; return;
} }
await fetchProjectData(projectId); await fetchProjectData(projectId);
logSafe( logSafe(
"Attendance data refreshed from notification for project $projectId"); 'Attendance data refreshed from notification for project $projectId',
);
} }
Future<void> fetchTodaysAttendance(String? projectId) async { Future<void> fetchTodaysAttendance(String? projectId) async {
@ -114,19 +130,35 @@ class AttendanceController extends GetxController {
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final response = await ApiService.getTodaysAttendance( final List<dynamic>? response = await ApiService.getTodaysAttendance(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); employees
for (var emp in 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; uploadingStates[emp.id] = false.obs;
} }
logSafe("Employees fetched: ${employees.length} for project $projectId");
logSafe(
'Employees fetched: ${employees.length} for project $projectId',
);
} else { } else {
logSafe("Failed to fetch employees for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch employees for project $projectId',
level: LogLevel.error,
);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
@ -135,14 +167,22 @@ class AttendanceController extends GetxController {
Future<void> fetchOrganizations(String projectId) async { Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true; isLoadingOrganizations.value = true;
// Keep original return type inference from your ApiService
final response = await ApiService.getAssignedOrganizations(projectId); final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) { if (response != null) {
organizations = response.data; organizations
logSafe("Organizations fetched: ${organizations.length}"); ..clear()
..addAll(response.data);
logSafe('Organizations fetched: ${organizations.length}');
} else { } else {
logSafe("Failed to fetch organizations for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch organizations for project $projectId',
level: LogLevel.error,
);
} }
isLoadingOrganizations.value = false; isLoadingOrganizations.value = false;
update(); update();
} }
@ -152,61 +192,43 @@ class AttendanceController extends GetxController {
String id, String id,
String employeeId, String employeeId,
String projectId, { String projectId, {
String comment = "Marked via mobile app", String comment = 'Marked via mobile app',
required int action, required int action,
bool imageCapture = true, bool imageCapture = true,
String? markTime, String? markTime,
String? date, String? date,
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; _setUploading(employeeId, true);
XFile? image; final XFile? image = await _captureAndPrepareImage(
if (imageCapture) { employeeId: employeeId,
image = await ImagePicker() imageCapture: imageCapture,
.pickImage(source: ImageSource.camera, imageQuality: 80); );
if (image == null) { if (imageCapture && image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning); return false;
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? position = await _getCurrentPositionSafely();
final position = await Geolocator.getCurrentPosition( if (position == null) return false;
desiredAccuracy: LocationAccuracy.high);
final imageName = imageCapture final String imageName = imageCapture
? ApiService.generateImageName(employeeId, employees.length + 1) ? ApiService.generateImageName(
: ""; employeeId,
employees.length + 1,
)
: '';
final now = DateTime.now(); final DateTime effectiveDate =
DateTime effectiveDate = now; _resolveEffectiveDateForAction(action, employeeId);
if (action == 1) { final DateTime now = DateTime.now();
final log = attendanceLogs.firstWhereOrNull( final String formattedMarkTime =
(log) => log.employeeId == employeeId && log.checkOut == null, markTime ?? DateFormat('hh:mm a').format(now);
); final String formattedDate =
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); date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
final result = await ApiService.uploadAttendanceImage( final bool result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
image, image,
@ -221,15 +243,99 @@ class AttendanceController extends GetxController {
date: formattedDate, date: formattedDate,
); );
logSafe( if (result) {
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate"); 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; return result;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error uploading attendance", logSafe(
level: LogLevel.error, error: e, stackTrace: stacktrace); 'Error uploading attendance',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
return false; return false;
} finally { } 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) { if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission(); permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning); logSafe(
'Location permissions are denied',
level: LogLevel.warning,
);
return false; return false;
} }
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied', logSafe(
level: LogLevel.error); 'Location permissions are permanently denied',
level: LogLevel.error,
);
return false; return false;
} }
@ -254,25 +365,40 @@ class AttendanceController extends GetxController {
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(String? projectId, Future<void> fetchAttendanceLogs(
{DateTime? dateFrom, DateTime? dateTo}) async { String? projectId, {
DateTime? dateFrom,
DateTime? dateTo,
}) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingAttendanceLogs.value = true; isLoadingAttendanceLogs.value = true;
final response = await ApiService.getAttendanceLogs( final List<dynamic>? response = await ApiService.getAttendanceLogs(
projectId, projectId,
dateFrom: dateFrom, dateFrom: dateFrom,
dateTo: dateTo, dateTo: dateTo,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
attendanceLogs = attendanceLogs
response.map((e) => AttendanceLogModel.fromJson(e)).toList(); ..clear()
logSafe("Attendance logs fetched: ${attendanceLogs.length}"); ..addAll(
response
.map<AttendanceLogModel>(
(dynamic e) => AttendanceLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
} else { } else {
logSafe("Failed to fetch attendance logs for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch attendance logs for project $projectId',
level: LogLevel.error,
);
} }
isLoadingAttendanceLogs.value = false; isLoadingAttendanceLogs.value = false;
@ -280,25 +406,37 @@ class AttendanceController extends GetxController {
} }
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() { Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{}; final Map<String, List<AttendanceLogModel>> groupedLogs =
<String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) { for (final AttendanceLogModel logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null final String checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!) ? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown'; : 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
groupedLogs.putIfAbsent(
checkInDate,
() => <AttendanceLogModel>[],
)..add(logItem);
} }
final sortedEntries = groupedLogs.entries.toList() final List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries =
..sort((a, b) { groupedLogs.entries.toList()
if (a.key == 'Unknown') return 1; ..sort(
if (b.key == 'Unknown') return -1; (MapEntry<String, List<AttendanceLogModel>> a,
final dateA = DateFormat('dd MMM yyyy').parse(a.key); MapEntry<String, List<AttendanceLogModel>> b) {
final dateB = DateFormat('dd MMM yyyy').parse(b.key); if (a.key == 'Unknown') return 1;
return dateB.compareTo(dateA); if (b.key == 'Unknown') return -1;
});
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 ------------------ // ------------------ Regularization Logs ------------------
@ -307,17 +445,31 @@ class AttendanceController extends GetxController {
isLoadingRegularizationLogs.value = true; isLoadingRegularizationLogs.value = true;
final response = await ApiService.getRegularizationLogs( final List<dynamic>? response = await ApiService.getRegularizationLogs(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
regularizationLogs = regularizationLogs
response.map((e) => RegularizationLogModel.fromJson(e)).toList(); ..clear()
logSafe("Regularization logs fetched: ${regularizationLogs.length}"); ..addAll(
response
.map<RegularizationLogModel>(
(dynamic e) => RegularizationLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe(
'Regularization logs fetched: ${regularizationLogs.length}',
);
} else { } else {
logSafe("Failed to fetch regularization logs for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch regularization logs for project $projectId',
level: LogLevel.error,
);
} }
isLoadingRegularizationLogs.value = false; isLoadingRegularizationLogs.value = false;
@ -330,16 +482,33 @@ class AttendanceController extends GetxController {
isLoadingLogView.value = true; isLoadingLogView.value = true;
final response = await ApiService.getAttendanceLogView(id); final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
if (response != null) { if (response != null) {
attendenceLogsView = attendenceLogsView
response.map((e) => AttendanceLogViewModel.fromJson(e)).toList(); ..clear()
attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000)) ..addAll(
.compareTo(a.activityTime ?? DateTime(2000))); response
logSafe("Attendance log view fetched for ID: $id"); .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 { } else {
logSafe("Failed to fetch attendance log view for ID $id", logSafe(
level: LogLevel.error); 'Failed to fetch attendance log view for ID $id',
level: LogLevel.error,
);
} }
isLoadingLogView.value = false; isLoadingLogView.value = false;
@ -375,16 +544,19 @@ class AttendanceController extends GetxController {
} }
logSafe( logSafe(
"Project data fetched for project ID: $projectId, tab: $selectedTab"); 'Project data fetched for project ID: $projectId, tab: $selectedTab',
update(); );
update();
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance( Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async { BuildContext context,
final today = DateTime.now(); AttendanceController controller,
) async {
final DateTime today = DateTime.now();
final picked = await showDateRangePicker( final DateTimeRange? picked = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2022), firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)), lastDate: today.subtract(const Duration(days: 1)),
@ -399,7 +571,8 @@ class AttendanceController extends GetxController {
endDateAttendance.value = picked.end; endDateAttendance.value = picked.end;
logSafe( logSafe(
"Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}"); 'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,

View File

@ -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/expense_type_report_model.dart';
import 'package:on_field_work/model/dashboard/monthly_expence_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/expense/expense_type_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// ========================= // =========================
@ -81,6 +82,10 @@ class DashboardController extends GetxController {
final RxInt selectedMonthsCount = 12.obs; final RxInt selectedMonthsCount = 12.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs; final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null); 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) { void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type; selectedExpenseType.value = type;
@ -100,24 +105,35 @@ class DashboardController extends GetxController {
super.onInit(); super.onInit();
logSafe( logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}', 'DashboardController initialized',
level: LogLevel.info, level: LogLevel.info,
); );
fetchAllDashboardData(); // React to project selection
// React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData(); 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], (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
fetchExpenseTypeReport( if (projectController.selectedProjectId.value.isNotEmpty) {
startDate: expenseReportStartDate.value, fetchExpenseTypeReport(
endDate: expenseReportEndDate.value, startDate: expenseReportStartDate.value,
); endDate: expenseReportEndDate.value,
);
}
}); });
// React to range changes
// React to attendance range changes
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
// React to project range changes
ever(projectSelectedRange, (_) => fetchProjectProgress()); 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) { void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration; selectedMonthlyExpenseDuration.value = duration;
@ -359,7 +395,8 @@ class DashboardController extends GetxController {
level: LogLevel.info); level: LogLevel.info);
} else { } else {
expenseTypeReportData.value = null; 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) { } catch (e, st) {
expenseTypeReportData.value = null; expenseTypeReportData.value = null;

View File

@ -1,9 +1,9 @@
class ApiEndpoints { 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://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.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://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"; static const String getMasterCurrencies = "/Master/currencies/list";
@ -44,6 +44,7 @@ class ApiEndpoints {
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team"; 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 getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";

View File

@ -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/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_status_response.dart';
import 'package:on_field_work/model/service_project/job_comments.dart'; import 'package:on_field_work/model/service_project/job_comments.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -3320,6 +3321,30 @@ class ApiService {
res != null ? _parseResponse(res, label: 'Employees') : null); 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( static Future<List<dynamic>?> getRegularizationLogs(
String projectId, { String projectId, {
String? organizationId, String? organizationId,

View File

@ -163,6 +163,9 @@ class MenuItems {
/// Service Projects /// Service Projects
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b"; 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. /// Contains all job status IDs used across the application.

View File

@ -271,13 +271,9 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? Container( ? const Text(
width: 60, 'Loading...',
height: 14, style: TextStyle(fontSize: 12, color: Colors.white),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
) )
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -38,11 +38,12 @@ class _AttendanceScreenState extends State<AttendanceScreen>
void initState() { void initState() {
super.initState(); super.initState();
// Watch permissions loaded
ever(permissionController.permissionsLoaded, (loaded) { ever(permissionController.permissionsLoaded, (loaded) {
if (loaded == true && !_tabsInitialized) { if (loaded == true && !_tabsInitialized) {
_initializeTabs(); WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {}); _initializeTabs();
setState(() {});
});
} }
}); });

View File

@ -1,18 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.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/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/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/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.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_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.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/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/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 { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@ -24,7 +29,10 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> with UIMixin { class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController = final DashboardController dashboardController =
Get.put(DashboardController(), permanent: true); 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>(); final ProjectController projectController = Get.find<ProjectController>();
bool hasMpin = true; bool hasMpin = true;
@ -37,12 +45,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
Future<void> _checkMpinStatus() async { Future<void> _checkMpinStatus() async {
hasMpin = await LocalStorage.getIsMpin(); hasMpin = await LocalStorage.getIsMpin();
if (mounted) setState(() {}); if (mounted) {
setState(() {});
}
} }
//--------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// REUSABLE CARD (smaller, minimal) // Helpers
//--------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Widget _cardWrapper({required Widget child}) { Widget _cardWrapper({required Widget child}) {
return Container( return Container(
@ -56,17 +66,13 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: Colors.black12.withOpacity(.05), color: Colors.black12.withOpacity(.05),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: const Offset(0, 4),
) ),
], ],
), ),
child: child, child: child,
); );
} }
//---------------------------------------------------------------------------
// SECTION TITLE
//---------------------------------------------------------------------------
Widget _sectionTitle(String title) { Widget _sectionTitle(String title) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8), padding: const EdgeInsets.only(left: 4, bottom: 8),
@ -81,189 +87,150 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
Widget _conditionalQuickActionCard() { // ---------------------------------------------------------------------------
String status = "1"; // <-- change as needed // Quick Actions
bool isCheckedIn = status == "O"; // ---------------------------------------------------------------------------
// Button color remains the same
Color buttonColor =
isCheckedIn ? Colors.red.shade700 : Colors.green.shade700;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3), // lighter/faded
contentTheme.primary.withOpacity(0.6), // slightly stronger
],
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,
color: Colors.white,
),
),
Icon(
isCheckedIn ? LucideIcons.log_out : LucideIcons.log_in,
color: Colors.white,
size: 24,
),
],
),
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),
),
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,
),
),
],
),
],
),
);
}
//---------------------------------------------------------------------------
// QUICK ACTIONS (updated to use the single card)
//---------------------------------------------------------------------------
Widget _quickActions() { Widget _quickActions() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionTitle("Quick Action"), _sectionTitle('Quick Action'),
_conditionalQuickActionCard(), 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),
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
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,
),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.white70,
),
],
),
),
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),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: attendanceController,
),
if (isCheckedIn) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: attendanceController,
),
],
],
),
],
),
);
}),
], ],
); );
} }
//---------------------------------------------------------------------------
// PROJECT DROPDOWN (clean compact)
//---------------------------------------------------------------------------
Widget _projectSelector() { // ---------------------------------------------------------------------------
return Obx(() { // Dashboard Modules
final isLoading = projectController.isLoading.value; // ---------------------------------------------------------------------------
final expanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects;
final selectedId = projectController.selectedProjectId.value;
if (isLoading) { Widget _dashboardModules() {
// 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() {
return Obx(() { return Obx(() {
if (menuController.isLoading.value) { if (menuController.isLoading.value) {
// Show skeleton instead of CircularProgressIndicator
return SkeletonLoaders.dashboardCardsSkeleton( 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.attendance,
MenuItems.employees, MenuItems.employees,
MenuItems.dailyTaskPlanning, MenuItems.dailyTaskPlanning,
@ -272,9 +239,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
MenuItems.finance, MenuItems.finance,
MenuItems.documents, MenuItems.documents,
MenuItems.serviceProjects, MenuItems.serviceProjects,
MenuItems.infraProjects,
]; ];
final meta = { final Map<String, _DashboardCardMeta> meta = {
MenuItems.attendance: MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success), _DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees: MenuItems.employees:
@ -286,97 +254,142 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
MenuItems.directory: MenuItems.directory:
_DashboardCardMeta(LucideIcons.folder, contentTheme.info), _DashboardCardMeta(LucideIcons.folder, contentTheme.info),
MenuItems.finance: MenuItems.finance:
_DashboardCardMeta(LucideIcons.wallet, contentTheme.info), _DashboardCardMeta(LucideIcons.wallet, contentTheme.info),
MenuItems.documents: MenuItems.documents:
_DashboardCardMeta(LucideIcons.file_text, contentTheme.info), _DashboardCardMeta(LucideIcons.file_text, contentTheme.info),
MenuItems.serviceProjects: MenuItems.serviceProjects:
_DashboardCardMeta(LucideIcons.package, contentTheme.info), _DashboardCardMeta(LucideIcons.package, contentTheme.info),
MenuItems.infraProjects:
_DashboardCardMeta(LucideIcons.building_2, contentTheme.primary),
}; };
final allowed = { final Map<String, dynamic> allowed = {
for (var m in menuController.menuItems) for (final m in menuController.menuItems)
if (m.available && meta.containsKey(m.id)) m.id: m 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
// **More compact grid**
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisCount: 4,
crossAxisSpacing: 6, crossAxisSpacing: 8,
mainAxisSpacing: 6, mainAxisSpacing: 8,
childAspectRatio: 1.2, // smaller & tighter childAspectRatio: 1.15,
), ),
itemCount: filtered.length, itemCount: filtered.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final id = filtered[index]; final String id = filtered[index];
final item = allowed[id]!; final item = allowed[id]!;
final cardMeta = meta[id]!; final _DashboardCardMeta cardMeta = meta[id]!;
final isEnabled = final bool isEnabled =
item.name == "Attendance" ? true : projectSelected; item.name == 'Attendance' ? true : projectSelected;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (!isEnabled) { if (!isEnabled) {
Get.defaultDialog( Get.snackbar(
title: "No Project Selected", 'Required',
middleText: "Please select a project first.", '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 { } else {
Get.toNamed(item.mobileLink); Get.toNamed(item.mobileLink);
} }
}, },
child: Container( child: Container(
// **Reduced padding**
padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isEnabled ? Colors.white : Colors.grey.shade100, color: Colors.white,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: Colors.black12.withOpacity(.1), color: isEnabled
width: 0.7, ? Colors.black12.withOpacity(0.06)
: Colors.transparent,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black12.withOpacity(.05), color: Colors.black.withOpacity(0.03),
blurRadius: 4, blurRadius: 4,
offset: const Offset(0, 2), offset: const Offset(0, 2),
) ),
], ],
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
cardMeta.icon, cardMeta.icon,
size: 20, size: 20,
color: color: isEnabled
isEnabled ? cardMeta.color : Colors.grey.shade400, ? cardMeta.color
: Colors.grey.shade300,
), ),
const SizedBox(height: 3), const SizedBox(height: 6),
Text( Padding(
item.name, padding: const EdgeInsets.symmetric(horizontal: 2),
textAlign: TextAlign.center, child: Text(
style: TextStyle( item.name,
fontSize: 9.5, textAlign: TextAlign.center,
fontWeight: FontWeight.w600, style: TextStyle(
color: fontSize: 10,
isEnabled ? Colors.black87 : Colors.grey.shade600, fontWeight: isEnabled
? FontWeight.w600
: FontWeight.w400,
color: isEnabled
? Colors.black87
: Colors.grey.shade400,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
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( return Container(
margin: const EdgeInsets.only(top: 10), margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -405,17 +496,19 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
], ],
), ),
constraints: constraints: BoxConstraints(
BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.33), maxHeight: MediaQuery.of(context).size.height * 0.33,
),
child: Column( child: Column(
children: [ children: [
TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search project...", hintText: 'Search project...',
isDense: true, isDense: true,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
border: border: OutlineInputBorder(
OutlineInputBorder(borderRadius: BorderRadius.circular(5)), borderRadius: BorderRadius.circular(5),
),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -428,9 +521,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
dense: true, dense: true,
value: project.id, value: project.id,
groupValue: selectedId, groupValue: selectedId,
onChanged: (v) { onChanged: (value) {
if (v != null) { if (value != null) {
projectController.updateSelectedProject(v); projectController.updateSelectedProject(value);
projectController.isProjectSelectionExpanded.value = projectController.isProjectSelectionExpanded.value =
false; false;
} }
@ -445,9 +538,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
); );
} }
//--------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MAIN UI // Build
//--------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -460,18 +553,23 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_projectSelector(), _projectSelector(),
// MySpacing.height(20),
// _quickActions(),
MySpacing.height(20), MySpacing.height(20),
// The updated module cards _quickActions(),
_dashboardCards(),
MySpacing.height(20), MySpacing.height(20),
_sectionTitle("Reports & Analytics"), _dashboardModules(),
_cardWrapper(child: ExpenseTypeReportChart()), MySpacing.height(20),
_sectionTitle('Reports & Analytics'),
_cardWrapper( _cardWrapper(
child: child: ExpenseTypeReportChart(),
ExpenseByStatusWidget(controller: dashboardController)), ),
_cardWrapper(child: MonthlyExpenseDashboardChart()), _cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(20), MySpacing.height(20),
], ],
), ),
@ -484,5 +582,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
class _DashboardCardMeta { class _DashboardCardMeta {
final IconData icon; final IconData icon;
final Color color; final Color color;
_DashboardCardMeta(this.icon, this.color);
const _DashboardCardMeta(this.icon, this.color);
} }

View 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});
}