Compare commits

..

4 Commits

61 changed files with 4546 additions and 9531 deletions

View File

@ -1,41 +1,37 @@
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:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/api_service.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_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/attendance_model.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/project_model.dart'; import 'package:on_field_work/model/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/regularization_log_model.dart';
import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/controller/project_controller.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// ------------------ Data Models ------------------ // ------------------ Data Models ------------------
final List<AttendanceModel> attendances = <AttendanceModel>[]; List<AttendanceModel> attendances = [];
final List<ProjectModel> projects = <ProjectModel>[]; List<ProjectModel> projects = [];
final List<EmployeeModel> employees = <EmployeeModel>[]; List<EmployeeModel> employees = [];
final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[]; List<AttendanceLogModel> attendanceLogs = [];
final List<RegularizationLogModel> regularizationLogs = List<RegularizationLogModel> regularizationLogs = [];
<RegularizationLogModel>[]; List<AttendanceLogViewModel> attendenceLogsView = [];
final List<AttendanceLogViewModel> attendenceLogsView =
<AttendanceLogViewModel>[];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
final List<Organization> organizations = <Organization>[]; List<Organization> organizations = [];
Organization? selectedOrganization; Organization? selectedOrganization;
final RxBool isLoadingOrganizations = false.obs; final isLoadingOrganizations = false.obs;
// ------------------ States ------------------ // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
@ -46,17 +42,16 @@ 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 RxBool isLoading = true.obs; final isLoading = true.obs;
final RxBool isLoadingProjects = true.obs; final isLoadingProjects = true.obs;
final RxBool isLoadingEmployees = true.obs; final isLoadingEmployees = true.obs;
final RxBool isLoadingAttendanceLogs = true.obs; final isLoadingAttendanceLogs = true.obs;
final RxBool isLoadingRegularizationLogs = true.obs; final isLoadingRegularizationLogs = true.obs;
final RxBool isLoadingLogView = true.obs; final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs;
final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; final searchQuery = ''.obs;
final RxBool showPendingOnly = false.obs;
final RxString searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
@ -69,43 +64,35 @@ class AttendanceController extends GetxController {
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
final DateTime today = DateTime.now(); final 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 {
final String query = searchQuery.value.trim().toLowerCase(); if (searchQuery.value.isEmpty) return employees;
if (query.isEmpty) return employees;
return employees return employees
.where( .where((e) =>
(EmployeeModel e) => e.name.toLowerCase().contains(query), e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
)
.toList(); .toList();
} }
List<AttendanceLogModel> get filteredLogs { List<AttendanceLogModel> get filteredLogs {
final String query = searchQuery.value.trim().toLowerCase(); if (searchQuery.value.isEmpty) return attendanceLogs;
if (query.isEmpty) return attendanceLogs;
return attendanceLogs return attendanceLogs
.where( .where((log) =>
(AttendanceLogModel log) => log.name.toLowerCase().contains(query), log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
)
.toList(); .toList();
} }
List<RegularizationLogModel> get filteredRegularizationLogs { List<RegularizationLogModel> get filteredRegularizationLogs {
final String query = searchQuery.value.trim().toLowerCase(); if (searchQuery.value.isEmpty) return regularizationLogs;
if (query.isEmpty) return regularizationLogs;
return regularizationLogs return regularizationLogs
.where( .where((log) =>
(RegularizationLogModel log) => log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
log.name.toLowerCase().contains(query),
)
.toList(); .toList();
} }
@ -113,16 +100,13 @@ 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( logSafe("No project selected for attendance refresh from notification",
'No project selected for attendance refresh from notification', level: LogLevel.warning);
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 {
@ -130,35 +114,19 @@ class AttendanceController extends GetxController {
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final List<dynamic>? response = await ApiService.getTodaysAttendance( final response = await ApiService.getTodaysAttendance(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
employees employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
..clear() for (var emp in employees) {
..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( logSafe("Failed to fetch employees for project $projectId",
'Failed to fetch employees for project $projectId', level: LogLevel.error);
level: LogLevel.error,
);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
@ -167,22 +135,14 @@ 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 organizations = response.data;
..clear() logSafe("Organizations fetched: ${organizations.length}");
..addAll(response.data);
logSafe('Organizations fetched: ${organizations.length}');
} else { } else {
logSafe( logSafe("Failed to fetch organizations for project $projectId",
'Failed to fetch organizations for project $projectId', level: LogLevel.error);
level: LogLevel.error,
);
} }
isLoadingOrganizations.value = false; isLoadingOrganizations.value = false;
update(); update();
} }
@ -192,43 +152,61 @@ 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 {
_setUploading(employeeId, true); uploadingStates[employeeId]?.value = true;
final XFile? image = await _captureAndPrepareImage( XFile? image;
employeeId: employeeId, if (imageCapture) {
imageCapture: imageCapture, image = await ImagePicker()
); .pickImage(source: ImageSource.camera, imageQuality: 80);
if (imageCapture && image == null) { if (image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning);
return false; return false;
} }
final Position? position = await _getCurrentPositionSafely(); final timestampedFile = await TimestampImageHelper.addTimestamp(
if (position == null) return false; imageFile: File(image.path));
final String imageName = imageCapture final compressedBytes =
? ApiService.generateImageName( await compressImageToUnder100KB(timestampedFile);
employeeId, if (compressedBytes == null) {
employees.length + 1, logSafe("Image compression failed.", level: LogLevel.error);
) return false;
: ''; }
final DateTime effectiveDate = final compressedFile = await saveCompressedImageToFile(compressedBytes);
_resolveEffectiveDateForAction(action, employeeId); image = XFile(compressedFile.path);
}
final DateTime now = DateTime.now(); if (!await _handleLocationPermission()) return false;
final String formattedMarkTime = final position = await Geolocator.getCurrentPosition(
markTime ?? DateFormat('hh:mm a').format(now); desiredAccuracy: LocationAccuracy.high);
final String formattedDate =
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,
);
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 bool result = await ApiService.uploadAttendanceImage( final result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
image, image,
@ -243,99 +221,15 @@ class AttendanceController extends GetxController {
date: formattedDate, date: formattedDate,
); );
if (result) {
logSafe( 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; return result;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe( logSafe("Error uploading attendance",
'Error uploading attendance', level: LogLevel.error, error: e, stackTrace: stacktrace);
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
return false; return false;
} finally { } finally {
_setUploading(employeeId, false); uploadingStates[employeeId]?.value = 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;
} }
} }
@ -345,19 +239,14 @@ 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( logSafe('Location permissions are denied', level: LogLevel.warning);
'Location permissions are denied',
level: LogLevel.warning,
);
return false; return false;
} }
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
logSafe( logSafe('Location permissions are permanently denied',
'Location permissions are permanently denied', level: LogLevel.error);
level: LogLevel.error,
);
return false; return false;
} }
@ -365,40 +254,25 @@ class AttendanceController extends GetxController {
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs( Future<void> fetchAttendanceLogs(String? projectId,
String? projectId, { {DateTime? dateFrom, DateTime? dateTo}) async {
DateTime? dateFrom,
DateTime? dateTo,
}) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingAttendanceLogs.value = true; isLoadingAttendanceLogs.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogs( final 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 =
..clear() response.map((e) => AttendanceLogModel.fromJson(e)).toList();
..addAll( logSafe("Attendance logs fetched: ${attendanceLogs.length}");
response
.map<AttendanceLogModel>(
(dynamic e) => AttendanceLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
} else { } else {
logSafe( logSafe("Failed to fetch attendance logs for project $projectId",
'Failed to fetch attendance logs for project $projectId', level: LogLevel.error);
level: LogLevel.error,
);
} }
isLoadingAttendanceLogs.value = false; isLoadingAttendanceLogs.value = false;
@ -406,37 +280,25 @@ class AttendanceController extends GetxController {
} }
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() { Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final Map<String, List<AttendanceLogModel>> groupedLogs = final groupedLogs = <String, List<AttendanceLogModel>>{};
<String, List<AttendanceLogModel>>{};
for (final AttendanceLogModel logItem in attendanceLogs) { for (var logItem in attendanceLogs) {
final String checkInDate = logItem.checkIn != null final 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 List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries = final sortedEntries = groupedLogs.entries.toList()
groupedLogs.entries.toList() ..sort((a, b) {
..sort(
(MapEntry<String, List<AttendanceLogModel>> a,
MapEntry<String, List<AttendanceLogModel>> b) {
if (a.key == 'Unknown') return 1; if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1; if (b.key == 'Unknown') return -1;
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
final DateTime dateA = DateFormat('dd MMM yyyy').parse(a.key); final dateB = DateFormat('dd MMM yyyy').parse(b.key);
final DateTime dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA); return dateB.compareTo(dateA);
}, });
);
return Map<String, List<AttendanceLogModel>>.fromEntries( return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
sortedEntries,
);
} }
// ------------------ Regularization Logs ------------------ // ------------------ Regularization Logs ------------------
@ -445,31 +307,17 @@ class AttendanceController extends GetxController {
isLoadingRegularizationLogs.value = true; isLoadingRegularizationLogs.value = true;
final List<dynamic>? response = await ApiService.getRegularizationLogs( final response = await ApiService.getRegularizationLogs(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
regularizationLogs regularizationLogs =
..clear() response.map((e) => RegularizationLogModel.fromJson(e)).toList();
..addAll( logSafe("Regularization logs fetched: ${regularizationLogs.length}");
response
.map<RegularizationLogModel>(
(dynamic e) => RegularizationLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe(
'Regularization logs fetched: ${regularizationLogs.length}',
);
} else { } else {
logSafe( logSafe("Failed to fetch regularization logs for project $projectId",
'Failed to fetch regularization logs for project $projectId', level: LogLevel.error);
level: LogLevel.error,
);
} }
isLoadingRegularizationLogs.value = false; isLoadingRegularizationLogs.value = false;
@ -482,33 +330,16 @@ class AttendanceController extends GetxController {
isLoadingLogView.value = true; isLoadingLogView.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogView(id); final response = await ApiService.getAttendanceLogView(id);
if (response != null) { if (response != null) {
attendenceLogsView attendenceLogsView =
..clear() response.map((e) => AttendanceLogViewModel.fromJson(e)).toList();
..addAll( attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000))
response .compareTo(a.activityTime ?? DateTime(2000)));
.map<AttendanceLogViewModel>( logSafe("Attendance log view fetched for ID: $id");
(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( logSafe("Failed to fetch attendance log view for ID $id",
'Failed to fetch attendance log view for ID $id', level: LogLevel.error);
level: LogLevel.error,
);
} }
isLoadingLogView.value = false; isLoadingLogView.value = false;
@ -544,19 +375,16 @@ 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, BuildContext context, AttendanceController controller) async {
AttendanceController controller, final today = DateTime.now();
) async {
final DateTime today = DateTime.now();
final DateTimeRange? picked = await showDateRangePicker( final 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)),
@ -571,8 +399,7 @@ 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,201 +7,191 @@ 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';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// Dependencies // =========================
final ProjectController projectController = Get.put(ProjectController()); // Attendance overview
// =========================
final RxList<Map<String, dynamic>> roleWiseData =
<Map<String, dynamic>>[].obs;
final RxString attendanceSelectedRange = '15D'.obs;
final RxBool attendanceIsChartView = true.obs;
final RxBool isAttendanceLoading = false.obs;
// ========================= // =========================
// 1. STATE VARIABLES // Project progress overview
// ========================= // =========================
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
final RxString projectSelectedRange = '15D'.obs;
final RxBool projectIsChartView = true.obs;
final RxBool isProjectLoading = false.obs;
// Attendance // =========================
final roleWiseData = <Map<String, dynamic>>[].obs; // Projects overview
final attendanceSelectedRange = '15D'.obs; // =========================
final attendanceIsChartView = true.obs; final RxInt totalProjects = 0.obs;
final isAttendanceLoading = false.obs; final RxInt ongoingProjects = 0.obs;
final RxBool isProjectsLoading = false.obs;
// Project Progress // =========================
final projectChartData = <ChartTaskData>[].obs; // Tasks overview
final projectSelectedRange = '15D'.obs; // =========================
final projectIsChartView = true.obs; final RxInt totalTasks = 0.obs;
final isProjectLoading = false.obs; final RxInt completedTasks = 0.obs;
final RxBool isTasksLoading = false.obs;
// Overview Counts // =========================
final totalProjects = 0.obs; // Teams overview
final ongoingProjects = 0.obs; // =========================
final isProjectsLoading = false.obs; final RxInt totalEmployees = 0.obs;
final RxInt inToday = 0.obs;
final RxBool isTeamsLoading = false.obs;
final totalTasks = 0.obs; // Common ranges
final completedTasks = 0.obs;
final isTasksLoading = false.obs;
final totalEmployees = 0.obs;
final inToday = 0.obs;
final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final expenseReportEndDate = DateTime.now().obs;
final isMonthlyExpenseLoading = false.obs;
final monthlyExpenseList = <MonthlyExpenseData>[].obs;
final selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final selectedMonthsCount = 12.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// Teams/Employees
final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs;
// Collection
final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
// Inject ProjectController
final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// ========================= // =========================
// 2. COMPUTED PROPERTIES // Expense Category Report
// =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
Rx<ExpenseTypeReportData?>(null);
final Rx<DateTime> expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
// ========================= // =========================
// Monthly Expense Report
// =========================
final RxBool isMonthlyExpenseLoading = false.obs;
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7; final RxInt selectedMonthsCount = 12.obs;
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7; final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// DSO Calculation Constants void updateSelectedExpenseType(ExpenseTypeModel? type) {
static const double _w0_30 = 15.0; selectedExpenseType.value = type;
static const double _w30_60 = 45.0;
static const double _w60_90 = 75.0;
static const double _w90_plus = 105.0;
double get calculatedDSO { // Debug print to verify
final data = collectionOverviewData.value; print('Selected: ${type?.name ?? "All Types"}');
if (data == null || data.totalDueAmount == 0) return 0.0;
final double weightedDue = (data.bucket0To30Amount * _w0_30) + if (type == null) {
(data.bucket30To60Amount * _w30_60) + fetchMonthlyExpenses();
(data.bucket60To90Amount * _w60_90) + } else {
(data.bucket90PlusAmount * _w90_plus); fetchMonthlyExpenses(categoryId: type.id);
}
return weightedDue / data.totalDueAmount;
} }
// =========================
// 3. LIFECYCLE
// =========================
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info);
// Project Selection Listener logSafe(
ever<String>(projectController.selectedProjectId, (id) { 'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
if (id.isNotEmpty) { level: LogLevel.info,
);
fetchAllDashboardData(); fetchAllDashboardData();
fetchTodaysAttendance(id);
}
});
// Expense Report Date Listener // React to project change
ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData();
});
everAll([expenseReportStartDate, expenseReportEndDate], (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchExpenseTypeReport( fetchExpenseTypeReport(
startDate: expenseReportStartDate.value, startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
); );
}
}); });
// React to range changes
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
} }
// ========================= // =========================
// 4. USER ACTIONS // Helper Methods
// ========================= // =========================
int _getDaysFromRange(String range) {
switch (range) {
case '7D':
return 7;
case '15D':
return 15;
case '30D':
return 30;
case '3M':
return 90;
case '6M':
return 180;
default:
return 7;
}
}
void updateAttendanceRange(String range) => int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
void updateAttendanceRange(String range) {
attendanceSelectedRange.value = range; attendanceSelectedRange.value = range;
void updateProjectRange(String range) => projectSelectedRange.value = range; logSafe('Attendance range updated to $range', level: LogLevel.debug);
void toggleAttendanceChartView(bool isChart) => }
void updateProjectRange(String range) {
projectSelectedRange.value = range;
logSafe('Project range updated to $range', level: LogLevel.debug);
}
void toggleAttendanceChartView(bool isChart) {
attendanceIsChartView.value = isChart; attendanceIsChartView.value = isChart;
void toggleProjectChartView(bool isChart) => logSafe('Attendance chart view toggled to: $isChart',
level: LogLevel.debug);
}
void toggleProjectChartView(bool isChart) {
projectIsChartView.value = isChart; projectIsChartView.value = isChart;
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
fetchMonthlyExpenses(categoryId: type?.id);
} }
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { // =========================
selectedMonthlyExpenseDuration.value = duration; // Manual Refresh Methods
// =========================
// Efficient Map lookup instead of Switch Future<void> refreshDashboard() async {
const durationMap = { logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
MonthlyExpenseDuration.oneMonth: 1, await fetchAllDashboardData();
MonthlyExpenseDuration.threeMonths: 3,
MonthlyExpenseDuration.sixMonths: 6,
MonthlyExpenseDuration.twelveMonths: 12,
MonthlyExpenseDuration.all: 0,
};
selectedMonthsCount.value = durationMap[duration] ?? 12;
fetchMonthlyExpenses();
} }
Future<void> refreshDashboard() => fetchAllDashboardData(); Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async { Future<void> refreshTasks() async {
final id = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id); if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
} }
Future<void> refreshProjects() async => fetchProjectProgress();
// ========================= // =========================
// 5. DATA FETCHING (API) // Fetch All Dashboard Data
// ========================= // =========================
/// Wrapper to reduce try-finally boilerplate for loading states
Future<void> _executeApiCall(
RxBool loader, Future<void> Function() apiLogic) async {
loader.value = true;
try {
await apiLogic();
} finally {
loader.value = false;
}
}
Future<void> fetchAllDashboardData() async { Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
return;
}
await Future.wait([ await Future.wait([
fetchRoleWiseAttendance(), fetchRoleWiseAttendance(),
@ -214,150 +204,248 @@ class DashboardController extends GetxController {
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
), ),
fetchMonthlyExpenses(), fetchMonthlyExpenses(),
fetchMasterData(), fetchMasterData()
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]); ]);
} }
Future<void> fetchCollectionOverview() async { void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
final projectId = projectController.selectedProjectId.value; selectedMonthlyExpenseDuration.value = duration;
if (projectId.isEmpty) return;
await _executeApiCall(isCollectionOverviewLoading, () async { // Set months count based on selection
final response = switch (duration) {
await ApiService.getCollectionOverview(projectId: projectId); case MonthlyExpenseDuration.oneMonth:
collectionOverviewData.value = selectedMonthsCount.value = 1;
(response?.success == true) ? response!.data : null; break;
}); case MonthlyExpenseDuration.threeMonths:
selectedMonthsCount.value = 3;
break;
case MonthlyExpenseDuration.sixMonths:
selectedMonthsCount.value = 6;
break;
case MonthlyExpenseDuration.twelveMonths:
selectedMonthsCount.value = 12;
break;
case MonthlyExpenseDuration.all:
selectedMonthsCount.value = 0; // 0 = All months in your API
break;
} }
Future<void> fetchTodaysAttendance(String projectId) async { // Re-fetch updated data
await _executeApiCall(isLoadingEmployees, () async { fetchMonthlyExpenses();
final response = await ApiService.getAttendanceForDashboard(projectId);
if (response != null) {
employees.value = response;
for (var emp in employees) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
}
}
});
} }
Future<void> fetchMasterData() async { Future<void> fetchMasterData() async {
try { try {
final data = await ApiService.getMasterExpenseTypes(); final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (data is List) { if (expenseTypesData is List) {
expenseTypes.value = expenseTypes.value =
data.map((e) => ExpenseTypeModel.fromJson(e)).toList(); expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (e) {
logSafe('Error fetching master data', level: LogLevel.error, error: e);
} }
} catch (_) {}
} }
Future<void> fetchMonthlyExpenses({String? categoryId}) async { Future<void> fetchMonthlyExpenses({String? categoryId}) async {
await _executeApiCall(isMonthlyExpenseLoading, () async { try {
isMonthlyExpenseLoading.value = true;
int months = selectedMonthsCount.value;
logSafe(
'Fetching Monthly Expense Report for last $months months'
'${categoryId != null ? ' (categoryId: $categoryId)' : ''}',
level: LogLevel.info,
);
final response = await ApiService.getDashboardMonthlyExpensesApi( final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId, categoryId: categoryId,
months: selectedMonthsCount.value, months: months,
); );
monthlyExpenseList.value =
(response?.success == true) ? response!.data : []; if (response != null && response.success) {
}); monthlyExpenseList.value = response.data;
logSafe('Monthly Expense Report fetched successfully.',
level: LogLevel.info);
} else {
monthlyExpenseList.clear();
logSafe('Failed to fetch Monthly Expense Report.',
level: LogLevel.error);
}
} catch (e, st) {
monthlyExpenseList.clear();
logSafe('Error fetching Monthly Expense Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isMonthlyExpenseLoading.value = false;
} }
Future<void> fetchPurchaseInvoiceOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isPurchaseInvoiceLoading, () async {
final response = await ApiService.getPurchaseInvoiceOverview(
projectId: projectId,
);
purchaseInvoiceOverviewData.value =
(response?.success == true) ? response!.data : null;
});
} }
Future<void> fetchPendingExpenses() async { Future<void> fetchPendingExpenses() async {
final id = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (projectId.isEmpty) return;
await _executeApiCall(isPendingExpensesLoading, () async { try {
final response = await ApiService.getPendingExpensesApi(projectId: id); isPendingExpensesLoading.value = true;
pendingExpensesData.value = final response =
(response?.success == true) ? response!.data : null; await ApiService.getPendingExpensesApi(projectId: projectId);
});
if (response != null && response.success) {
pendingExpensesData.value = response.data;
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
} else {
pendingExpensesData.value = null;
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
}
} catch (e, st) {
pendingExpensesData.value = null;
logSafe('Error fetching pending expenses',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isPendingExpensesLoading.value = false;
}
} }
// =========================
// API Calls
// =========================
Future<void> fetchRoleWiseAttendance() async { Future<void> fetchRoleWiseAttendance() async {
final id = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (projectId.isEmpty) return;
await _executeApiCall(isAttendanceLoading, () async { try {
final response = await ApiService.getDashboardAttendanceOverview( isAttendanceLoading.value = true;
id, getAttendanceDays()); final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
if (response != null) {
roleWiseData.value = roleWiseData.value =
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []; response.map((e) => Map<String, dynamic>.from(e)).toList();
}); logSafe('Attendance overview fetched successfully.',
level: LogLevel.info);
} else {
roleWiseData.clear();
logSafe('Failed to fetch attendance overview: response is null.',
level: LogLevel.error);
}
} catch (e, st) {
roleWiseData.clear();
logSafe('Error fetching attendance overview',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isAttendanceLoading.value = false;
}
} }
Future<void> fetchExpenseTypeReport( Future<void> fetchExpenseTypeReport({
{required DateTime startDate, required DateTime endDate}) async { required DateTime startDate,
final id = projectController.selectedProjectId.value; required DateTime endDate,
if (id.isEmpty) return; }) async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isExpenseTypeReportLoading.value = true;
await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi( final response = await ApiService.getExpenseTypeReportApi(
projectId: id, projectId: projectId,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate,
); );
expenseTypeReportData.value =
(response?.success == true) ? response!.data : null; if (response != null && response.success) {
}); expenseTypeReportData.value = response.data;
logSafe('Expense Category Report fetched successfully.',
level: LogLevel.info);
} else {
expenseTypeReportData.value = null;
logSafe('Failed to fetch Expense Category Report.', level: LogLevel.error);
}
} catch (e, st) {
expenseTypeReportData.value = null;
logSafe('Error fetching Expense Category Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isExpenseTypeReportLoading.value = false;
}
} }
Future<void> fetchProjectProgress() async { Future<void> fetchProjectProgress() async {
final id = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (projectId.isEmpty) return;
await _executeApiCall(isProjectLoading, () async { try {
isProjectLoading.value = true;
final response = await ApiService.getProjectProgress( final response = await ApiService.getProjectProgress(
projectId: id, days: getProjectDays()); projectId: projectId, days: getProjectDays());
if (response?.success == true) {
projectChartData.value = response!.data if (response != null && response.success) {
.map((d) => ChartTaskData.fromProjectData(d)) projectChartData.value =
.toList(); response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
logSafe('Project progress data mapped for chart', level: LogLevel.info);
} else { } else {
projectChartData.clear(); projectChartData.clear();
logSafe('Failed to fetch project progress', level: LogLevel.error);
}
} catch (e, st) {
projectChartData.clear();
logSafe('Error fetching project progress',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isProjectLoading.value = false;
} }
});
} }
Future<void> fetchDashboardTasks({required String projectId}) async { Future<void> fetchDashboardTasks({required String projectId}) async {
await _executeApiCall(isTasksLoading, () async { if (projectId.isEmpty) return;
try {
isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId); final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) {
totalTasks.value = response!.data?.totalTasks ?? 0; if (response != null && response.success) {
totalTasks.value = response.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0; completedTasks.value = response.data?.completedTasks ?? 0;
logSafe('Dashboard tasks fetched', level: LogLevel.info);
} else { } else {
totalTasks.value = 0; totalTasks.value = 0;
completedTasks.value = 0; completedTasks.value = 0;
logSafe('Failed to fetch tasks', level: LogLevel.error);
}
} catch (e, st) {
totalTasks.value = 0;
completedTasks.value = 0;
logSafe('Error fetching tasks',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTasksLoading.value = false;
} }
});
} }
Future<void> fetchDashboardTeams({required String projectId}) async { Future<void> fetchDashboardTeams({required String projectId}) async {
await _executeApiCall(isTeamsLoading, () async { if (projectId.isEmpty) return;
try {
isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId); final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) {
totalEmployees.value = response!.data?.totalEmployees ?? 0; if (response != null && response.success) {
totalEmployees.value = response.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0; inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else { } else {
totalEmployees.value = 0; totalEmployees.value = 0;
inToday.value = 0; inToday.value = 0;
logSafe('Failed to fetch teams', level: LogLevel.error);
}
} catch (e, st) {
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Error fetching teams',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTeamsLoading.value = false;
} }
});
} }
} }

View File

@ -142,8 +142,8 @@ class DocumentController extends GetxController {
); );
if (response != null && response.success) { if (response != null && response.success) {
if (response.data?.data.isNotEmpty ?? false) { if (response.data.data.isNotEmpty) {
documents.addAll(response.data!.data); documents.addAll(response.data.data);
pageNumber.value++; pageNumber.value++;
} else { } else {
hasMore.value = false; hasMore.value = false;

View File

@ -1,48 +0,0 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
class InfraProjectController extends GetxController {
final projects = <ProjectData>[].obs;
final isLoading = false.obs;
final searchQuery = ''.obs;
// Filtered list
List<ProjectData> get filteredProjects {
final q = searchQuery.value.trim().toLowerCase();
if (q.isEmpty) return projects;
return projects.where((p) {
return (p.name?.toLowerCase().contains(q) ?? false) ||
(p.shortName?.toLowerCase().contains(q) ?? false) ||
(p.projectAddress?.toLowerCase().contains(q) ?? false) ||
(p.contactPerson?.toLowerCase().contains(q) ?? false);
}).toList();
}
// Fetch Projects
Future<void> fetchProjects({int pageNumber = 1, int pageSize = 20}) async {
try {
isLoading.value = true;
final response = await ApiService.getInfraProjectsList(
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.data != null) {
projects.assignAll(response.data!.data ?? []);
} else {
projects.clear();
}
} catch (e) {
rethrow;
} finally {
isLoading.value = false;
}
}
void updateSearch(String query) {
searchQuery.value = query;
}
}

View File

@ -1,38 +0,0 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
class InfraProjectDetailsController extends GetxController {
final String projectId;
InfraProjectDetailsController({required this.projectId});
var isLoading = true.obs;
var projectDetails = Rxn<ProjectData>();
var errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
fetchProjectDetails();
}
Future<void> fetchProjectDetails() async {
try {
isLoading.value = true;
final response = await ApiService.getInfraProjectDetails(projectId: projectId);
if (response != null && response.success == true && response.data != null) {
projectDetails.value = response.data;
isLoading.value = false;
} else {
errorMessage.value = response?.message ?? "Failed to load project details";
}
} catch (e) {
errorMessage.value = "Error fetching project details: $e";
} finally {
isLoading.value = false;
}
}
}

View File

@ -15,9 +15,6 @@ class PermissionController extends GetxController {
Timer? _refreshTimer; Timer? _refreshTimer;
var isLoading = true.obs; var isLoading = true.obs;
/// NEW: reactive flag to signal permissions are loaded
var permissionsLoaded = false.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -55,10 +52,6 @@ class PermissionController extends GetxController {
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");
// NEW: mark permissions as loaded
permissionsLoaded.value = true;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error loading data from API", logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace); level: LogLevel.error, error: e, stackTrace: stacktrace);
@ -110,7 +103,7 @@ class PermissionController extends GetxController {
} }
void _startAutoRefresh() { void _startAutoRefresh() {
_refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async { _refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered."); logSafe("Auto-refresh triggered.");
final token = await _getAuthToken(); final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
@ -124,6 +117,8 @@ class PermissionController extends GetxController {
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
// logSafe("Checking permission $permissionId: $hasPerm",
// level: LogLevel.debug);
return hasPerm; return hasPerm;
} }

View File

@ -76,7 +76,11 @@ class AddServiceProjectJobController extends GetxController {
startDate: startDate.value!, startDate: startDate.value!,
dueDate: dueDate.value!, dueDate: dueDate.value!,
tags: enteredTags tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true}) .map((tag) => {
"id": null,
"name": tag,
"isActive": true
})
.toList(), .toList(),
); );

View File

@ -1,3 +1,4 @@
// service_project_details_screen_controller.dart
import 'package:get/get.dart'; import 'package:get/get.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/model/service_project/service_projects_details_model.dart'; import 'package:on_field_work/model/service_project/service_projects_details_model.dart';
@ -6,12 +7,10 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart'; import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart';
import 'package:on_field_work/model/service_project/job_allocation_model.dart'; import 'package:on_field_work/model/service_project/job_allocation_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 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
class ServiceProjectDetailsController extends GetxController { class ServiceProjectDetailsController extends GetxController {
// -------------------- Observables -------------------- // -------------------- Observables --------------------
@ -30,8 +29,6 @@ class ServiceProjectDetailsController extends GetxController {
var errorMessage = ''.obs; var errorMessage = ''.obs;
var jobErrorMessage = ''.obs; var jobErrorMessage = ''.obs;
var jobDetailErrorMessage = ''.obs; var jobDetailErrorMessage = ''.obs;
final ImagePicker picker = ImagePicker();
var isProcessingAttachment = false.obs;
// Pagination // Pagination
var pageNumber = 1; var pageNumber = 1;
@ -45,16 +42,7 @@ class ServiceProjectDetailsController extends GetxController {
var isTeamLoading = false.obs; var isTeamLoading = false.obs;
var teamErrorMessage = ''.obs; var teamErrorMessage = ''.obs;
var filteredJobList = <JobEntity>[].obs; var filteredJobList = <JobEntity>[].obs;
// -------------------- Job Status --------------------
// With this:
var jobStatusList = <JobStatus>[].obs;
var selectedJobStatus = Rx<JobStatus?>(null);
var isJobStatusLoading = false.obs;
var jobStatusErrorMessage = ''.obs;
// -------------------- Job Comments --------------------
var jobComments = <CommentItem>[].obs;
var isCommentsLoading = false.obs;
var commentsErrorMessage = ''.obs;
// -------------------- Lifecycle -------------------- // -------------------- Lifecycle --------------------
@override @override
void onInit() { void onInit() {
@ -89,9 +77,7 @@ class ServiceProjectDetailsController extends GetxController {
final lowerSearch = searchText.toLowerCase(); final lowerSearch = searchText.toLowerCase();
return job.title.toLowerCase().contains(lowerSearch) || return job.title.toLowerCase().contains(lowerSearch) ||
(job.description.toLowerCase().contains(lowerSearch)) || (job.description.toLowerCase().contains(lowerSearch)) ||
(job.tags?.any( (job.tags?.any((tag) => tag.name.toLowerCase().contains(lowerSearch)) ?? false);
(tag) => tag.name.toLowerCase().contains(lowerSearch)) ??
false);
}).toList(); }).toList();
} }
} }
@ -106,10 +92,7 @@ class ServiceProjectDetailsController extends GetxController {
teamErrorMessage.value = ''; teamErrorMessage.value = '';
try { try {
final result = await ApiService.getServiceProjectAllocationList( final result = await ApiService.getServiceProjectAllocationList(projectId: projectId.value, isActive: true);
projectId: projectId.value,
isActive: true,
);
if (result != null) { if (result != null) {
teamList.value = result; teamList.value = result;
@ -123,41 +106,6 @@ class ServiceProjectDetailsController extends GetxController {
} }
} }
Future<void> fetchJobStatus({required String statusId}) async {
if (projectId.value.isEmpty) {
jobStatusErrorMessage.value = "Invalid project ID";
return;
}
isJobStatusLoading.value = true;
jobStatusErrorMessage.value = '';
try {
final statuses = await ApiService.getMasterJobStatus(
projectId: projectId.value,
statusId: statusId,
);
if (statuses != null && statuses.isNotEmpty) {
jobStatusList.value = statuses;
// Keep previously selected if exists, else pick first
selectedJobStatus.value = statuses.firstWhere(
(status) => status.id == selectedJobStatus.value?.id,
orElse: () => statuses.first,
);
print("Job Status List: ${jobStatusList.map((e) => e.name).toList()}");
} else {
jobStatusErrorMessage.value = "No job statuses found";
}
} catch (e) {
jobStatusErrorMessage.value = "Error fetching job status: $e";
} finally {
isJobStatusLoading.value = false;
}
}
Future<void> fetchProjectDetail() async { Future<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) { if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID"; errorMessage.value = "Invalid project ID";
@ -168,14 +116,12 @@ class ServiceProjectDetailsController extends GetxController {
errorMessage.value = ''; errorMessage.value = '';
try { try {
final result = final result = await ApiService.getServiceProjectDetailApi(projectId.value);
await ApiService.getServiceProjectDetailApi(projectId.value);
if (result != null && result.data != null) { if (result != null && result.data != null) {
projectDetail.value = result.data!; projectDetail.value = result.data!;
} else { } else {
errorMessage.value = errorMessage.value = result?.message ?? "Failed to fetch project details";
result?.message ?? "Failed to fetch project details";
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error: $e"; errorMessage.value = "Error: $e";
@ -194,8 +140,7 @@ class ServiceProjectDetailsController extends GetxController {
attendanceMessage.value = ''; attendanceMessage.value = '';
try { try {
final result = final result = await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
if (result != null) { if (result != null) {
attendanceLog.value = result; attendanceLog.value = result;
@ -258,10 +203,7 @@ class ServiceProjectDetailsController extends GetxController {
pageNumber = 1; pageNumber = 1;
hasMoreJobs.value = true; hasMoreJobs.value = true;
await Future.wait([ await Future.wait([fetchProjectDetail(), fetchProjectJobs()]);
fetchProjectDetail(),
fetchProjectJobs(),
]);
} }
// -------------------- Job Detail -------------------- // -------------------- Job Detail --------------------
@ -306,104 +248,17 @@ class ServiceProjectDetailsController extends GetxController {
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
attendanceMessage.value = attendanceMessage.value = "Location permission permanently denied. Enable it from settings.";
"Location permission permanently denied. Enable it from settings.";
return null; return null;
} }
return await Geolocator.getCurrentPosition( return await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
desiredAccuracy: LocationAccuracy.high);
} catch (e) { } catch (e) {
attendanceMessage.value = "Failed to get location: $e"; attendanceMessage.value = "Failed to get location: $e";
return null; return null;
} }
} }
Future<void> fetchJobComments({bool refresh = false}) async {
if (jobDetail.value?.data?.id == null) {
commentsErrorMessage.value = "Invalid job ID";
return;
}
if (refresh) pageNumber = 1;
isCommentsLoading.value = true;
commentsErrorMessage.value = '';
try {
final response = await ApiService.getJobCommentList(
jobTicketId: jobDetail.value!.data!.id!,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.data != null) {
final newComments = response.data?.data ?? [];
if (refresh || pageNumber == 1) {
jobComments.value = newComments;
} else {
jobComments.addAll(newComments);
}
hasMoreJobs.value =
(response.data?.totalEntities ?? 0) > (pageNumber * pageSize);
if (hasMoreJobs.value) pageNumber++;
} else {
commentsErrorMessage.value =
response?.message ?? "Failed to fetch comments";
}
} catch (e) {
commentsErrorMessage.value = "Error fetching comments: $e";
} finally {
isCommentsLoading.value = false;
}
}
Future<bool> addJobComment({
required String jobId,
required String comment,
List<File>? files,
}) async {
try {
List<Map<String, dynamic>> attachments = [];
if (files != null && files.isNotEmpty) {
for (final file in files) {
final bytes = await file.readAsBytes();
final base64Data = base64Encode(bytes);
final mimeType =
lookupMimeType(file.path) ?? "application/octet-stream";
attachments.add({
"fileName": file.path.split('/').last,
"base64Data": base64Data,
"contentType": mimeType,
"fileSize": bytes.length,
"description": "",
"isActive": true,
});
}
}
final success = await ApiService.addJobComment(
jobTicketId: jobId,
comment: comment,
attachments: attachments,
);
if (success) {
await fetchJobDetail(jobId);
refresh();
}
return success;
} catch (e) {
print("Error adding comment: $e");
return false;
}
}
/// Tag In / Tag Out for a job with proper payload /// Tag In / Tag Out for a job with proper payload
Future<void> updateJobAttendance({ Future<void> updateJobAttendance({
required String jobId, required String jobId,
@ -428,8 +283,7 @@ class ServiceProjectDetailsController extends GetxController {
if (attachment != null) { if (attachment != null) {
final bytes = await attachment.readAsBytes(); final bytes = await attachment.readAsBytes();
final base64Data = base64Encode(bytes); final base64Data = base64Encode(bytes);
final mimeType = final mimeType = lookupMimeType(attachment.path) ?? 'application/octet-stream';
lookupMimeType(attachment.path) ?? 'application/octet-stream';
attachmentPayload = { attachmentPayload = {
"documentId": jobId, "documentId": jobId,
"fileName": attachment.path.split('/').last, "fileName": attachment.path.split('/').last,
@ -450,13 +304,10 @@ class ServiceProjectDetailsController extends GetxController {
"attachment": attachmentPayload, "attachment": attachmentPayload,
}; };
final success = await ApiService.updateServiceProjectJobAttendance( final success = await ApiService.updateServiceProjectJobAttendance(payload: payload);
payload: payload,
);
if (success) { if (success) {
attendanceMessage.value = attendanceMessage.value = action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
await fetchJobDetail(jobId); await fetchJobDetail(jobId);
} else { } else {
attendanceMessage.value = "Failed to update attendance"; attendanceMessage.value = "Failed to update attendance";

View File

@ -3,7 +3,7 @@ class ApiEndpoints {
// 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";
@ -36,10 +36,6 @@ class ApiEndpoints {
"/Dashboard/expense/monthly"; "/Dashboard/expense/monthly";
static const String getExpenseTypeReport = "/Dashboard/expense/type"; static const String getExpenseTypeReport = "/Dashboard/expense/type";
static const String getPendingExpenses = "/Dashboard/expense/pendings"; static const String getPendingExpenses = "/Dashboard/expense/pendings";
static const String getCollectionOverview = "/dashboard/collection-overview";
static const String getPurchaseInvoiceOverview =
"/dashboard/purchase-invoice-overview";
///// Projects Module API Endpoints ///// Projects Module API Endpoints
static const String createProject = "/project"; static const String createProject = "/project";
@ -48,7 +44,6 @@ 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";
@ -157,14 +152,4 @@ class ApiEndpoints {
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/list"; static const String getTeamRoles = "/master/team-roles/list";
static const String getServiceProjectBranches = "/serviceproject/branch/list"; static const String getServiceProjectBranches = "/serviceproject/branch/list";
static const String getMasterJobStatus = "/Master/job-status/list";
static const String addJobComment = "/ServiceProject/job/add/comment";
static const String getJobCommentList = "/ServiceProject/job/comment/list";
// Infra Project Module API Endpoints
static const String getInfraProjectsList = "/project/list";
static const String getInfraProjectDetail = "/project/details";
} }

View File

@ -40,14 +40,6 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m
import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart'; import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart';
import 'package:on_field_work/model/service_project/job_allocation_model.dart'; 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_comments.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -319,274 +311,6 @@ class ApiService {
} }
} }
/// ============================================
/// GET PURCHASE INVOICE OVERVIEW (Dashboard)
/// ============================================
static Future<PurchaseInvoiceOverviewResponse?> getPurchaseInvoiceOverview({
String? projectId,
}) async {
try {
final queryParams = <String, String>{};
if (projectId != null && projectId.isNotEmpty) {
queryParams['projectId'] = projectId;
}
final response = await _getRequest(
ApiEndpoints.getPurchaseInvoiceOverview,
queryParams: queryParams,
);
if (response == null) {
_log("getPurchaseInvoiceOverview: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson = _parseResponseForAllData(
response,
label: "PurchaseInvoiceOverview",
);
if (parsedJson == null) return null;
return PurchaseInvoiceOverviewResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getPurchaseInvoiceOverview: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// ============================================
/// GET COLLECTION OVERVIEW (Dashboard)
/// ============================================
static Future<CollectionOverviewResponse?> getCollectionOverview({
String? projectId,
}) async {
try {
// Build query params (only add projectId if not null)
final queryParams = <String, String>{};
if (projectId != null && projectId.isNotEmpty) {
queryParams['projectId'] = projectId;
}
final response = await _getRequest(
ApiEndpoints.getCollectionOverview,
queryParams: queryParams,
);
if (response == null) {
_log("getCollectionOverview: No response from server",
level: LogLevel.error);
return null;
}
// Parse full JSON (success, message, data, etc.)
final parsedJson =
_parseResponseForAllData(response, label: "CollectionOverview");
if (parsedJson == null) return null;
return CollectionOverviewResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getCollectionOverview: $e\n$stack",
level: LogLevel.error);
return null;
}
}
// Infra Project Module APIs
/// ================================
/// GET INFRA PROJECT DETAILS
/// ================================
static Future<ProjectDetailsResponse?> getInfraProjectDetails({
required String projectId,
}) async {
final endpoint = "${ApiEndpoints.getInfraProjectDetail}/$projectId";
try {
final response = await _getRequest(endpoint);
if (response == null) {
_log("getInfraProjectDetails: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "InfraProjectDetails");
if (parsedJson == null) return null;
return ProjectDetailsResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getInfraProjectDetails: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// ================================
/// GET INFRA PROJECTS LIST
/// ================================
static Future<ProjectsResponse?> getInfraProjectsList({
int pageSize = 20,
int pageNumber = 1,
String searchString = "",
}) async {
final queryParams = {
"pageSize": pageSize.toString(),
"pageNumber": pageNumber.toString(),
"searchString": searchString,
};
try {
final response = await _getRequest(
ApiEndpoints.getInfraProjectsList,
queryParams: queryParams,
);
if (response == null) {
_log("getInfraProjectsList: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "InfraProjectsList");
if (parsedJson == null) return null;
return ProjectsResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getInfraProjectsList: $e\n$stack",
level: LogLevel.error);
return null;
}
}
static Future<JobCommentResponse?> getJobCommentList({
required String jobTicketId,
int pageNumber = 1,
int pageSize = 20,
}) async {
final queryParams = {
'jobTicketId': jobTicketId,
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
};
try {
final response = await _getRequest(
ApiEndpoints.getJobCommentList,
queryParams: queryParams,
);
if (response == null) {
_log("getJobCommentList: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "JobCommentList");
if (parsedJson == null) return null;
return JobCommentResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getJobCommentList: $e\n$stack", level: LogLevel.error);
return null;
}
}
static Future<bool> addJobComment({
required String jobTicketId,
required String comment,
List<Map<String, dynamic>> attachments = const [],
}) async {
final body = {
"jobTicketId": jobTicketId,
"comment": comment,
"attachments": attachments,
};
try {
final response = await _postRequest(
ApiEndpoints.addJobComment,
body,
);
if (response == null) {
_log("addJobComment: No response from server", level: LogLevel.error);
return false;
}
// Handle 201 Created as success manually
if (response.statusCode == 201) {
_log("AddJobComment: Comment added successfully (201).",
level: LogLevel.info);
return true;
}
// Otherwise fallback to existing _parseResponse
final parsed = _parseResponse(response, label: "AddJobComment");
if (parsed != null && parsed['success'] == true) {
_log("AddJobComment: Comment added successfully.",
level: LogLevel.info);
return true;
} else {
_log(
"AddJobComment failed: ${parsed?['message'] ?? 'Unknown error'}",
level: LogLevel.error,
);
return false;
}
} catch (e, stack) {
_log("Exception in addJobComment: $e\n$stack", level: LogLevel.error);
return false;
}
}
static Future<List<JobStatus>?> getMasterJobStatus({
required String statusId,
required String projectId,
}) async {
final queryParams = {
'statusId': statusId,
'projectId': projectId,
};
try {
final response = await _getRequest(
ApiEndpoints.getMasterJobStatus,
queryParams: queryParams,
);
if (response == null) {
_log("getMasterJobStatus: No response received.");
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "MasterJobStatus");
if (parsedJson == null) return null;
// Directly parse JobStatus list
final dataList = (parsedJson['data'] as List<dynamic>?)
?.map((e) => JobStatus.fromJson(e))
.toList();
return dataList;
} catch (e, stack) {
_log("Exception in getMasterJobStatus: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// Fetch Service Project Branches with full response /// Fetch Service Project Branches with full response
static Future<ServiceProjectBranchesResponse?> getServiceProjectBranchesFull({ static Future<ServiceProjectBranchesResponse?> getServiceProjectBranchesFull({
required String projectId, required String projectId,
@ -3473,30 +3197,6 @@ 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

@ -63,9 +63,6 @@ class ThemeController extends GetxController {
await Future.delayed(const Duration(milliseconds: 600)); await Future.delayed(const Duration(milliseconds: 600));
showApplied.value = false; showApplied.value = false;
// Navigate to dashboard after applying theme
Get.offAllNamed('/dashboard');
} }
} }

View File

@ -163,9 +163,6 @@ 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 = "5fab4b88-c9a0-417b-aca2-130980fdb0cf";
} }
/// Contains all job status IDs used across the application. /// Contains all job status IDs used across the application.

View File

@ -3,206 +3,84 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.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/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class CustomAppBar extends StatefulWidget class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
with UIMixin
implements PreferredSizeWidget {
final String title; final String title;
final String? projectName; // If passed, show static text final String? projectName;
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
final Color? backgroundColor;
CustomAppBar({ const CustomAppBar({
super.key, super.key,
required this.title, required this.title,
this.projectName, this.projectName,
this.onBackPressed, this.onBackPressed,
this.backgroundColor,
}); });
@override
Size get preferredSize => const Size.fromHeight(72);
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
final ProjectController projectController = Get.find();
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
void _toggleDropdown() {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
OverlayEntry _createOverlayEntry() {
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => GestureDetector(
onTap: () {
_toggleDropdown();
},
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
Positioned(
left: offset.dx + 16,
top: offset.dy + size.height,
width: size.width - 32,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(5),
child: Container(
height: MediaQuery.of(context).size.height * 0.33,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: "Search project...",
isDense: true,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5)),
),
),
Expanded(
child: ListView.builder(
itemCount: projectController.projects.length,
itemBuilder: (_, index) {
final project = projectController.projects[index];
return RadioListTile<String>(
dense: true,
value: project.id,
groupValue:
projectController.selectedProjectId.value,
onChanged: (v) {
if (v != null) {
projectController.updateSelectedProject(v);
_toggleDropdown();
}
},
title: Text(project.name),
);
},
),
),
],
),
),
),
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color effectiveBackgroundColor = return PreferredSize(
widget.backgroundColor ?? contentTheme.primary; preferredSize: const Size.fromHeight(72),
const Color onPrimaryColor = Colors.white; child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
final bool showDropdown = widget.projectName == null; elevation: 0.5,
return AppBar(
backgroundColor: effectiveBackgroundColor,
elevation: 0,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
titleSpacing: 0, titleSpacing: 0,
shadowColor: Colors.transparent, title: Padding(
leading: Padding( padding: MySpacing.xy(16, 0),
padding: MySpacing.only(left: 16), child: Row(
child: IconButton( crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon( icon: const Icon(
Icons.arrow_back_ios_new, Icons.arrow_back_ios_new,
color: onPrimaryColor, color: Colors.black,
size: 20, size: 20,
), ),
onPressed: widget.onBackPressed ?? () => Get.back(), onPressed: onBackPressed ?? () => Get.back(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
), ),
), MySpacing.width(5),
title: Padding(
padding: MySpacing.only(right: 16, left: 8),
child: Row(
children: [
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
// TITLE
MyText.titleLarge( MyText.titleLarge(
widget.title, title,
fontWeight: 800, fontWeight: 700,
color: onPrimaryColor, color: Colors.black,
overflow: TextOverflow.ellipsis,
maxLines: 1,
), ),
MySpacing.height(3),
showDropdown MySpacing.height(2),
? CompositedTransformTarget(
link: _layerLink, // PROJECT NAME ROW
child: GestureDetector( GetBuilder<ProjectController>(
onTap: _toggleDropdown, builder: (projectController) {
child: Row( // NEW LOGIC simple and safe
children: [ final displayProjectName =
const Icon(Icons.folder_open, projectName ??
size: 14, color: onPrimaryColor), projectController.selectedProject?.name ??
MySpacing.width(4),
Flexible(
child: Obx(() {
final projectName = projectController
.selectedProject?.name ??
'Select Project'; 'Select Project';
return MyText.bodySmall(
projectName, return Row(
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}),
),
MySpacing.width(2),
const Icon(Icons.keyboard_arrow_down,
size: 18, color: onPrimaryColor),
],
),
),
)
: Row(
children: [ children: [
const Icon(Icons.folder_open, const Icon(
size: 14, color: onPrimaryColor), Icons.work_outline,
size: 14,
color: Colors.grey,
),
MySpacing.width(4), MySpacing.width(4),
Flexible( Expanded(
child: MyText.bodySmall( child: MyText.bodySmall(
widget.projectName!, displayProjectName,
fontWeight: 500, fontWeight: 600,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, color: Colors.grey[700],
), ),
), ),
], ],
);
},
), ),
], ],
), ),
@ -210,21 +88,10 @@ class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
], ],
), ),
), ),
actions: [
Padding(
padding: MySpacing.only(right: 16),
child: IconButton(
icon: const Icon(Icons.home, color: onPrimaryColor, size: 24),
onPressed: () => Get.offAllNamed('/dashboard'),
), ),
),
],
); );
} }
@override @override
void dispose() { Size get preferredSize => const Size.fromHeight(72);
_overlayEntry?.remove();
super.dispose();
}
} }

View File

@ -1,426 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class CollectionsHealthWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final DashboardController controller = Get.find<DashboardController>();
return Obx(() {
final data = controller.collectionOverviewData.value;
final isLoading = controller.isCollectionOverviewLoading.value;
// Loading state
if (isLoading) {
return Container(
decoration: _boxDecoration(), // Maintain the outer box decoration
padding: const EdgeInsets.all(16.0),
child: SkeletonLoaders.collectionHealthSkeleton(),
);
}
// No data
if (data == null) {
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Center(
child: MyText.bodyMedium('No collection overview data available.'),
),
);
}
// Data available
final double totalDue = data.totalDueAmount;
final double totalCollected = data.totalCollectedAmount;
final double pendingPercentage = data.pendingPercentage / 100.0;
final double dsoDays = controller.calculatedDSO;
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildHeader(),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 5,
child: _buildLeftChartSection(
totalDue: totalDue,
pendingPercentage: pendingPercentage,
totalCollected: totalCollected,
),
),
const SizedBox(width: 16),
Expanded(
flex: 4,
child: _buildRightMetricsSection(
data: data,
dsoDays: dsoDays,
),
),
],
),
const SizedBox(height: 20),
_buildAgingAnalysis(data: data),
],
),
);
});
}
// ==============================
// HEADER
// ==============================
Widget _buildHeader() {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Collections Health Overview', fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('View your collection health data.',
color: Colors.grey),
],
),
),
],
);
}
// ==============================
// LEFT SECTION (GAUGE + SUMMARY)
// ==============================
Widget _buildLeftChartSection({
required double totalDue,
required double pendingPercentage,
required double totalCollected,
}) {
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
String collectedPercentStr =
((1 - pendingPercentage) * 100).toStringAsFixed(0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: [
_GaugeChartPlaceholder(
backgroundColor: Colors.white,
pendingPercentage: pendingPercentage,
),
const SizedBox(width: 12),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodyLarge(
'${totalDue.toStringAsFixed(0)} DUE',
fontWeight: 700,
),
const SizedBox(height: 4),
MyText.bodySmall(
'• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)',
color: Colors.black54,
),
MyText.bodySmall(
'${totalCollected.toStringAsFixed(0)} Collected',
color: Colors.black54,
),
],
),
),
],
),
],
);
}
// ==============================
// RIGHT METRICS SECTION
// ==============================
Widget _buildRightMetricsSection({
required CollectionOverviewData data,
required double dsoDays,
}) {
final String topClientName = data.topClient?.name ?? 'N/A';
final double topClientBalance = data.topClientBalance;
return Column(
children: <Widget>[
_buildMetricCard(
title: 'Top Client Balance',
value: topClientName,
subValue: '${topClientBalance.toStringAsFixed(0)}',
valueColor: Colors.red,
isDetailed: true,
),
const SizedBox(height: 10),
_buildMetricCard(
title: 'Total Collected (YTD)',
value: '${data.totalCollectedAmount.toStringAsFixed(0)}',
subValue: 'Collected',
valueColor: Colors.green,
isDetailed: false,
),
],
);
}
Widget _buildMetricCard({
required String title,
required String value,
required String subValue,
required Color valueColor,
required bool isDetailed,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodySmall(title, color: Colors.black54),
const SizedBox(height: 2),
if (isDetailed) ...[
MyText.bodySmall(value, fontWeight: 600),
MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700),
] else
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
MyText.bodySmall(value, fontWeight: 600),
MyText.bodySmall(subValue, color: valueColor, fontWeight: 600),
],
),
],
),
);
}
// ==============================
// AGING ANALYSIS
// ==============================
Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
final buckets = [
AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green,
data.bucket0To30Invoices),
AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange,
data.bucket30To60Invoices),
AgingBucketData('60-90 Days', data.bucket60To90Amount,
Colors.red.shade300, data.bucket60To90Invoices),
AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red,
data.bucket90PlusInvoices),
];
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Outstanding Collections Aging Analysis',
fontWeight: 700),
MyText.bodySmall(
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
color: Colors.black54),
const SizedBox(height: 10),
_AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding),
const SizedBox(height: 15),
Wrap(
spacing: 12,
runSpacing: 8,
children: buckets
.map((bucket) => _buildAgingLegendItem(bucket.title,
bucket.amount, bucket.color, bucket.invoiceCount))
.toList(),
),
],
);
}
Widget _buildAgingLegendItem(
String title, double amount, Color color, int count) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
MyText.bodySmall(
'$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'),
],
);
}
BoxDecoration _boxDecoration() {
return BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
);
}
}
// =====================================================================
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
// =====================================================================
// Gauge Chart
class _GaugeChartPlaceholder extends StatelessWidget {
final Color backgroundColor;
final double pendingPercentage;
const _GaugeChartPlaceholder({
required this.backgroundColor,
required this.pendingPercentage,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 120,
height: 80,
child: Stack(
children: [
CustomPaint(
size: const Size(120, 70),
painter: _SemiCirclePainter(
canvasColor: backgroundColor,
pendingPercentage: pendingPercentage,
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FittedBox(
child: MyText.bodySmall('RISK LEVEL', fontWeight: 600),
),
),
),
],
),
);
}
}
class _SemiCirclePainter extends CustomPainter {
final Color canvasColor;
final double pendingPercentage;
_SemiCirclePainter(
{required this.canvasColor, required this.pendingPercentage});
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromCircle(
center: Offset(size.width / 2, size.height),
radius: size.width / 2,
);
const double arc = 3.14159;
final double pendingSweep = arc * pendingPercentage;
final double collectedSweep = arc * (1 - pendingPercentage);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.1)
..strokeWidth = 10
..style = PaintingStyle.stroke;
canvas.drawArc(rect, arc, arc, false, backgroundPaint);
final pendingPaint = Paint()
..strokeWidth = 10
..style = PaintingStyle.stroke
..shader = const LinearGradient(
colors: [Colors.orange, Colors.red],
).createShader(rect);
canvas.drawArc(rect, arc, pendingSweep, false, pendingPaint);
final collectedPaint = Paint()
..color = Colors.green
..strokeWidth = 10
..style = PaintingStyle.stroke;
canvas.drawArc(
rect, arc + pendingSweep, collectedSweep, false, collectedPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// AGING BUCKET
class AgingBucketData {
final String title;
final double amount;
final Color color;
final int invoiceCount; // ADDED
// UPDATED CONSTRUCTOR
AgingBucketData(this.title, this.amount, this.color, this.invoiceCount);
}
class _AgingStackedBar extends StatelessWidget {
final List<AgingBucketData> buckets;
final double totalOutstanding;
const _AgingStackedBar({
required this.buckets,
required this.totalOutstanding,
});
@override
Widget build(BuildContext context) {
if (totalOutstanding == 0) {
return Container(
height: 16,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: MyText.bodySmall('No Outstanding Collections',
color: Colors.black54),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
children: buckets.where((b) => b.amount > 0).map((bucket) {
final flexValue = bucket.amount / totalOutstanding;
return Expanded(
flex: (flexValue * 1000).toInt(),
child: Container(height: 16, color: bucket.color),
);
}).toList(),
),
);
}
}

View File

@ -1,717 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class CompactPurchaseInvoiceDashboard extends StatelessWidget {
const CompactPurchaseInvoiceDashboard({super.key});
@override
Widget build(BuildContext context) {
final DashboardController controller = Get.find();
// Use Obx to reactively listen to data changes
return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value;
// Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
}
// Show empty state if no data
if (data == null || data.totalInvoices == 0) {
return Center(
child: MyText.bodySmall('No purchase invoices found.'),
);
}
// Convert API response to internal PurchaseInvoiceData list
final invoices = (data.projectBreakdown ?? [])
.map((project) => PurchaseInvoiceData(
id: project.id ?? '',
title: project.name ?? 'Unknown',
proformaInvoiceAmount: project.totalValue ?? 0.0,
supplierName: data.topSupplier?.name ?? 'N/A',
projectName: project.name ?? 'Unknown',
statusName: 'Unknown', // API might have status if needed
))
.toList();
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics);
});
}
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
const double spacing = 16.0;
const double smallSpacing = 8.0;
return Container(
padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const _DashboardHeader(),
const SizedBox(height: spacing),
_TotalValueCard(
totalProformaAmount: metrics.totalProformaAmount,
totalCount: metrics.totalCount,
),
const SizedBox(height: spacing),
_CondensedMetricsRow(
draftCount: metrics.draftCount,
avgInvoiceValue: metrics.avgInvoiceValue,
topSupplierName: metrics.topSupplierName,
spacing: smallSpacing,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
const _SectionTitle('Status Breakdown by Value'),
const SizedBox(height: smallSpacing),
_StatusDonutChart(
statusBuckets: metrics.statusBuckets,
totalAmount: metrics.totalProformaAmount,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
const _SectionTitle('Top Projects by Proforma Value'),
const SizedBox(height: smallSpacing),
_ProjectBreakdown(
projects: metrics.projectBuckets.take(3).toList(),
totalAmount: metrics.totalProformaAmount,
spacing: smallSpacing,
),
],
),
);
}
}
/// Container object used internally
class PurchaseInvoiceDashboardData {
final List<PurchaseInvoiceData> invoices;
final PurchaseInvoiceMetrics metrics;
const PurchaseInvoiceDashboardData({
required this.invoices,
required this.metrics,
});
}
/// =======================
/// DATA MODELS
/// =======================
class PurchaseInvoiceData {
final String id;
final String title;
final double proformaInvoiceAmount;
final String supplierName;
final String projectName;
final String statusName;
const PurchaseInvoiceData({
required this.id,
required this.title,
required this.proformaInvoiceAmount,
required this.supplierName,
required this.projectName,
required this.statusName,
});
factory PurchaseInvoiceData.fromJson(Map<String, dynamic> json) {
final supplier = json['supplier'] as Map<String, dynamic>? ?? const {};
final project = json['project'] as Map<String, dynamic>? ?? const {};
final status = json['status'] as Map<String, dynamic>? ?? const {};
return PurchaseInvoiceData(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
proformaInvoiceAmount:
(json['proformaInvoiceAmount'] as num?)?.toDouble() ?? 0.0,
supplierName: supplier['name']?.toString() ?? 'Unknown Supplier',
projectName: project['name']?.toString() ?? 'Unknown Project',
statusName: status['displayName']?.toString() ?? 'Unknown',
);
}
}
class StatusBucketData {
final String title;
final double amount;
final Color color;
final int count;
const StatusBucketData({
required this.title,
required this.amount,
required this.color,
required this.count,
});
}
class ProjectMetricData {
final String name;
final double amount;
const ProjectMetricData({
required this.name,
required this.amount,
});
}
class PurchaseInvoiceMetrics {
final double totalProformaAmount;
final int totalCount;
final int draftCount;
final String topSupplierName;
final double topSupplierAmount;
final List<StatusBucketData> statusBuckets;
final List<ProjectMetricData> projectBuckets;
final double avgInvoiceValue;
const PurchaseInvoiceMetrics({
required this.totalProformaAmount,
required this.totalCount,
required this.draftCount,
required this.topSupplierName,
required this.topSupplierAmount,
required this.statusBuckets,
required this.projectBuckets,
required this.avgInvoiceValue,
});
}
/// =======================
/// METRICS CALCULATOR
/// =======================
class PurchaseInvoiceMetricsCalculator {
PurchaseInvoiceMetrics calculate(List<PurchaseInvoiceData> invoices) {
final double totalProformaAmount =
invoices.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
final int totalCount = invoices.length;
final int draftCount =
invoices.where((item) => item.statusName == 'Draft').length;
final Map<String, double> supplierTotals = <String, double>{};
for (final invoice in invoices) {
supplierTotals.update(
invoice.supplierName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final MapEntry<String, double>? topSupplierEntry = supplierTotals
.entries.isEmpty
? null
: supplierTotals.entries.reduce((a, b) => a.value > b.value ? a : b);
final String topSupplierName = topSupplierEntry?.key ?? 'N/A';
final double topSupplierAmount = topSupplierEntry?.value ?? 0.0;
final Map<String, double> projectTotals = <String, double>{};
for (final invoice in invoices) {
projectTotals.update(
invoice.projectName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final List<ProjectMetricData> projectBuckets = projectTotals.entries
.map((e) => ProjectMetricData(name: e.key, amount: e.value))
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
final Map<String, List<PurchaseInvoiceData>> statusGroups =
<String, List<PurchaseInvoiceData>>{};
for (final invoice in invoices) {
statusGroups.putIfAbsent(
invoice.statusName,
() => <PurchaseInvoiceData>[],
);
statusGroups[invoice.statusName]!.add(invoice);
}
final List<StatusBucketData> statusBuckets = statusGroups.entries.map(
(entry) {
final double statusTotal = entry.value
.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
return StatusBucketData(
title: entry.key,
amount: statusTotal,
color: getColorForStatus(entry.key),
count: entry.value.length,
);
},
).toList();
final double avgInvoiceValue =
totalCount > 0 ? totalProformaAmount / totalCount : 0.0;
return PurchaseInvoiceMetrics(
totalProformaAmount: totalProformaAmount,
totalCount: totalCount,
draftCount: draftCount,
topSupplierName: topSupplierName,
topSupplierAmount: topSupplierAmount,
statusBuckets: statusBuckets,
projectBuckets: projectBuckets,
avgInvoiceValue: avgInvoiceValue,
);
}
}
/// =======================
/// UTILITIES
/// =======================
Color _getProjectColor(String name) {
final int hash = name.hashCode;
const List<Color> colors = <Color>[
Color(0xFF42A5F5), // Blue
Color(0xFF66BB6A), // Green
Color(0xFFFFA726), // Orange
Color(0xFFEC407A), // Pink
Color(0xFF7E57C2), // Deep Purple
Color(0xFF26C6DA), // Cyan
Color(0xFFFFEE58), // Yellow
];
return colors[hash.abs() % colors.length];
}
Color getColorForStatus(String status) {
switch (status) {
case 'Draft':
return Colors.blueGrey;
case 'Pending Approval':
return Colors.orange;
case 'Approved':
return Colors.green;
case 'Paid':
return Colors.blue;
default:
return Colors.grey;
}
}
/// =======================
/// REDESIGNED INTERNAL UI WIDGETS
/// =======================
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return MyText.bodySmall(
title,
color: Colors.grey.shade700,
fontWeight: 700,
letterSpacing: 0.5,
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader();
@override
Widget build(BuildContext context) {
return Row(mainAxisAlignment: MainAxisAlignment.start, children: [
Expanded(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
MyText.bodyMedium(
'Purchase Invoice ',
fontWeight: 700,
),
SizedBox(height: 2),
MyText.bodySmall(
'View your purchase invoice data.',
color: Colors.grey,
),
]))
]);
}
}
// Total Value Card - Refined Style
class _TotalValueCard extends StatelessWidget {
final double totalProformaAmount;
final int totalCount;
const _TotalValueCard({
required this.totalProformaAmount,
required this.totalCount,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD), // Lighter Blue
borderRadius: BorderRadius.circular(5),
border: Border.all(color: const Color(0xFFBBDEFB), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodySmall(
'TOTAL PROFORMA VALUE (₹)',
color: Colors.blue.shade800,
fontWeight: 700,
letterSpacing: 1.0,
),
Icon(
Icons.account_balance_wallet_outlined,
color: Colors.blue.shade700,
size: 20,
),
],
),
MySpacing.height(8),
MyText.bodyMedium(
totalProformaAmount.toStringAsFixed(0),
),
MySpacing.height(4),
MyText.bodySmall(
'Over $totalCount Total Invoices',
color: Colors.blueGrey.shade600,
fontWeight: 500,
),
],
),
);
}
}
// Condensed Metrics Row - Replaces the GridView
class _CondensedMetricsRow extends StatelessWidget {
final int draftCount;
final double avgInvoiceValue;
final String topSupplierName;
final double spacing;
const _CondensedMetricsRow({
required this.draftCount,
required this.avgInvoiceValue,
required this.topSupplierName,
required this.spacing,
});
@override
Widget build(BuildContext context) {
// Only showing 3 key metrics in a row for a tighter feel
return Row(
children: [
Expanded(
child: _CondensedMetricCard(
title: 'Drafts',
value: draftCount.toString(),
caption: 'To Complete',
color: Colors.orange.shade700,
icon: Icons.edit_note_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Avg. Value',
value: '${avgInvoiceValue.toStringAsFixed(0)}',
caption: 'Per Invoice',
color: Colors.purple.shade700,
icon: Icons.calculate_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Top Supplier',
value: topSupplierName,
caption: 'By Value',
color: Colors.green.shade700,
icon: Icons.business_center_outlined,
),
),
],
);
}
}
// Condensed Metric Card - Small, impactful display
class _CondensedMetricCard extends StatelessWidget {
final String title;
final String value;
final String caption;
final Color color;
final IconData icon;
const _CondensedMetricCard({
required this.title,
required this.value,
required this.caption,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withOpacity(0.15), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Expanded(
child: MyText.bodySmall(
title,
overflow: TextOverflow.ellipsis,
color: color,
fontWeight: 700,
),
),
],
),
MySpacing.height(6),
MyText.bodyMedium(
value,
overflow: TextOverflow.ellipsis,
fontWeight: 800,
),
MyText.bodySmall(
caption,
color: Colors.grey.shade500,
fontWeight: 500,
),
],
),
);
}
}
// Status Breakdown (Donut Chart + Legend) - Stronger Visualization
class _StatusDonutChart extends StatelessWidget {
final List<StatusBucketData> statusBuckets;
final double totalAmount;
const _StatusDonutChart({
required this.statusBuckets,
required this.totalAmount,
});
@override
Widget build(BuildContext context) {
final List<StatusBucketData> activeBuckets = statusBuckets
.where((b) => b.amount > 0)
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
if (activeBuckets.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: MyText.bodySmall(
'No active invoices to display status breakdown.',
color: Colors.grey.shade500,
),
);
}
// Determine the percentage of the largest bucket for the center text
final double mainPercentage =
totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Simulated Donut Chart (Center Focus)
Container(
width: 120,
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: activeBuckets.first.color.withOpacity(0.5), width: 6),
color: activeBuckets.first.color.withOpacity(0.05),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.bodySmall(
activeBuckets.first.title,
color: activeBuckets.first.color,
fontWeight: 700,
),
MyText.bodyMedium(
'${(mainPercentage * 100).toStringAsFixed(0)}%',
),
],
),
),
const SizedBox(width: 16),
// Legend/Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: activeBuckets.map((bucket) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: bucket.color,
shape: BoxShape.circle,
),
),
Expanded(
child: MyText.bodySmall(
'${bucket.title} (${bucket.count})',
color: Colors.grey.shade800,
fontWeight: 500,
),
),
MyText.bodySmall(
'${bucket.amount.toStringAsFixed(0)}',
fontWeight: 700,
color: bucket.color.withOpacity(0.9),
),
],
),
);
}).toList(),
),
),
],
);
}
}
// Project Breakdown - Denser and with clearer value
class _ProjectBreakdown extends StatelessWidget {
final List<ProjectMetricData> projects;
final double totalAmount;
final double spacing;
const _ProjectBreakdown({
required this.projects,
required this.totalAmount,
required this.spacing,
});
@override
Widget build(BuildContext context) {
if (projects.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: MyText.bodySmall(
'No project data available.',
color: Colors.grey.shade500,
),
);
}
return Column(
children: projects.map((project) {
final double percentage =
totalAmount > 0 ? (project.amount / totalAmount) : 0.0;
final Color color = _getProjectColor(project.name);
final String percentageText = (percentage * 100).toStringAsFixed(1);
return Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
project.name,
overflow: TextOverflow.ellipsis,
fontWeight: 600,
),
const SizedBox(height: 2),
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 4, // Smaller bar height
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MyText.bodyMedium(
'${project.amount.toStringAsFixed(0)}',
fontWeight: 700,
color: color.withOpacity(0.9),
),
MyText.bodySmall(
'$percentageText%',
fontWeight: 500,
color: Colors.grey.shade600,
),
],
),
],
),
);
}).toList(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
class PillTabBar extends StatelessWidget {
final TabController controller;
final List<String> tabs;
final Color selectedColor;
final Color unselectedColor;
final Color indicatorColor;
final double height;
final ValueChanged<int>? onTap;
const PillTabBar({
Key? key,
required this.controller,
required this.tabs,
this.selectedColor = Colors.blue,
this.unselectedColor = Colors.grey,
this.indicatorColor = Colors.blueAccent,
this.height = 48,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// Dynamic horizontal padding between tabs
final screenWidth = MediaQuery.of(context).size.width;
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: controller,
indicator: BoxDecoration(
color: indicatorColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(height / 2),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: EdgeInsets.symmetric(
horizontal: tabSpacing / 2,
vertical: 4,
),
labelColor: selectedColor,
unselectedLabelColor: unselectedColor,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
tabs: tabs
.map(
(text) => Tab(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: tabSpacing),
child: Text(
text,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
),
)
.toList(),
onTap: onTap,
),
),
);
}
}

View File

@ -1,510 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:on_field_work/controller/service_project/service_project_details_screen_controller.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/service_project/job_comments.dart';
import 'package:on_field_work/helpers/widgets/image_viewer_dialog.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
class AddCommentWidget extends StatefulWidget {
final String jobId;
final String jobTicketId;
const AddCommentWidget({
super.key,
required this.jobId,
required this.jobTicketId,
});
@override
State<AddCommentWidget> createState() => _AddCommentWidgetState();
}
class _AddCommentWidgetState extends State<AddCommentWidget> {
final TextEditingController _controller = TextEditingController();
final List<File> _selectedFiles = [];
final ServiceProjectDetailsController controller =
Get.find<ServiceProjectDetailsController>();
bool isSubmitting = false;
bool isProcessingAttachment = false;
@override
void initState() {
super.initState();
controller.fetchJobComments(refresh: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// --- PICK MULTIPLE FILES ---
Future<void> _pickFiles() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
setState(() {
_selectedFiles.addAll(
result.paths.whereType<String>().map((path) => File(path)));
});
}
} catch (e) {
Get.snackbar("Error", "Failed to pick files: $e");
}
}
// --- PICK IMAGE FROM CAMERA ---
Future<void> _pickFromCamera() async {
try {
final pickedFile =
await controller.picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
setState(() {
controller.isProcessingAttachment.value =
true; // optional: show loading
});
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
setState(() {
_selectedFiles.add(timestampedFile);
});
}
} catch (e) {
Get.snackbar("Camera error", "$e",
backgroundColor: Colors.red.shade200, colorText: Colors.white);
} finally {
setState(() {
controller.isProcessingAttachment.value = false;
});
}
}
// --- SUBMIT COMMENT ---
Future<void> _submitComment() async {
if (_controller.text.trim().isEmpty && _selectedFiles.isEmpty) return;
setState(() => isSubmitting = true);
final success = await controller.addJobComment(
jobId: widget.jobId,
comment: _controller.text.trim(),
files: _selectedFiles,
);
setState(() => isSubmitting = false);
if (success) {
_controller.clear();
_selectedFiles.clear();
FocusScope.of(context).unfocus();
await controller.fetchJobComments(refresh: true);
}
}
// --- HELPER: CHECK IF FILE IS IMAGE ---
bool _isImage(String? fileName) {
if (fileName == null) return false;
final ext = fileName.split('.').last.toLowerCase();
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].contains(ext);
}
// --- SELECTED FILES PREVIEW ---
// --- SELECTED FILES PREVIEW (styled like expense attachments) ---
Widget _buildSelectedFiles() {
if (_selectedFiles.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _selectedFiles.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final file = _selectedFiles[index];
final fileName = file.path.split('/').last;
final isImage = _isImage(fileName);
return GestureDetector(
onTap: isImage
? () {
// Show image preview
Get.to(() => ImageViewerDialog(
imageSources: _selectedFiles.toList(),
initialIndex: _selectedFiles
.where((f) => _isImage(f.path.split('/').last))
.toList()
.indexOf(file),
captions: _selectedFiles
.where((f) => _isImage(f.path.split('/').last))
.map((f) => f.path.split('/').last)
.toList(),
));
}
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isImage ? Colors.teal.shade50 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isImage ? Colors.teal.shade100 : Colors.grey.shade300,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isImage
? Icons.insert_photo_outlined
: Icons.insert_drive_file_outlined,
size: 16,
color:
isImage ? Colors.teal.shade700 : Colors.grey.shade700,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: Text(
fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isImage
? Colors.teal.shade700
: Colors.grey.shade700,
),
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () => setState(() => _selectedFiles.removeAt(index)),
child: Icon(
Icons.close,
size: 14,
color:
isImage ? Colors.teal.shade700 : Colors.grey.shade700,
),
),
],
),
),
);
},
),
);
}
// --- BUILD SINGLE COMMENT ITEM ---
Widget _buildCommentItem(CommentItem comment) {
final firstName = comment.createdBy?.firstName ?? '';
final lastName = comment.createdBy?.lastName ?? '';
final formattedDate = comment.createdAt != null
? DateTimeUtils.convertUtcToLocal(comment.createdAt!,
format: 'dd MMM yyyy hh:mm a')
: "Just now";
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: firstName, lastName: lastName, size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
"$firstName $lastName".trim().isNotEmpty
? "$firstName $lastName"
: "Unknown User",
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 14),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6.0),
child: Text("",
style: TextStyle(fontSize: 14, color: Colors.grey)),
),
Icon(Icons.access_time, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(formattedDate,
style:
TextStyle(fontSize: 13, color: Colors.grey[600])),
],
),
const SizedBox(height: 4),
if (comment.comment != null && comment.comment!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
comment.comment!,
style:
const TextStyle(fontSize: 14, color: Colors.black87),
),
),
if (comment.attachments != null &&
comment.attachments!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: comment.attachments!.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final attachment = comment.attachments![index];
final isImage = _isImage(attachment.fileName);
final imageAttachments = comment.attachments!
.where((a) => _isImage(a.fileName))
.toList();
final imageIndex =
imageAttachments.indexOf(attachment);
return GestureDetector(
onTap: isImage
? () {
Get.to(() => ImageViewerDialog(
imageSources: imageAttachments
.map((a) => a.preSignedUrl ?? "")
.toList(),
initialIndex: imageIndex,
captions: imageAttachments
.map((a) => a.fileName ?? "")
.toList(),
));
}
: null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isImage
? Colors.teal.shade50
: Colors.purple.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isImage
? Colors.teal.shade100
: Colors.purple.shade100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isImage
? Icons.insert_photo_outlined
: Icons.insert_drive_file_outlined,
size: 16,
color: isImage
? Colors.teal.shade700
: Colors.purple.shade700,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 100),
child: Text(
attachment.fileName ?? "Attachment",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isImage
? Colors.teal.shade700
: Colors.purple.shade700,
),
),
),
],
),
),
);
},
),
),
),
],
),
),
],
),
);
}
// --- COMMENT LIST ---
Widget _buildCommentList() {
return Obx(() {
if (controller.isCommentsLoading.value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Column(
children: [
const CircularProgressIndicator(strokeWidth: 3),
MySpacing.height(12),
MyText.bodyMedium("Loading comments...",
color: Colors.grey.shade600),
],
),
),
);
}
if (controller.jobComments.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Column(
children: [
Icon(Icons.chat_bubble_outline,
size: 40, color: Colors.grey.shade400),
MySpacing.height(8),
MyText.bodyMedium("No comments yet.",
color: Colors.grey.shade600),
MyText.bodySmall("Be the first to post a comment.",
color: Colors.grey.shade500),
],
),
),
);
}
return Column(
children: controller.jobComments.map(_buildCommentItem).toList(),
);
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
TextField(
controller: _controller,
maxLines: 3,
decoration: InputDecoration(
hintText: "Type your comment here...",
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blue, width: 2),
),
),
),
MySpacing.height(10),
_buildSelectedFiles(),
MySpacing.height(10),
Row(
children: [
// Attach file
IconButton(
onPressed: isSubmitting ? null : _pickFiles,
icon: const Icon(Icons.attach_file, size: 24, color: Colors.blue),
tooltip: "Attach File",
),
// Camera (icon-only)
Stack(
alignment: Alignment.center,
children: [
IconButton(
onPressed:
isSubmitting || controller.isProcessingAttachment.value
? null
: _pickFromCamera,
icon: const Icon(Icons.camera_alt,
size: 24, color: Colors.blue),
tooltip: "Camera",
),
if (controller.isProcessingAttachment.value)
const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const Spacer(),
// Submit button
ElevatedButton(
onPressed: isSubmitting ? null : _submitComment,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 2,
),
child: isSubmitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text("Post Comment"),
),
],
),
MySpacing.height(30),
const Divider(height: 1, thickness: 0.5),
MySpacing.height(20),
Obx(() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
"Comments (${controller.jobComments.length})",
fontWeight: 700),
MySpacing.height(16),
_buildCommentList(),
],
)),
],
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:on_field_work/helpers/services/app_initializer.dart';
import 'package:on_field_work/view/my_app.dart'; import 'package:on_field_work/view/my_app.dart';
import 'package:on_field_work/helpers/theme/app_notifier.dart'; import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/view/layouts/offline_screen.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
Future<void> main() async { Future<void> main() async {
@ -54,37 +55,38 @@ Widget _buildErrorApp() => const MaterialApp(
), ),
); );
class MainWrapper extends StatelessWidget { class MainWrapper extends StatefulWidget {
const MainWrapper({super.key}); const MainWrapper({super.key});
@override @override
Widget build(BuildContext context) { State<MainWrapper> createState() => _MainWrapperState();
// 1. Use FutureBuilder to check the current connectivity status ONCE. }
return FutureBuilder<List<ConnectivityResult>>(
future: Connectivity().checkConnectivity(), class _MainWrapperState extends State<MainWrapper> {
builder: (context, initialSnapshot) { List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
// If the initial check is still running, display a standard loading screen. final Connectivity _connectivity = Connectivity();
if (!initialSnapshot.hasData) {
return const MaterialApp( @override
home: Center(child: CircularProgressIndicator()), void initState() {
); super.initState();
_initializeConnectivity();
_connectivity.onConnectivityChanged.listen((results) {
setState(() => _connectivityStatus = results);
});
} }
// 2. Once the initial status is known, use StreamBuilder for real-time updates. Future<void> _initializeConnectivity() async {
return StreamBuilder<List<ConnectivityResult>>( final result = await _connectivity.checkConnectivity();
stream: Connectivity().onConnectivityChanged, setState(() => _connectivityStatus = result);
// 💡 CRITICAL: Use the actual result from the FutureBuilder as the initial data. }
initialData: initialSnapshot.data!,
builder: (context, streamSnapshot) {
final List<ConnectivityResult> results =
streamSnapshot.data ?? [ConnectivityResult.none];
final bool isOffline = results.contains(ConnectivityResult.none);
// Pass the accurate connectivity status down to MyApp. @override
return MyApp(isOffline: isOffline); Widget build(BuildContext context) {
}, final bool isOffline =
); _connectivityStatus.contains(ConnectivityResult.none);
}, return isOffline
); ? const MaterialApp(
debugShowCheckedModeBanner: false, home: OfflineScreen())
: const MyApp();
} }
} }

View File

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

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart'; import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart'; import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/date_range_picker.dart'; import 'package:on_field_work/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
@ -25,6 +27,21 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
class _AttendanceFilterBottomSheetState class _AttendanceFilterBottomSheetState
extends State<AttendanceFilterBottomSheet> { extends State<AttendanceFilterBottomSheet> {
late String tempSelectedTab;
@override
void initState() {
super.initState();
tempSelectedTab = widget.selectedTab;
}
String getLabelText() {
final start = DateTimeUtils.formatDate(
widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
widget.controller.endDateAttendance.value, 'dd MMM yyyy');
return "$start - $end";
}
Widget _popupSelector({ Widget _popupSelector({
required String currentValue, required String currentValue,
@ -34,8 +51,12 @@ class _AttendanceFilterBottomSheetState
return PopupMenuButton<String>( return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (context) => itemBuilder: (context) => items
items.map((e) => PopupMenuItem<String>(value: e, child: MyText(e))).toList(), .map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -86,11 +107,46 @@ class _AttendanceFilterBottomSheetState
); );
} }
List<Widget> _buildFilters() { List<Widget> buildMainFilters() {
final List<Widget> widgets = []; final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
final viewOptions = [
{'label': 'Today\'s Attendance', 'value': 'todaysAttendance'},
{'label': 'Attendance Logs', 'value': 'attendanceLogs'},
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
];
final filteredOptions = viewOptions.where((item) {
return item['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
final List<Widget> widgets = [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("View", fontWeight: 600),
),
),
...filteredOptions.map((item) {
return RadioListTile<String>(
dense: true,
contentPadding: EdgeInsets.zero,
title: MyText.bodyMedium(
item['label']!,
fontWeight: 500,
),
value: item['value']!,
groupValue: tempSelectedTab,
onChanged: (value) => setState(() => tempSelectedTab = value!),
);
}),
];
// Organization selector
widgets.addAll([ widgets.addAll([
const Divider(),
Padding( Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12), padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align( child: Align(
@ -124,8 +180,7 @@ class _AttendanceFilterBottomSheetState
}), }),
]); ]);
// Date range (only for Attendance Logs) if (tempSelectedTab == 'attendanceLogs') {
if (widget.selectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
Padding( Padding(
@ -153,20 +208,24 @@ class _AttendanceFilterBottomSheetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
// FIX: avoids hiding under navigation buttons
child: BaseBottomSheet( child: BaseBottomSheet(
title: "Attendance Filter", title: "Attendance Filter",
submitText: "Apply", submitText: "Apply",
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, { onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id, 'selectedOrganization': widget.controller.selectedOrganization?.id,
}), }),
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 24), padding:
const EdgeInsets.only(bottom: 24), // FIX: extra safe padding
child: SingleChildScrollView( child: SingleChildScrollView(
// FIX: full scrollable in landscape
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: _buildFilters(), children: buildMainFilters(),
), ),
), ),
), ),

View File

@ -1,192 +0,0 @@
import 'dart:convert';
/// ===============================
/// MAIN MODEL: CollectionOverview
/// ===============================
class CollectionOverviewResponse {
final bool success;
final String message;
final CollectionOverviewData data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
CollectionOverviewResponse({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory CollectionOverviewResponse.fromJson(Map<String, dynamic> json) {
return CollectionOverviewResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: CollectionOverviewData.fromJson(json['data'] ?? {}),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
}
/// ===============================
/// DATA BLOCK
/// ===============================
class CollectionOverviewData {
final double totalDueAmount;
final double totalCollectedAmount;
final double totalValue;
final double pendingPercentage;
final double collectedPercentage;
final int bucket0To30Invoices;
final int bucket30To60Invoices;
final int bucket60To90Invoices;
final int bucket90PlusInvoices;
final double bucket0To30Amount;
final double bucket30To60Amount;
final double bucket60To90Amount;
final double bucket90PlusAmount;
final double topClientBalance;
final TopClient? topClient;
CollectionOverviewData({
required this.totalDueAmount,
required this.totalCollectedAmount,
required this.totalValue,
required this.pendingPercentage,
required this.collectedPercentage,
required this.bucket0To30Invoices,
required this.bucket30To60Invoices,
required this.bucket60To90Invoices,
required this.bucket90PlusInvoices,
required this.bucket0To30Amount,
required this.bucket30To60Amount,
required this.bucket60To90Amount,
required this.bucket90PlusAmount,
required this.topClientBalance,
required this.topClient,
});
factory CollectionOverviewData.fromJson(Map<String, dynamic> json) {
return CollectionOverviewData(
totalDueAmount: (json['totalDueAmount'] ?? 0).toDouble(),
totalCollectedAmount: (json['totalCollectedAmount'] ?? 0).toDouble(),
totalValue: (json['totalValue'] ?? 0).toDouble(),
pendingPercentage: (json['pendingPercentage'] ?? 0).toDouble(),
collectedPercentage: (json['collectedPercentage'] ?? 0).toDouble(),
bucket0To30Invoices: json['bucket0To30Invoices'] ?? 0,
bucket30To60Invoices: json['bucket30To60Invoices'] ?? 0,
bucket60To90Invoices: json['bucket60To90Invoices'] ?? 0,
bucket90PlusInvoices: json['bucket90PlusInvoices'] ?? 0,
bucket0To30Amount: (json['bucket0To30Amount'] ?? 0).toDouble(),
bucket30To60Amount: (json['bucket30To60Amount'] ?? 0).toDouble(),
bucket60To90Amount: (json['bucket60To90Amount'] ?? 0).toDouble(),
bucket90PlusAmount: (json['bucket90PlusAmount'] ?? 0).toDouble(),
topClientBalance: (json['topClientBalance'] ?? 0).toDouble(),
topClient: json['topClient'] != null
? TopClient.fromJson(json['topClient'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'totalDueAmount': totalDueAmount,
'totalCollectedAmount': totalCollectedAmount,
'totalValue': totalValue,
'pendingPercentage': pendingPercentage,
'collectedPercentage': collectedPercentage,
'bucket0To30Invoices': bucket0To30Invoices,
'bucket30To60Invoices': bucket30To60Invoices,
'bucket60To90Invoices': bucket60To90Invoices,
'bucket90PlusInvoices': bucket90PlusInvoices,
'bucket0To30Amount': bucket0To30Amount,
'bucket30To60Amount': bucket30To60Amount,
'bucket60To90Amount': bucket60To90Amount,
'bucket90PlusAmount': bucket90PlusAmount,
'topClientBalance': topClientBalance,
'topClient': topClient?.toJson(),
};
}
}
/// ===============================
/// NESTED MODEL: Top Client
/// ===============================
class TopClient {
final String id;
final String name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
TopClient({
required this.id,
required this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory TopClient.fromJson(Map<String, dynamic> json) {
return TopClient(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'],
contactPerson: json['contactPerson'],
address: json['address'],
gstNumber: json['gstNumber'],
contactNumber: json['contactNumber'],
sprid: json['sprid'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}
/// ===============================
/// Optional: Quick decode method
/// ===============================
CollectionOverviewResponse parseCollectionOverview(String jsonString) {
return CollectionOverviewResponse.fromJson(jsonDecode(jsonString));
}

View File

@ -1,221 +0,0 @@
// ============================
// PurchaseInvoiceOverviewModel.dart
// ============================
class PurchaseInvoiceOverviewResponse {
final bool? success;
final String? message;
final PurchaseInvoiceOverviewData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
PurchaseInvoiceOverviewResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory PurchaseInvoiceOverviewResponse.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null
? PurchaseInvoiceOverviewData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp?.toIso8601String(),
};
}
}
class PurchaseInvoiceOverviewData {
final int? totalInvoices;
final double? totalValue;
final double? averageValue;
final List<StatusBreakdown>? statusBreakdown;
final List<ProjectBreakdown>? projectBreakdown;
final TopSupplier? topSupplier;
PurchaseInvoiceOverviewData({
this.totalInvoices,
this.totalValue,
this.averageValue,
this.statusBreakdown,
this.projectBreakdown,
this.topSupplier,
});
factory PurchaseInvoiceOverviewData.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewData(
totalInvoices: json['totalInvoices'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
averageValue: (json['averageValue'] != null)
? (json['averageValue'] as num).toDouble()
: null,
statusBreakdown: json['statusBreakdown'] != null
? (json['statusBreakdown'] as List)
.map((e) => StatusBreakdown.fromJson(e))
.toList()
: null,
projectBreakdown: json['projectBreakdown'] != null
? (json['projectBreakdown'] as List)
.map((e) => ProjectBreakdown.fromJson(e))
.toList()
: null,
topSupplier: json['topSupplier'] != null
? TopSupplier.fromJson(json['topSupplier'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'totalInvoices': totalInvoices,
'totalValue': totalValue,
'averageValue': averageValue,
'statusBreakdown': statusBreakdown?.map((e) => e.toJson()).toList(),
'projectBreakdown': projectBreakdown?.map((e) => e.toJson()).toList(),
'topSupplier': topSupplier?.toJson(),
};
}
}
class StatusBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
StatusBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory StatusBreakdown.fromJson(Map<String, dynamic> json) {
return StatusBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class ProjectBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
ProjectBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory ProjectBreakdown.fromJson(Map<String, dynamic> json) {
return ProjectBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class TopSupplier {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
TopSupplier({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory TopSupplier.fromJson(Map<String, dynamic> json) {
return TopSupplier(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}

View File

@ -148,7 +148,7 @@ class DocumentType {
final String name; final String name;
final String? regexExpression; final String? regexExpression;
final String allowedContentType; final String allowedContentType;
final double maxSizeAllowedInMB; final int maxSizeAllowedInMB;
final bool isValidationRequired; final bool isValidationRequired;
final bool isMandatory; final bool isMandatory;
final bool isSystem; final bool isSystem;

View File

@ -1,7 +1,7 @@
class DocumentsResponse { class DocumentsResponse {
final bool success; final bool success;
final String message; final String message;
final DocumentDataWrapper? data; final DocumentDataWrapper data;
final dynamic errors; final dynamic errors;
final int statusCode; final int statusCode;
final DateTime timestamp; final DateTime timestamp;
@ -9,7 +9,7 @@ class DocumentsResponse {
DocumentsResponse({ DocumentsResponse({
required this.success, required this.success,
required this.message, required this.message,
this.data, required this.data,
this.errors, this.errors,
required this.statusCode, required this.statusCode,
required this.timestamp, required this.timestamp,
@ -19,13 +19,11 @@ class DocumentsResponse {
return DocumentsResponse( return DocumentsResponse(
success: json['success'] ?? false, success: json['success'] ?? false,
message: json['message'] ?? '', message: json['message'] ?? '',
data: json['data'] != null data: DocumentDataWrapper.fromJson(json['data']),
? DocumentDataWrapper.fromJson(json['data'])
: null,
errors: json['errors'], errors: json['errors'],
statusCode: json['statusCode'] ?? 0, statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] != null timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp']) ?? DateTime.now() ? DateTime.parse(json['timestamp'])
: DateTime.now(), : DateTime.now(),
); );
} }
@ -34,7 +32,7 @@ class DocumentsResponse {
return { return {
'success': success, 'success': success,
'message': message, 'message': message,
'data': data?.toJson(), 'data': data.toJson(),
'errors': errors, 'errors': errors,
'statusCode': statusCode, 'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(), 'timestamp': timestamp.toIso8601String(),
@ -63,10 +61,9 @@ class DocumentDataWrapper {
currentPage: json['currentPage'] ?? 0, currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0, totalPages: json['totalPages'] ?? 0,
totalEntites: json['totalEntites'] ?? 0, totalEntites: json['totalEntites'] ?? 0,
data: (json['data'] as List<dynamic>?) data: (json['data'] as List<dynamic>? ?? [])
?.map((e) => DocumentItem.fromJson(e)) .map((e) => DocumentItem.fromJson(e))
.toList() ?? .toList(),
[],
); );
} }
@ -86,28 +83,28 @@ class DocumentItem {
final String name; final String name;
final String documentId; final String documentId;
final String description; final String description;
final DateTime? uploadedAt; final DateTime uploadedAt;
final String? parentAttachmentId; final String? parentAttachmentId;
final bool isCurrentVersion; final bool isCurrentVersion;
final int version; final int version;
final bool isActive; final bool isActive;
final bool? isVerified; final bool? isVerified;
final UploadedBy? uploadedBy; final UploadedBy uploadedBy;
final DocumentType? documentType; final DocumentType documentType;
DocumentItem({ DocumentItem({
required this.id, required this.id,
required this.name, required this.name,
required this.documentId, required this.documentId,
required this.description, required this.description,
this.uploadedAt, required this.uploadedAt,
this.parentAttachmentId, this.parentAttachmentId,
required this.isCurrentVersion, required this.isCurrentVersion,
required this.version, required this.version,
required this.isActive, required this.isActive,
this.isVerified, this.isVerified,
this.uploadedBy, required this.uploadedBy,
this.documentType, required this.documentType,
}); });
factory DocumentItem.fromJson(Map<String, dynamic> json) { factory DocumentItem.fromJson(Map<String, dynamic> json) {
@ -116,20 +113,14 @@ class DocumentItem {
name: json['name'] ?? '', name: json['name'] ?? '',
documentId: json['documentId'] ?? '', documentId: json['documentId'] ?? '',
description: json['description'] ?? '', description: json['description'] ?? '',
uploadedAt: json['uploadedAt'] != null uploadedAt: DateTime.parse(json['uploadedAt']),
? DateTime.tryParse(json['uploadedAt'])
: null,
parentAttachmentId: json['parentAttachmentId'], parentAttachmentId: json['parentAttachmentId'],
isCurrentVersion: json['isCurrentVersion'] ?? false, isCurrentVersion: json['isCurrentVersion'] ?? false,
version: json['version'] ?? 0, version: json['version'] ?? 0,
isActive: json['isActive'] ?? false, isActive: json['isActive'] ?? false,
isVerified: json['isVerified'], isVerified: json['isVerified'],
uploadedBy: json['uploadedBy'] != null uploadedBy: UploadedBy.fromJson(json['uploadedBy']),
? UploadedBy.fromJson(json['uploadedBy']) documentType: DocumentType.fromJson(json['documentType']),
: null,
documentType: json['documentType'] != null
? DocumentType.fromJson(json['documentType'])
: null,
); );
} }
@ -139,14 +130,14 @@ class DocumentItem {
'name': name, 'name': name,
'documentId': documentId, 'documentId': documentId,
'description': description, 'description': description,
'uploadedAt': uploadedAt?.toIso8601String(), 'uploadedAt': uploadedAt.toIso8601String(),
'parentAttachmentId': parentAttachmentId, 'parentAttachmentId': parentAttachmentId,
'isCurrentVersion': isCurrentVersion, 'isCurrentVersion': isCurrentVersion,
'version': version, 'version': version,
'isActive': isActive, 'isActive': isActive,
'isVerified': isVerified, 'isVerified': isVerified,
'uploadedBy': uploadedBy?.toJson(), 'uploadedBy': uploadedBy.toJson(),
'documentType': documentType?.toJson(), 'documentType': documentType.toJson(),
}; };
} }
} }
@ -217,7 +208,7 @@ class DocumentType {
final String name; final String name;
final String? regexExpression; final String? regexExpression;
final String? allowedContentType; final String? allowedContentType;
final double? maxSizeAllowedInMB; final int? maxSizeAllowedInMB;
final bool isValidationRequired; final bool isValidationRequired;
final bool isMandatory; final bool isMandatory;
final bool isSystem; final bool isSystem;
@ -241,7 +232,7 @@ class DocumentType {
return DocumentType( return DocumentType(
id: json['id'] ?? '', id: json['id'] ?? '',
name: json['name'] ?? '', name: json['name'] ?? '',
regexExpression: json['regexExpression'], regexExpression: json['regexExpression'], // nullable
allowedContentType: json['allowedContentType'], allowedContentType: json['allowedContentType'],
maxSizeAllowedInMB: json['maxSizeAllowedInMB'], maxSizeAllowedInMB: json['maxSizeAllowedInMB'],
isValidationRequired: json['isValidationRequired'] ?? false, isValidationRequired: json['isValidationRequired'] ?? false,

View File

@ -1,222 +0,0 @@
class ProjectDetailsResponse {
final bool? success;
final String? message;
final ProjectData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
ProjectDetailsResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory ProjectDetailsResponse.fromJson(Map<String, dynamic> json) {
return ProjectDetailsResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null ? ProjectData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp?.toIso8601String(),
};
}
}
class ProjectData {
final String? id;
final String? name;
final String? shortName;
final String? projectAddress;
final String? contactPerson;
final DateTime? startDate;
final DateTime? endDate;
final ProjectStatus? projectStatus;
final Promoter? promoter;
final Pmc? pmc;
ProjectData({
this.id,
this.name,
this.shortName,
this.projectAddress,
this.contactPerson,
this.startDate,
this.endDate,
this.projectStatus,
this.promoter,
this.pmc,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
return ProjectData(
id: json['id'] as String?,
name: json['name'] as String?,
shortName: json['shortName'] as String?,
projectAddress: json['projectAddress'] as String?,
contactPerson: json['contactPerson'] as String?,
startDate: json['startDate'] != null
? DateTime.tryParse(json['startDate'])
: null,
endDate: json['endDate'] != null
? DateTime.tryParse(json['endDate'])
: null,
projectStatus: json['projectStatus'] != null
? ProjectStatus.fromJson(json['projectStatus'])
: null,
promoter: json['promoter'] != null
? Promoter.fromJson(json['promoter'])
: null,
pmc: json['pmc'] != null ? Pmc.fromJson(json['pmc']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'projectStatus': projectStatus?.toJson(),
'promoter': promoter?.toJson(),
'pmc': pmc?.toJson(),
};
}
}
class ProjectStatus {
final String? id;
final String? status;
ProjectStatus({this.id, this.status});
factory ProjectStatus.fromJson(Map<String, dynamic> json) {
return ProjectStatus(
id: json['id'] as String?,
status: json['status'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'status': status,
};
}
}
class Promoter {
final String? id;
final String? name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
Promoter({
this.id,
this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory Promoter.fromJson(Map<String, dynamic> json) {
return Promoter(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
contactPerson: json['contactPerson'] as String?,
address: json['address'] as String?,
gstNumber: json['gstNumber'] as String?,
contactNumber: json['contactNumber'] as String?,
sprid: json['sprid'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}
class Pmc {
final String? id;
final String? name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
Pmc({
this.id,
this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory Pmc.fromJson(Map<String, dynamic> json) {
return Pmc(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
contactPerson: json['contactPerson'] as String?,
address: json['address'] as String?,
gstNumber: json['gstNumber'] as String?,
contactNumber: json['contactNumber'] as String?,
sprid: json['sprid'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}

View File

@ -1,138 +0,0 @@
// Root Response Model
class ProjectsResponse {
final bool? success;
final String? message;
final ProjectsPageData? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
ProjectsResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory ProjectsResponse.fromJson(Map<String, dynamic> json) {
return ProjectsResponse(
success: json['success'],
message: json['message'],
data: json['data'] != null
? ProjectsPageData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: json['timestamp'],
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
// Pagination + Data List
class ProjectsPageData {
final int? currentPage;
final int? totalPages;
final int? totalEntites;
final List<ProjectData>? data;
ProjectsPageData({
this.currentPage,
this.totalPages,
this.totalEntites,
this.data,
});
factory ProjectsPageData.fromJson(Map<String, dynamic> json) {
return ProjectsPageData(
currentPage: json['currentPage'],
totalPages: json['totalPages'],
totalEntites: json['totalEntites'],
data: (json['data'] as List<dynamic>?)
?.map((e) => ProjectData.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntites': totalEntites,
'data': data?.map((e) => e.toJson()).toList(),
};
}
}
// Individual Project Model
class ProjectData {
final String? id;
final String? name;
final String? shortName;
final String? projectAddress;
final String? contactPerson;
final String? startDate;
final String? endDate;
final String? projectStatusId;
final int? teamSize;
final double? completedWork;
final double? plannedWork;
ProjectData({
this.id,
this.name,
this.shortName,
this.projectAddress,
this.contactPerson,
this.startDate,
this.endDate,
this.projectStatusId,
this.teamSize,
this.completedWork,
this.plannedWork,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
return ProjectData(
id: json['id'],
name: json['name'],
shortName: json['shortName'],
projectAddress: json['projectAddress'],
contactPerson: json['contactPerson'],
startDate: json['startDate'],
endDate: json['endDate'],
projectStatusId: json['projectStatusId'],
teamSize: json['teamSize'],
completedWork: (json['completedWork'] as num?)?.toDouble(),
plannedWork: (json['plannedWork'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate,
'endDate': endDate,
'projectStatusId': projectStatusId,
'teamSize': teamSize,
'completedWork': completedWork,
'plannedWork': plannedWork,
};
}
}

View File

@ -1,253 +0,0 @@
class JobCommentResponse {
final bool? success;
final String? message;
final JobCommentData? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
JobCommentResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory JobCommentResponse.fromJson(Map<String, dynamic> json) {
return JobCommentResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null ? JobCommentData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] as String?,
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class JobCommentData {
final int? currentPage;
final int? totalPages;
final int? totalEntities;
final List<CommentItem>? data;
JobCommentData({
this.currentPage,
this.totalPages,
this.totalEntities,
this.data,
});
factory JobCommentData.fromJson(Map<String, dynamic> json) {
return JobCommentData(
currentPage: json['currentPage'] as int?,
totalPages: json['totalPages'] as int?,
totalEntities: json['totalEntities'] as int?,
data: json['data'] != null
? List<CommentItem>.from(
(json['data'] as List).map((x) => CommentItem.fromJson(x)))
: null,
);
}
Map<String, dynamic> toJson() => {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntities': totalEntities,
'data': data?.map((x) => x.toJson()).toList(),
};
}
class CommentItem {
final String? id;
final JobTicket? jobTicket;
final String? comment;
final bool? isActive;
final String? createdAt;
final User? createdBy;
final String? updatedAt;
final User? updatedBy;
final List<Attachment>? attachments;
CommentItem({
this.id,
this.jobTicket,
this.comment,
this.isActive,
this.createdAt,
this.createdBy,
this.updatedAt,
this.updatedBy,
this.attachments,
});
factory CommentItem.fromJson(Map<String, dynamic> json) {
return CommentItem(
id: json['id'] as String?,
jobTicket: json['jobTicket'] != null
? JobTicket.fromJson(json['jobTicket'])
: null,
comment: json['comment'] as String?,
isActive: json['isActive'] as bool?,
createdAt: json['createdAt'] as String?,
createdBy:
json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
updatedAt: json['updatedAt'] as String?,
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
attachments: json['attachments'] != null
? List<Attachment>.from(
(json['attachments'] as List).map((x) => Attachment.fromJson(x)))
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'jobTicket': jobTicket?.toJson(),
'comment': comment,
'isActive': isActive,
'createdAt': createdAt,
'createdBy': createdBy?.toJson(),
'updatedAt': updatedAt,
'updatedBy': updatedBy?.toJson(),
'attachments': attachments?.map((x) => x.toJson()).toList(),
};
}
class JobTicket {
final String? id;
final String? title;
final String? description;
final String? jobTicketUId;
final String? statusName;
final bool? isArchive;
JobTicket({
this.id,
this.title,
this.description,
this.jobTicketUId,
this.statusName,
this.isArchive,
});
factory JobTicket.fromJson(Map<String, dynamic> json) {
return JobTicket(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
jobTicketUId: json['jobTicketUId'] as String?,
statusName: json['statusName'] as String?,
isArchive: json['isArchive'] as bool?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'jobTicketUId': jobTicketUId,
'statusName': statusName,
'isArchive': isArchive,
};
}
class User {
final String? id;
final String? firstName;
final String? lastName;
final String? email;
final String? photo;
final String? jobRoleId;
final String? jobRoleName;
User({
this.id,
this.firstName,
this.lastName,
this.email,
this.photo,
this.jobRoleId,
this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String?,
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
email: json['email'] as String?,
photo: json['photo'] as String?,
jobRoleId: json['jobRoleId'] as String?,
jobRoleName: json['jobRoleName'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'firstName': firstName,
'lastName': lastName,
'email': email,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
class Attachment {
final String? id;
final String? batchId;
final String? fileName;
final String? preSignedUrl;
final String? thumbPreSignedUrl;
final int? fileSize;
final String? contentType;
final String? uploadedAt;
Attachment({
this.id,
this.batchId,
this.fileName,
this.preSignedUrl,
this.thumbPreSignedUrl,
this.fileSize,
this.contentType,
this.uploadedAt,
});
factory Attachment.fromJson(Map<String, dynamic> json) {
return Attachment(
id: json['id'] as String?,
batchId: json['batchId'] as String?,
fileName: json['fileName'] as String?,
preSignedUrl: json['preSignedUrl'] as String?,
thumbPreSignedUrl: json['thumbPreSignedUrl'] as String?,
fileSize: json['fileSize'] as int?,
contentType: json['contentType'] as String?,
uploadedAt: json['uploadedAt'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'batchId': batchId,
'fileName': fileName,
'preSignedUrl': preSignedUrl,
'thumbPreSignedUrl': thumbPreSignedUrl,
'fileSize': fileSize,
'contentType': contentType,
'uploadedAt': uploadedAt,
};
}

View File

@ -1,85 +0,0 @@
class JobStatusResponse {
final bool? success;
final String? message;
final List<JobStatus>? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
JobStatusResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory JobStatusResponse.fromJson(Map<String, dynamic> json) {
return JobStatusResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: (json['data'] as List<dynamic>?)
?.map((e) => JobStatus.fromJson(e))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
// --------------------------
// Single Job Status Model
// --------------------------
class JobStatus {
final String? id;
final String? name;
final String? displayName;
final int? level;
JobStatus({
this.id,
this.name,
this.displayName,
this.level,
});
factory JobStatus.fromJson(Map<String, dynamic> json) {
return JobStatus(
id: json['id'] as String?,
name: json['name'] as String?,
displayName: json['displayName'] as String?,
level: json['level'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'displayName': displayName,
'level': level,
};
}
// Add equality by id
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is JobStatus && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

View File

@ -277,7 +277,6 @@ class UpdateLog {
final Status? status; final Status? status;
final Status? nextStatus; final Status? nextStatus;
final String? comment; final String? comment;
final String? updatedAt;
final User? updatedBy; final User? updatedBy;
UpdateLog({ UpdateLog({
@ -285,7 +284,6 @@ class UpdateLog {
this.status, this.status,
this.nextStatus, this.nextStatus,
this.comment, this.comment,
this.updatedAt,
this.updatedBy, this.updatedBy,
}); });
@ -299,7 +297,6 @@ class UpdateLog {
? Status.fromJson(json['nextStatus']) ? Status.fromJson(json['nextStatus'])
: null, : null,
comment: json['comment'] as String?, comment: json['comment'] as String?,
updatedAt: json['updatedAt'] as String?,
updatedBy: updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
); );

View File

@ -11,6 +11,8 @@ import 'package:on_field_work/view/error_pages/error_404_screen.dart';
import 'package:on_field_work/view/error_pages/error_500_screen.dart'; import 'package:on_field_work/view/error_pages/error_500_screen.dart';
import 'package:on_field_work/view/dashboard/dashboard_screen.dart'; import 'package:on_field_work/view/dashboard/dashboard_screen.dart';
import 'package:on_field_work/view/Attendence/attendance_screen.dart'; import 'package:on_field_work/view/Attendence/attendance_screen.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
import 'package:on_field_work/view/employees/employees_screen.dart'; import 'package:on_field_work/view/employees/employees_screen.dart';
import 'package:on_field_work/view/auth/login_option_screen.dart'; import 'package:on_field_work/view/auth/login_option_screen.dart';
import 'package:on_field_work/view/auth/mpin_screen.dart'; import 'package:on_field_work/view/auth/mpin_screen.dart';
@ -23,8 +25,6 @@ import 'package:on_field_work/view/finance/finance_screen.dart';
import 'package:on_field_work/view/finance/advance_payment_screen.dart'; import 'package:on_field_work/view/finance/advance_payment_screen.dart';
import 'package:on_field_work/view/finance/payment_request_screen.dart'; import 'package:on_field_work/view/finance/payment_request_screen.dart';
import 'package:on_field_work/view/service_project/service_project_screen.dart'; import 'package:on_field_work/view/service_project/service_project_screen.dart';
import 'package:on_field_work/view/infraProject/infra_project_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
RouteSettings? redirect(String? route) { RouteSettings? redirect(String? route) {
@ -70,6 +70,15 @@ getPageRoute() {
name: '/dashboard/employees', name: '/dashboard/employees',
page: () => EmployeesScreen(), page: () => EmployeesScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Daily Task Planning
GetPage(
name: '/dashboard/daily-task-Planning',
page: () => DailyTaskPlanningScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/daily-task-progress',
page: () => DailyProgressReportScreen(),
middlewares: [AuthMiddleware()]),
GetPage( GetPage(
name: '/dashboard/directory-main-page', name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(), page: () => DirectoryMainScreen(),
@ -93,12 +102,6 @@ getPageRoute() {
name: '/dashboard/payment-request', name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(), page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Infrastructure Projects
GetPage(
name: '/dashboard/infra-projects',
page: () => InfraProjectScreen(),
middlewares: [AuthMiddleware()]),
// Authentication // Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart'; import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart'; import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart'; import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_container.dart'; import 'package:on_field_work/helpers/widgets/my_container.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.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/helpers/widgets/my_text.dart';
@ -11,14 +12,21 @@ import 'package:on_field_work/model/attendance/log_details_view.dart';
import 'package:on_field_work/model/attendance/attendence_action_button.dart'; import 'package:on_field_work/model/attendance/attendence_action_button.dart';
import 'package:on_field_work/helpers/utils/attendance_actions.dart'; import 'package:on_field_work/helpers/utils/attendance_actions.dart';
class AttendanceLogsTab extends StatelessWidget { class AttendanceLogsTab extends StatefulWidget {
final AttendanceController controller; final AttendanceController controller;
const AttendanceLogsTab({super.key, required this.controller}); const AttendanceLogsTab({super.key, required this.controller});
@override
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
}
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
Widget _buildStatusHeader() { Widget _buildStatusHeader() {
return Obx(() { return Obx(() {
if (!controller.showPendingOnly.value) return const SizedBox.shrink(); if (!widget.controller.showPendingOnly.value) {
return const SizedBox.shrink();
}
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -38,7 +46,7 @@ class AttendanceLogsTab extends StatelessWidget {
), ),
), ),
InkWell( InkWell(
onTap: () => controller.showPendingOnly.value = false, onTap: () => widget.controller.showPendingOnly.value = false,
child: const Icon(Icons.close, size: 18, color: Colors.orange), child: const Icon(Icons.close, size: 18, color: Colors.orange),
), ),
], ],
@ -47,6 +55,7 @@ class AttendanceLogsTab extends StatelessWidget {
}); });
} }
/// Return button text priority for sorting inside same date
int _getActionPriority(employee) { int _getActionPriority(employee) {
final text = AttendanceButtonHelper.getButtonText( final text = AttendanceButtonHelper.getButtonText(
activity: employee.activity, activity: employee.activity,
@ -68,20 +77,32 @@ class AttendanceLogsTab extends StatelessWidget {
final isCheckoutAction = final isCheckoutAction =
text.contains("checkout") || text.contains("check out"); text.contains("checkout") || text.contains("check out");
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) return 0; int priority;
if (isCheckoutAction) return 0; if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) {
if (text.contains("regular")) return 1; priority = 0;
if (text == "requested") return 2; } else if (isCheckoutAction) {
if (text == "approved") return 3; priority = 0;
if (text == "rejected") return 4; } else if (text.contains("regular")) {
return 5; priority = 1;
} else if (text == "requested") {
priority = 2;
} else if (text == "approved") {
priority = 3;
} else if (text == "rejected") {
priority = 4;
} else {
priority = 5;
}
return priority;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final allLogs = List.of(controller.filteredLogs); final allLogs = List.of(widget.controller.filteredLogs);
final showPendingOnly = controller.showPendingOnly.value;
// Filter logs if "pending only"
final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly final filteredLogs = showPendingOnly
? allLogs.where((emp) => emp.activity == 1).toList() ? allLogs.where((emp) => emp.activity == 1).toList()
: allLogs; : allLogs;
@ -95,6 +116,7 @@ class AttendanceLogsTab extends StatelessWidget {
groupedLogs.putIfAbsent(dateKey, () => []).add(log); groupedLogs.putIfAbsent(dateKey, () => []).add(log);
} }
// Sort dates (latest first)
final sortedDates = groupedLogs.keys.toList() final sortedDates = groupedLogs.keys.toList()
..sort((a, b) { ..sort((a, b) {
final da = DateTimeUtils.parseDate(a, 'dd MMM yyyy') ?? DateTime(0); final da = DateTimeUtils.parseDate(a, 'dd MMM yyyy') ?? DateTime(0);
@ -103,19 +125,20 @@ class AttendanceLogsTab extends StatelessWidget {
}); });
final dateRangeText = final dateRangeText =
'${DateTimeUtils.formatDate(controller.startDateAttendance.value, 'dd MMM yyyy')} - ' '${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(controller.endDateAttendance.value, 'dd MMM yyyy')}'; '${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
// Sticky header + scrollable list
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header Row // Header row
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
controller.isLoadingAttendanceLogs.value MyText.titleMedium("Attendance Logs", fontWeight: 600),
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader() ? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall( : MyText.bodySmall(
dateRangeText, dateRangeText,
@ -129,53 +152,50 @@ class AttendanceLogsTab extends StatelessWidget {
// Pending-only header // Pending-only header
_buildStatusHeader(), _buildStatusHeader(),
MySpacing.height(8),
// Divider between header and list // Content: loader, empty, or logs
const Divider(height: 1), if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
// Scrollable attendance logs else if (filteredLogs.isEmpty)
Expanded( SizedBox(
child: controller.isLoadingAttendanceLogs.value height: 120,
? SkeletonLoaders.employeeListSkeletonLoader() child: Center(
: filteredLogs.isEmpty
? Center(
child: Text(showPendingOnly child: Text(showPendingOnly
? "No Pending Actions Found" ? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"), : "No Attendance Logs Found for this Project"),
),
) )
: ListView.builder( else
padding: MySpacing.all(8), MyCard.bordered(
itemCount: sortedDates.length, paddingAll: 8,
itemBuilder: (context, dateIndex) { child: Column(
final date = sortedDates[dateIndex]; crossAxisAlignment: CrossAxisAlignment.start,
final employees = groupedLogs[date]! children: [
..sort((a, b) { for (final date in sortedDates) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(date, fontWeight: 700),
),
// Sort employees inside this date by action priority first, then latest entry
for (final emp in (groupedLogs[date]!
..sort(
(a, b) {
final priorityCompare = _getActionPriority(a) final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b)); .compareTo(_getActionPriority(b));
if (priorityCompare != 0) return priorityCompare; if (priorityCompare != 0) return priorityCompare;
final aTime =
a.checkOut ?? a.checkIn ?? DateTime(0);
final bTime =
b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(aTime);
});
return Column( final aTime = a.checkOut ?? a.checkIn ?? DateTime(0);
crossAxisAlignment: CrossAxisAlignment.start, final bTime = b.checkOut ?? b.checkIn ?? DateTime(0);
children: [ return bTime.compareTo(
Padding( aTime);
padding: },
const EdgeInsets.symmetric(vertical: 8), ))) ...[
child: MyText.bodyMedium(date, fontWeight: 700),
),
...employees.map(
(emp) => Column(
children: [
MyContainer( MyContainer(
paddingAll: 8, paddingAll: 8,
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Avatar( Avatar(
firstName: emp.firstName, firstName: emp.firstName,
@ -185,8 +205,7 @@ class AttendanceLogsTab extends StatelessWidget {
MySpacing.width(16), MySpacing.width(16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
@ -194,8 +213,7 @@ class AttendanceLogsTab extends StatelessWidget {
child: MyText.bodyMedium( child: MyText.bodyMedium(
emp.name, emp.name,
fontWeight: 600, fontWeight: 600,
overflow: TextOverflow overflow: TextOverflow.ellipsis,
.ellipsis,
), ),
), ),
MySpacing.width(6), MySpacing.width(6),
@ -204,8 +222,7 @@ class AttendanceLogsTab extends StatelessWidget {
'(${emp.designation})', '(${emp.designation})',
fontWeight: 600, fontWeight: 600,
color: Colors.grey[700], color: Colors.grey[700],
overflow: TextOverflow overflow: TextOverflow.ellipsis,
.ellipsis,
), ),
), ),
], ],
@ -215,37 +232,24 @@ class AttendanceLogsTab extends StatelessWidget {
emp.checkOut != null) emp.checkOut != null)
Row( Row(
children: [ children: [
if (emp.checkIn != if (emp.checkIn != null) ...[
null) ...[ const Icon(Icons.arrow_circle_right,
const Icon( size: 16, color: Colors.green),
Icons
.arrow_circle_right,
size: 16,
color:
Colors.green),
MySpacing.width(4), MySpacing.width(4),
MyText.bodySmall( MyText.bodySmall(
DateTimeUtils DateTimeUtils.formatDate(
.formatDate( emp.checkIn!, 'hh:mm a'),
emp.checkIn!,
'hh:mm a'),
fontWeight: 600, fontWeight: 600,
), ),
MySpacing.width(16), MySpacing.width(16),
], ],
if (emp.checkOut != if (emp.checkOut != null) ...[
null) ...[ const Icon(Icons.arrow_circle_left,
const Icon( size: 16, color: Colors.red),
Icons
.arrow_circle_left,
size: 16,
color: Colors.red),
MySpacing.width(4), MySpacing.width(4),
MyText.bodySmall( MyText.bodySmall(
DateTimeUtils DateTimeUtils.formatDate(
.formatDate( emp.checkOut!, 'hh:mm a'),
emp.checkOut!,
'hh:mm a'),
fontWeight: 600, fontWeight: 600,
), ),
], ],
@ -253,19 +257,16 @@ class AttendanceLogsTab extends StatelessWidget {
), ),
MySpacing.height(12), MySpacing.height(12),
Row( Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.end,
MainAxisAlignment.end,
children: [ children: [
AttendanceActionButton( AttendanceActionButton(
employee: emp, employee: emp,
attendanceController: attendanceController: widget.controller,
controller,
), ),
MySpacing.width(8), MySpacing.width(8),
AttendanceLogViewButton( AttendanceLogViewButton(
employee: emp, employee: emp,
attendanceController: attendanceController: widget.controller,
controller,
), ),
], ],
), ),
@ -275,12 +276,10 @@ class AttendanceLogsTab extends StatelessWidget {
], ],
), ),
), ),
Divider(color: Colors.grey.withOpacity(0.3)),
],
], ],
),
),
], ],
);
},
), ),
), ),
], ],

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/theme/app_theme.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_flex.dart'; import 'package:on_field_work/helpers/widgets/my_flex.dart';
import 'package:on_field_work/helpers/widgets/my_flex_item.dart'; import 'package:on_field_work/helpers/widgets/my_flex_item.dart';
@ -7,15 +8,12 @@ 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/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart'; import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart'; import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart';
import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart'; import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart';
import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart'; import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
class AttendanceScreen extends StatefulWidget { class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key}); const AttendanceScreen({super.key});
@ -24,84 +22,43 @@ class AttendanceScreen extends StatefulWidget {
State<AttendanceScreen> createState() => _AttendanceScreenState(); State<AttendanceScreen> createState() => _AttendanceScreenState();
} }
class _AttendanceScreenState extends State<AttendanceScreen> class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
with SingleTickerProviderStateMixin, UIMixin {
final attendanceController = Get.put(AttendanceController()); final attendanceController = Get.put(AttendanceController());
final permissionController = Get.put(PermissionController()); final permissionController = Get.put(PermissionController());
final projectController = Get.put(ProjectController()); final projectController = Get.find<ProjectController>();
late TabController _tabController; String selectedTab = 'todaysAttendance';
late List<Map<String, String>> _tabs;
bool _tabsInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
ever(permissionController.permissionsLoaded, (loaded) {
if (loaded == true && !_tabsInitialized) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeTabs();
setState(() {});
});
}
});
// Watch project changes to reload data
ever<String>(projectController.selectedProjectId, (projectId) async { ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty && _tabsInitialized) { if (projectId.isNotEmpty) await _loadData(projectId);
await _fetchTabData(attendanceController.selectedTab);
}
}); });
// If permissions are already loaded at init WidgetsBinding.instance.addPostFrameCallback((_) {
if (permissionController.permissionsLoaded.value) {
_initializeTabs();
}
}
void _initializeTabs() async {
final allTabs = [
{'label': "Today's", 'value': 'todaysAttendance'},
{'label': "Logs", 'value': 'attendanceLogs'},
{'label': "Regularization", 'value': 'regularizationRequests'},
];
final hasRegularizationPermission =
permissionController.hasPermission(Permissions.regularizeAttendance);
_tabs = allTabs.where((tab) {
return tab['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
_tabController = TabController(length: _tabs.length, vsync: this);
// Keep selectedTab in sync and fetch data on tab change
_tabController.addListener(() async {
if (!_tabController.indexIsChanging) {
final selectedTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
}
});
_tabsInitialized = true;
// Load initial data for default tab
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) _loadData(projectId);
final initialTab = _tabs[_tabController.index]['value']!; });
attendanceController.selectedTab = initialTab; }
await _fetchTabData(initialTab);
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
// attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error loading data: $e");
} }
} }
Future<void> _fetchTabData(String tab) async { Future<void> _refreshData() async {
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
switch (tab) { // Call only the relevant API for current tab
switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId); await attendanceController.fetchTodaysAttendance(projectId);
break; break;
@ -118,8 +75,59 @@ class _AttendanceScreenState extends State<AttendanceScreen>
} }
} }
Future<void> _refreshData() async { Widget _buildAppBar() {
await _fetchTabData(attendanceController.selectedTab); return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Attendance',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
);
} }
Widget _buildFilterSearchRow() { Widget _buildFilterSearchRow() {
@ -155,11 +163,11 @@ class _AttendanceScreenState extends State<AttendanceScreen>
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
@ -167,14 +175,17 @@ class _AttendanceScreenState extends State<AttendanceScreen>
}), }),
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
), ),
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -187,18 +198,19 @@ class _AttendanceScreenState extends State<AttendanceScreen>
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius:
BorderRadius.vertical(top: Radius.circular(5)), BorderRadius.vertical(top: Radius.circular(12)),
), ),
builder: (context) => AttendanceFilterBottomSheet( builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController, controller: attendanceController,
permissionController: permissionController, permissionController: permissionController,
selectedTab: _tabs[_tabController.index]['value']!, selectedTab: selectedTab,
), ),
); );
if (result != null) { if (result != null) {
final selectedProjectId = final selectedProjectId =
projectController.selectedProjectId.value; projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
final selectedOrgId = final selectedOrgId =
result['selectedOrganization'] as String?; result['selectedOrganization'] as String?;
@ -209,12 +221,111 @@ class _AttendanceScreenState extends State<AttendanceScreen>
} }
if (selectedProjectId.isNotEmpty) { if (selectedProjectId.isNotEmpty) {
await _fetchTabData(attendanceController.selectedTab); try {
await attendanceController.fetchTodaysAttendance(
selectedProjectId,
);
await attendanceController.fetchAttendanceLogs(
selectedProjectId,
);
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
attendanceController.selectedTab = selectedView;
if (selectedProjectId.isNotEmpty) {
await attendanceController
.fetchProjectData(selectedProjectId);
}
} }
} }
}, },
), ),
), ),
MySpacing.width(8),
// Pending Actions Menu (red dot here instead)
if (selectedTab == 'attendanceLogs')
Obx(() {
final showPending = attendanceController.showPendingOnly.value;
return Stack(
children: [
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
children: [
const SizedBox(width: 10),
const Expanded(
child: Text('Show Pending Actions')),
Switch.adaptive(
value: attendanceController
.showPendingOnly.value,
activeColor: Colors.indigo,
onChanged: (val) {
attendanceController
.showPendingOnly.value = val;
Navigator.pop(context);
},
),
],
)),
),
],
),
),
if (showPending)
Positioned(
top: 6,
right: 6,
child: Container(
height: 8,
width: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
);
}),
], ],
), ),
); );
@ -233,11 +344,8 @@ class _AttendanceScreenState extends State<AttendanceScreen>
); );
} }
Widget _buildTabBarView() { Widget _buildSelectedTabContent() {
return TabBarView( switch (selectedTab) {
controller: _tabController,
children: _tabs.map((tab) {
switch (tab['value']) {
case 'attendanceLogs': case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController); return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests': case 'regularizationRequests':
@ -246,77 +354,33 @@ class _AttendanceScreenState extends State<AttendanceScreen>
default: default:
return TodaysAttendanceTab(controller: attendanceController); return TodaysAttendanceTab(controller: attendanceController);
} }
}).toList(),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
if (!_tabsInitialized) {
return Scaffold( return Scaffold(
appBar: CustomAppBar( appBar: PreferredSize(
title: "Attendance", preferredSize: const Size.fromHeight(72),
backgroundColor: appBarColor, child: _buildAppBar(),
onBackPressed: () => Get.toNamed('/dashboard'),
), ),
body: const Center(child: CircularProgressIndicator()), body: SafeArea(
);
}
return Scaffold(
appBar: CustomAppBar(
title: "Attendance",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
SafeArea(
child: GetBuilder<AttendanceController>( child: GetBuilder<AttendanceController>(
init: attendanceController, init: attendanceController,
tag: 'attendance_dashboard_controller', tag: 'attendance_dashboard_controller',
builder: (controller) { builder: (controller) {
final selectedProjectId = final selectedProjectId = projectController.selectedProjectId.value;
projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty; final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshData, onRefresh: _refreshData,
child: SingleChildScrollView( child: Builder(
builder: (context) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero, padding: MySpacing.zero,
child: Column( child: Column(
children: [ children: [
Padding( MySpacing.height(flexSpacing),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e['label']!).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
onTap: (index) async {
final selectedTab = _tabs[index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
},
),
),
_buildFilterSearchRow(), _buildFilterSearchRow(),
MyFlex( MyFlex(
children: [ children: [
@ -324,30 +388,25 @@ class _AttendanceScreenState extends State<AttendanceScreen>
sizes: 'lg-12 md-12 sm-12', sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected child: noProjectSelected
? _buildNoProjectWidget() ? _buildNoProjectWidget()
: SizedBox( : _buildSelectedTabContent(),
height:
MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
), ),
], ],
), ),
], ],
), ),
),
); );
}, },
), ),
);
},
), ),
],
), ),
); );
} }
@override @override
void dispose() { void dispose() {
_tabController.dispose(); // 🧹 Clean up the controller when user leaves this screen
if (Get.isRegistered<AttendanceController>()) { if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>(); Get.delete<AttendanceController>();
} }

View File

@ -1,3 +1,4 @@
// lib/view/attendance/tabs/regularization_requests_tab.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -18,11 +19,17 @@ class RegularizationRequestsTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Column(
final isLoading = controller.isLoadingRegularizationLogs.value; crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
final employees = controller.filteredRegularizationLogs; final employees = controller.filteredRegularizationLogs;
if (isLoading) { if (controller.isLoadingRegularizationLogs.value) {
return SkeletonLoaders.employeeListSkeletonLoader(); return SkeletonLoaders.employeeListSkeletonLoader();
} }
@ -30,22 +37,18 @@ class RegularizationRequestsTab extends StatelessWidget {
return const SizedBox( return const SizedBox(
height: 120, height: 120,
child: Center( child: Center(
child: Text("No Regularization Requests Found for this Project"), child:
Text("No Regularization Requests Found for this Project"),
), ),
); );
} }
return ListView.builder( return MyCard.bordered(
itemCount: employees.length,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
final employee = employees[index]; // Corrected index
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
paddingAll: 8, paddingAll: 8,
child: Column( child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [ children: [
MyContainer( MyContainer(
paddingAll: 8, paddingAll: 8,
@ -55,7 +58,7 @@ class RegularizationRequestsTab extends StatelessWidget {
Avatar( Avatar(
firstName: employee.firstName, firstName: employee.firstName,
lastName: employee.lastName, lastName: employee.lastName,
size: 35, size: 31,
), ),
MySpacing.width(16), MySpacing.width(16),
Expanded( Expanded(
@ -141,13 +144,15 @@ class RegularizationRequestsTab extends StatelessWidget {
], ],
), ),
), ),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
], ],
), );
}),
), ),
); );
}, }),
],
); );
});
} }
} }

View File

@ -4,6 +4,7 @@ import 'package:on_field_work/controller/attendance/attendance_screen_controller
import 'package:on_field_work/helpers/utils/date_time_utils.dart'; import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart'; import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart'; import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_container.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.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/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
@ -21,28 +22,17 @@ class TodaysAttendanceTab extends StatelessWidget {
final isLoading = controller.isLoadingEmployees.value; final isLoading = controller.isLoadingEmployees.value;
final employees = controller.filteredEmployees; final employees = controller.filteredEmployees;
if (isLoading) { return Column(
return SkeletonLoaders.employeeListSkeletonLoader(); crossAxisAlignment: CrossAxisAlignment.start,
} children: [
Padding(
if (employees.isEmpty) { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text("No Employees Assigned"),
),
);
}
return ListView.builder(
itemCount: employees.length + 1,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 12, top: 4),
child: Row( child: Row(
children: [ children: [
Expanded(
child:
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall( MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'), DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600, fontWeight: 600,
@ -50,68 +40,75 @@ class TodaysAttendanceTab extends StatelessWidget {
), ),
], ],
), ),
); ),
} if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
final employee = employees[index - 1]; else if (employees.isEmpty)
const SizedBox(
return Padding( height: 120,
padding: const EdgeInsets.only(bottom: 8), child: Center(child: Text("No Employees Assigned")))
child: MyCard.bordered( else
paddingAll: 10, MyCard.bordered(
paddingAll: 8,
child: Column( child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 5,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- 1. Employee Info Row (Avatar, Name, Designation ONLY) ---
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Avatar
Avatar( Avatar(
firstName: employee.firstName, firstName: employee.firstName,
lastName: employee.lastName, lastName: employee.lastName,
size: 30, size: 31),
), MySpacing.width(16),
MySpacing.width(10),
// Employee Details (Expanded to use remaining space)
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
MyText.titleSmall(employee.name, Wrap(
fontWeight: 600, overflow: TextOverflow.ellipsis), spacing: 6,
MyText.labelSmall( children: [
employee.designation, MyText.bodyMedium(employee.name,
fontWeight: 500, fontWeight: 600),
color: Colors.grey[600], MyText.bodySmall(
overflow: TextOverflow.ellipsis, '(${employee.designation})',
), fontWeight: 600,
color: Colors.grey[700]),
], ],
), ),
), MySpacing.height(8),
if (employee.checkIn != null ||
// Status Text (Added back for context) employee.checkOut != null)
if (employee.checkIn == null) Row(
MyText.bodySmall( children: [
'Check In Pending', if (employee.checkIn != null)
fontWeight: 600, Row(
color: Colors.red, children: [
) const Icon(
else if (employee.checkOut == null) Icons.arrow_circle_right,
MyText.bodySmall( size: 16,
'Checked In', color: Colors.green),
fontWeight: 600, MySpacing.width(4),
color: Colors.green, Text(DateTimeUtils.formatDate(
), employee.checkIn!,
'hh:mm a')),
],
),
if (employee.checkOut != null) ...[
MySpacing.width(16),
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(
employee.checkOut!, 'hh:mm a')),
],
], ],
), ),
// --- Separator before buttons ---
MySpacing.height(12), MySpacing.height(12),
// --- 2. Action Buttons Row (Below main info) ---
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -131,8 +128,17 @@ class TodaysAttendanceTab extends StatelessWidget {
], ],
), ),
), ),
],
),
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
); );
}, }),
),
),
],
); );
}); });
} }

View File

@ -8,7 +8,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/view/auth/request_demo_bottom_sheet.dart'; import 'package:on_field_work/view/auth/request_demo_bottom_sheet.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/wave_background.dart'; import 'package:on_field_work/helpers/widgets/wave_background.dart';
import 'package:package_info_plus/package_info_plus.dart';
enum LoginOption { email, otp } enum LoginOption { email, otp }
@ -32,8 +31,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
late final Animation<double> _logoAnimation; late final Animation<double> _logoAnimation;
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
String _appVersion = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -46,15 +43,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
); );
_controller.forward(); _controller.forward();
_fetchAppVersion();
}
Future<void> _fetchAppVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
});
} }
@override @override
@ -154,11 +142,8 @@ class _WelcomeScreenState extends State<WelcomeScreen>
option: null, option: null,
), ),
const SizedBox(height: 36), const SizedBox(height: 36),
// Dynamic App Version
if (_appVersion.isNotEmpty)
MyText( MyText(
'App version $_appVersion', 'App version 1.0.0',
color: Colors.grey, color: Colors.grey,
fontSize: 12, fontSize: 12,
), ),

View File

@ -1,25 +1,22 @@
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/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/project_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/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/utils/permission_constants.dart'; import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/project_progress_chart.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/collection_dashboard_card.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.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/dashbaord/dashboard_overview_widgets.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});
@ -31,8 +28,6 @@ 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 AttendanceController attendanceController =
Get.put(AttendanceController());
final DynamicMenuController menuController = Get.put(DynamicMenuController()); final DynamicMenuController menuController = Get.put(DynamicMenuController());
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.find<ProjectController>();
@ -46,212 +41,86 @@ 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) { if (mounted) setState(() {});
setState(() {});
}
} }
// --------------------------------------------------------------------------- @override
// Helpers Widget build(BuildContext context) {
// --------------------------------------------------------------------------- return Layout(
child: SingleChildScrollView(
Widget _cardWrapper({required Widget child}) { padding: const EdgeInsets.all(10),
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: child,
);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
);
}
// ---------------------------------------------------------------------------
// Quick Actions
// ---------------------------------------------------------------------------
Widget _quickActions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Quick Action'),
Obx(() {
if (dashboardController.isLoadingEmployees.value) {
// Show loading skeleton
return SkeletonLoaders.attendanceQuickCardSkeleton();
}
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),
),
);
}
// Actual employee quick action card
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _buildDashboardCards(),
children: [ MySpacing.height(24),
Avatar( _buildProjectSelector(),
firstName: employee.firstName, MySpacing.height(24),
lastName: employee.lastName, _buildAttendanceChartSection(),
size: 30, MySpacing.height(12),
MySpacing.height(24),
_buildProjectProgressChartSection(),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
), ),
MySpacing.width(10), MySpacing.height(24),
Expanded( SizedBox(
child: Column( width: double.infinity,
crossAxisAlignment: CrossAxisAlignment.start, child: DashboardOverviewWidgets.tasksOverview(),
children: [
MyText.titleSmall(
employee.name,
fontWeight: 600,
color: Colors.white,
),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.white70,
), ),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
], ],
), ),
), ),
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,
),
],
],
),
],
),
);
}),
],
); );
} }
// --------------------------------------------------------------------------- /// ---------------- Dynamic Dashboard Cards ----------------
// Dashboard Modules Widget _buildDashboardCards() {
// ---------------------------------------------------------------------------
Widget _dashboardModules() {
return Obx(() { return Obx(() {
if (menuController.isLoading.value) { if (menuController.isLoading.value) {
return SkeletonLoaders.dashboardCardsSkeleton( return SkeletonLoaders.dashboardCardsSkeleton();
maxWidth: MediaQuery.of(context).size.width, }
if (menuController.hasError.value || menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
); );
} }
final bool projectSelected = projectController.selectedProject != null; final projectSelected = projectController.selectedProject != null;
// these are String constants from permission_constants.dart // Define dashboard card meta with order
final List<String> cardOrder = [ final List<String> cardOrder = [
MenuItems.attendance, MenuItems.attendance,
MenuItems.employees, MenuItems.employees,
MenuItems.dailyTaskPlanning,
MenuItems.dailyProgressReport,
MenuItems.directory, MenuItems.directory,
MenuItems.finance, MenuItems.finance,
MenuItems.documents, MenuItems.documents,
MenuItems.serviceProjects, MenuItems.serviceProjects
MenuItems.infraProjects,
]; ];
final Map<String, _DashboardCardMeta> meta = { final Map<String, _DashboardCardMeta> cardMeta = {
MenuItems.attendance: MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success), _DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees: MenuItems.employees:
_DashboardCardMeta(LucideIcons.users, contentTheme.warning), _DashboardCardMeta(LucideIcons.users, contentTheme.warning),
MenuItems.dailyTaskPlanning:
_DashboardCardMeta(LucideIcons.logs, contentTheme.info),
MenuItems.dailyProgressReport:
_DashboardCardMeta(LucideIcons.list_todo, contentTheme.info),
MenuItems.directory: MenuItems.directory:
_DashboardCardMeta(LucideIcons.folder, contentTheme.info), _DashboardCardMeta(LucideIcons.folder, contentTheme.info),
MenuItems.finance: MenuItems.finance:
@ -260,272 +129,352 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
_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 Map<String, dynamic> allowed = { // Filter only available menus that exist in cardMeta
for (final m in menuController.menuItems) final allowedMenusMap = {
if (m.available && meta.containsKey(m.id)) m.id: m, for (var menu in menuController.menuItems)
if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu
}; };
final List<String> filtered = if (allowedMenusMap.isEmpty) {
cardOrder.where((id) => allowed.containsKey(id)).toList(); return const Center(
child: Text(
return Column( "No accessible modules found.",
crossAxisAlignment: CrossAxisAlignment.start, style: TextStyle(color: Colors.grey),
children: [
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(),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 15,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
itemCount: filtered.length,
itemBuilder: (context, index) {
final String id = filtered[index];
final item = allowed[id]!;
final _DashboardCardMeta cardMeta = meta[id]!;
final bool isEnabled =
item.name == 'Attendance' ? true : projectSelected;
return GestureDetector(
onTap: () {
if (!isEnabled) {
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( // Create list of cards in fixed order
decoration: BoxDecoration( final stats =
color: Colors.white, cardOrder.where((id) => allowedMenusMap.containsKey(id)).map((id) {
borderRadius: BorderRadius.circular(10), final menu = allowedMenusMap[id]!;
border: Border.all( final meta = cardMeta[id]!;
color: isEnabled return _DashboardStatItem(
? Colors.black12.withOpacity(0.06) meta.icon, menu.name, meta.color, menu.mobileLink);
: Colors.transparent, }).toList();
),
boxShadow: [ return LayoutBuilder(builder: (context, constraints) {
BoxShadow( int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
color: Colors.black.withOpacity(0.03), double cardWidth =
blurRadius: 4, (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
offset: const Offset(0, 2),
), return Wrap(
], spacing: 6,
), runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.map((stat) =>
_buildDashboardCard(stat, projectSelected, cardWidth))
.toList(),
);
});
});
}
Widget _buildDashboardCard(
_DashboardStatItem stat, bool isProjectSelected, double width) {
final isEnabled = stat.title == "Attendance" ? true : isProjectSelected;
return Opacity(
opacity: isEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isEnabled,
child: InkWell(
onTap: () => _onDashboardCardTap(stat, isEnabled),
borderRadius: BorderRadius.circular(5),
child: MyCard.bordered(
width: width,
height: 60,
paddingAll: 4,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
cardMeta.icon, padding: const EdgeInsets.all(4),
size: 20, decoration: BoxDecoration(
color: color: stat.color.withOpacity(0.1),
isEnabled ? cardMeta.color : Colors.grey.shade300, borderRadius: BorderRadius.circular(4),
), ),
const SizedBox(height: 6), child: Icon(
Padding( stat.icon,
padding: const EdgeInsets.symmetric(horizontal: 2), size: 16,
color: stat.color,
),
),
MySpacing.height(4),
Flexible(
child: Text( child: Text(
item.name, stat.title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: overflow: TextOverflow.ellipsis,
isEnabled ? FontWeight.w600 : FontWeight.w400,
color: isEnabled
? Colors.black87
: Colors.grey.shade400,
height: 1.2,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
), ),
), ),
),
),
); );
}, }
void _onDashboardCardTap(_DashboardStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this module.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
}
/// ---------------- Project Progress Chart ----------------
Widget _buildProjectProgressChartSection() {
return Obx(() {
if (dashboardController.projectChartData.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text("No project progress data available."),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
data: dashboardController.projectChartData,
),
), ),
],
); );
}); });
} }
// --------------------------------------------------------------------------- /// ---------------- Attendance Chart ----------------
// Project Selector Widget _buildAttendanceChartSection() {
// ---------------------------------------------------------------------------
Widget _projectSelector() {
return Obx(() { return Obx(() {
final bool isLoading = projectController.isLoading.value; final attendanceMenu = menuController.menuItems
final bool expanded = projectController.isProjectSelectionExpanded.value; .firstWhereOrNull((m) => m.id == MenuItems.attendance);
if (attendanceMenu == null || !attendanceMenu.available)
return const SizedBox.shrink();
final isProjectSelected = projectController.selectedProject != null;
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
),
),
),
);
});
}
/// ---------------- Project Selector (Inserted between Attendance & Project Progress)
Widget _buildProjectSelector() {
return Obx(() {
final isLoading = projectController.isLoading.value;
final isExpanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects; final projects = projectController.projects;
final String? selectedId = projectController.selectedProjectId.value; final selectedProjectId = projectController.selectedProjectId.value;
final hasProjects = projects.isNotEmpty;
if (isLoading) { if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton( return const Padding(
maxWidth: MediaQuery.of(context).size.width, padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
); );
} }
if (!hasProjects) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: const [
Icon(Icons.warning_amber_outlined, color: Colors.redAccent),
SizedBox(width: 8),
Text(
"No Project Assigned",
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
final selectedProject =
projects.firstWhereOrNull((p) => p.id == selectedProjectId);
final searchNotifier = ValueNotifier<String>("");
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionTitle('Project'),
GestureDetector( GestureDetector(
onTap: () => projectController.isProjectSelectionExpanded.toggle(), onTap: () => projectController.isProjectSelectionExpanded.toggle(),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
boxShadow: [ color: Colors.white,
boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12.withOpacity(.04), color: Colors.black12,
blurRadius: 6, blurRadius: 1,
offset: const Offset(0, 2), offset: Offset(0, 1))
),
], ],
), ),
child: Row( child: Row(
children: [ children: [
const Icon( const Icon(Icons.work_outline,
Icons.work_outline, size: 18, color: Colors.blueAccent),
color: Colors.blue, const SizedBox(width: 8),
size: 20,
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Column(
projects crossAxisAlignment: CrossAxisAlignment.start,
.firstWhereOrNull( children: [
(p) => p.id == selectedId, Text(
) selectedProject?.name ?? "Select Project",
?.name ??
'Select Project',
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 14, fontWeight: FontWeight.w700),
fontWeight: FontWeight.w600, maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2),
Text(
"Tap to switch project (${projects.length})",
style: const TextStyle(
fontSize: 12, color: Colors.black54),
),
],
), ),
), ),
Icon( Icon(
expanded isExpanded
? Icons.keyboard_arrow_up ? Icons.arrow_drop_up_outlined
: Icons.keyboard_arrow_down, : Icons.arrow_drop_down_outlined,
size: 26, color: Colors.black,
color: Colors.black54,
), ),
], ],
), ),
), ),
), ),
if (expanded) _projectDropdownList(projects, selectedId), if (isExpanded)
], ValueListenableBuilder<String>(
); valueListenable: searchNotifier,
}); builder: (context, query, _) {
} final lowerQuery = query.toLowerCase();
final filteredProjects = lowerQuery.isEmpty
? projects
: projects
.where((p) => p.name.toLowerCase().contains(lowerQuery))
.toList();
Widget _projectDropdownList(List projects, String? selectedId) {
return Container( return Container(
margin: const EdgeInsets.only(top: 10), margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.2)), border: Border.all(color: Colors.grey.withOpacity(0.12)),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12.withOpacity(.07), color: Colors.black12,
blurRadius: 10, blurRadius: 4,
offset: const Offset(0, 3), offset: Offset(0, 2))
),
], ],
), ),
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.33, maxHeight: MediaQuery.of(context).size.height * 0.35),
),
child: Column( child: Column(
children: [ children: [
TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search project...',
isDense: true, isDense: true,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search, size: 18),
hintText: "Search project",
hintStyle: const TextStyle(fontSize: 13),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 2),
), ),
onChanged: (value) => searchNotifier.value = value,
), ),
const SizedBox(height: 8),
if (filteredProjects.isEmpty)
const Expanded(
child: Center(
child: Text("No projects found",
style: TextStyle(
fontSize: 13, color: Colors.black54)),
), ),
const SizedBox(height: 10), )
else
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: projects.length, shrinkWrap: true,
itemBuilder: (_, index) { itemCount: filteredProjects.length,
final project = projects[index]; itemBuilder: (context, index) {
final project = filteredProjects[index];
final isSelected =
project.id == selectedProjectId;
return RadioListTile<String>( return RadioListTile<String>(
dense: true,
value: project.id, value: project.id,
groupValue: selectedId, groupValue: selectedProjectId,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
projectController.updateSelectedProject(value); projectController
projectController.isProjectSelectionExpanded.value = .updateSelectedProject(value);
false; projectController.isProjectSelectionExpanded
.value = false;
} }
}, },
title: Text(project.name), title: Text(
project.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? Colors.blueAccent
: Colors.black87,
),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 0),
activeColor: Colors.blueAccent,
tileColor: isSelected
? Colors.blueAccent.withOpacity(0.06)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
visualDensity:
const VisualDensity(vertical: -4),
); );
}, },
), ),
@ -533,55 +482,26 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
], ],
), ),
); );
},
),
],
);
});
} }
// --------------------------------------------------------------------------- }
// Build
// ---------------------------------------------------------------------------
@override /// ---------------- Dashboard Card Models ----------------
Widget build(BuildContext context) { class _DashboardStatItem {
return Scaffold( final IconData icon;
backgroundColor: const Color(0xfff5f6fa), final String title;
body: Layout( final Color color;
child: SingleChildScrollView( final String route;
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column( _DashboardStatItem(this.icon, this.title, this.color, this.route);
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_projectSelector(),
MySpacing.height(20),
_quickActions(),
MySpacing.height(20),
_dashboardModules(),
MySpacing.height(20),
_sectionTitle('Reports & Analytics'),
CompactPurchaseInvoiceDashboard(),
MySpacing.height(20),
CollectionsHealthWidget(),
MySpacing.height(20),
_cardWrapper(
child: ExpenseTypeReportChart(),
),
_cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(20),
],
),
),
),
);
}
} }
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

@ -13,7 +13,6 @@ import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.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/custom_app_bar.dart';
class ContactDetailScreen extends StatefulWidget { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
@ -24,21 +23,18 @@ class ContactDetailScreen extends StatefulWidget {
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> class _ContactDetailScreenState extends State<ContactDetailScreen>
with SingleTickerProviderStateMixin, UIMixin { with UIMixin {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
late Rx<ContactModel> contactRx; late Rx<ContactModel> contactRx;
late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
directoryController = Get.find<DirectoryController>(); directoryController = Get.find<DirectoryController>();
projectController = Get.put(ProjectController()); projectController = Get.find<ProjectController>();
contactRx = widget.contact.obs; contactRx = widget.contact.obs;
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await directoryController.fetchCommentsForContact(contactRx.value.id, await directoryController.fetchCommentsForContact(contactRx.value.id,
active: true); active: true);
@ -53,54 +49,61 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
}); });
} }
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary; return DefaultTabController(
length: 2,
return Scaffold( child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildMainAppBar(),
// AppBar is outside SafeArea (correct)
appBar: CustomAppBar(
title: 'Contact Profile',
backgroundColor: appBarColor,
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
),
// Only the content is wrapped inside SafeArea
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ************ GRADIENT + SUBHEADER + TABBAR ************ Obx(() => _buildSubHeader(contactRx.value)),
Container( const Divider(height: 1, thickness: 0.5, color: Colors.grey),
width: double.infinity, Expanded(
padding: const EdgeInsets.only(bottom: 8), child: TabBarView(children: [
decoration: BoxDecoration( Obx(() => _buildDetailsTab(contactRx.value)),
gradient: LinearGradient( _buildCommentsTab(),
begin: Alignment.topCenter, ]),
end: Alignment.bottomCenter, ),
colors: [
contentTheme.primary,
contentTheme.primary.withOpacity(0),
], ],
), ),
), ),
child: Obx(() => _buildSubHeader(contactRx.value)),
), ),
);
}
// ************ TAB CONTENT ************ PreferredSizeWidget _buildMainAppBar() {
Expanded( return AppBar(
child: TabBarView( backgroundColor: const Color(0xFFF5F5F5),
controller: _tabController, elevation: 0.2,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Obx(() => _buildDetailsTab(contactRx.value)), IconButton(
_buildCommentsTab(), icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (p) {
return ProjectLabel(p.selectedProject?.name);
}),
], ],
), ),
), ),
@ -115,10 +118,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
final lastName = final lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
final Color primaryColor = contentTheme.primary; return Padding(
return Container(
color: Colors.transparent,
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -137,53 +137,20 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
], ],
), ),
]), ]),
MySpacing.height(12), TabBar(
// === MODERN PILL-SHAPED TABBAR === labelColor: Colors.black,
Container( unselectedLabelColor: Colors.grey,
height: 48, indicatorColor: contentTheme.primary,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(24),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding:
const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
labelColor: primaryColor,
unselectedLabelColor: Colors.grey.shade600,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
tabs: const [ tabs: const [
Tab(text: "Details"), Tab(text: "Details"),
Tab(text: "Notes"), Tab(text: "Notes"),
], ],
dividerColor: Colors.transparent,
),
), ),
], ],
), ),
); );
} }
// --- DETAILS TAB ---
Widget _buildDetailsTab(ContactModel contact) { Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", "); final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds final bucketNames = contact.bucketIds
@ -261,8 +228,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(Icons.business, "Organization", contact.organization),
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
_infoCard("Meta Info", [ _infoCard("Meta Info", [
@ -315,7 +281,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
); );
} }
// --- COMMENTS TAB ---
Widget _buildCommentsTab() { Widget _buildCommentsTab() {
return Obx(() { return Obx(() {
final contactId = contactRx.value.id; final contactId = contactRx.value.id;
@ -657,3 +622,25 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
); );
} }
} }
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -3,13 +3,12 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/directory/directory_controller.dart'; import 'package:on_field_work/controller/directory/directory_controller.dart';
import 'package:on_field_work/controller/directory/notes_controller.dart'; import 'package:on_field_work/controller/directory/notes_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.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/view/directory/directory_view.dart'; import 'package:on_field_work/view/directory/directory_view.dart';
import 'package:on_field_work/view/directory/notes_view.dart'; import 'package:on_field_work/view/directory/notes_view.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class DirectoryMainScreen extends StatefulWidget { class DirectoryMainScreen extends StatefulWidget {
const DirectoryMainScreen({super.key}); const DirectoryMainScreen({super.key});
@ -19,7 +18,7 @@ class DirectoryMainScreen extends StatefulWidget {
} }
class _DirectoryMainScreenState extends State<DirectoryMainScreen> class _DirectoryMainScreenState extends State<DirectoryMainScreen>
with SingleTickerProviderStateMixin, UIMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
final DirectoryController controller = Get.put(DirectoryController()); final DirectoryController controller = Get.put(DirectoryController());
@ -39,46 +38,97 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary; return OrientationBuilder(
builder: (context, orientation) {
final bool isLandscape = orientation == Orientation.landscape;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: PreferredSize(
title: "Directory", preferredSize: Size.fromHeight(
onBackPressed: () => Get.offNamed('/dashboard'), isLandscape ? 55 : 72, // Responsive height
backgroundColor: appBarColor,
), ),
body: Stack( child: SafeArea(
bottom: false,
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [ children: [
// === TOP GRADIENT === IconButton(
Container( icon: const Icon(Icons.arrow_back_ios_new,
height: 50, color: Colors.black, size: 20),
decoration: BoxDecoration( onPressed: () => Get.offNamed('/dashboard'),
gradient: LinearGradient( ),
begin: Alignment.topCenter, MySpacing.width(8),
end: Alignment.bottomCenter,
colors: [ /// FIX: Flexible to prevent overflow in landscape
appBarColor, Flexible(
appBarColor.withOpacity(0.0), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Directory',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
], ],
), ),
), ),
), ),
),
),
SafeArea( /// MAIN CONTENT
top: false, body: SafeArea(
bottom: true, bottom: true,
child: Column( child: Column(
children: [ children: [
PillTabBar( Container(
color: Colors.white,
child: TabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Directory", "Notes"], labelColor: Colors.black,
selectedColor: contentTheme.primary, unselectedLabelColor: Colors.grey,
unselectedColor: Colors.grey.shade600, indicatorColor: Colors.red,
indicatorColor: contentTheme.primary, tabs: const [
Tab(text: "Directory"),
Tab(text: "Notes"),
],
),
), ),
// === TABBAR VIEW ===
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -91,8 +141,8 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
], ],
), ),
), ),
], );
), },
); );
} }
} }

View File

@ -13,7 +13,6 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/document/document_edit_bottom_sheet.dart'; import 'package:on_field_work/model/document/document_edit_bottom_sheet.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final String documentId; final String documentId;
@ -24,7 +23,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState(); State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
} }
class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin { class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
@ -50,37 +49,15 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar( appBar: CustomAppBar(
title: 'Document Details', title: 'Document Details',
backgroundColor: appBarColor,
onBackPressed: () { onBackPressed: () {
Get.back(); Get.back();
}, },
), ),
body: Stack( body: Obx(() {
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader(); return SkeletonLoaders.documentDetailsSkeletonLoader();
} }
@ -107,11 +84,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin
children: [ children: [
_buildDetailsCard(doc), _buildDetailsCard(doc),
const SizedBox(height: 20), const SizedBox(height: 20),
MyText.titleMedium( MyText.titleMedium("Versions",
"Versions", fontWeight: 700, color: Colors.black),
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildVersionsSection(), _buildVersionsSection(),
], ],
@ -119,9 +93,6 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin
), ),
); );
}), }),
),
],
),
); );
} }

View File

@ -115,6 +115,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_fabAnimationController.dispose(); _fabAnimationController.dispose();
docController.searchController.dispose();
docController.documents.clear(); docController.documents.clear();
super.dispose(); super.dispose();
} }
@ -136,7 +137,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
], ],
), ),
child: TextField( child: TextField(
controller: docController.searchController, // keep GetX controller controller: docController.searchController,
onChanged: (value) { onChanged: (value) {
docController.searchQuery.value = value; docController.searchQuery.value = value;
docController.fetchDocuments( docController.fetchDocuments(
@ -427,21 +428,14 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
} }
Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) { Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) {
final uploadDate = doc.uploadedAt != null final uploadDate =
? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal()) DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
: '-'; final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal());
final uploadTime = doc.uploadedAt != null final uploader = doc.uploadedBy.firstName.isNotEmpty
? DateFormat("hh:mm a").format(doc.uploadedAt!.toLocal()) ? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
: '';
final uploader =
(doc.uploadedBy != null && doc.uploadedBy!.firstName.isNotEmpty)
? "${doc.uploadedBy!.firstName} ${doc.uploadedBy!.lastName ?? ''}"
.trim()
: "You"; : "You";
final iconColor = final iconColor = _getDocumentTypeColor(doc.documentType.name);
_getDocumentTypeColor(doc.documentType?.name ?? 'unknown');
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -485,10 +479,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon( child: Icon(
_getDocumentIcon(doc.documentType?.name ?? 'unknown'), _getDocumentIcon(doc.documentType.name),
color: iconColor, color: iconColor,
size: 24, size: 24,
)), ),
),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
@ -502,7 +497,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: MyText.labelSmall( child: MyText.labelSmall(
doc.documentType?.name ?? 'Unknown', doc.documentType.name,
fontWeight: 600, fontWeight: 600,
color: iconColor, color: iconColor,
letterSpacing: 0.3, letterSpacing: 0.3,
@ -804,42 +799,38 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
} }
Widget _buildBody() { Widget _buildBody() {
// Non-reactive widgets return Obx(() {
final searchBar = _buildSearchBar(); // Check permissions
final filterChips = _buildFilterChips(); if (permissionController.permissions.isEmpty) {
final statusBanner = _buildStatusBanner(); return _buildLoadingIndicator();
}
return Column(
children: [
searchBar,
filterChips,
statusBanner,
// Only the list is reactive
Expanded(
child: Obx(() {
if (!permissionController.hasPermission(Permissions.viewDocument)) { if (!permissionController.hasPermission(Permissions.viewDocument)) {
return _buildPermissionDenied(); return _buildPermissionDenied();
} }
// Show skeleton loader
if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: SkeletonLoaders.documentSkeletonLoader(),
);
}
final docs = docController.documents; final docs = docController.documents;
// Skeleton loader return Column(
if (docController.isLoading.value && docs.isEmpty) { children: [
return SkeletonLoaders.documentSkeletonLoader(); _buildSearchBar(),
} _buildFilterChips(),
_buildStatusBanner(),
// Empty state Expanded(
if (!docController.isLoading.value && docs.isEmpty) { child: MyRefreshIndicator(
return _buildEmptyState();
}
// List of documents
return MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
final combinedFilter = { final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(), 'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': docController.selectedCategory.toList(), 'documentCategoryIds':
docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(), 'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(), 'documentTagIds': docController.selectedTag.toList(),
}; };
@ -851,7 +842,17 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
reset: true, reset: true,
); );
}, },
child: ListView.builder( child: docs.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _buildEmptyState(),
),
],
)
: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100, top: 8), padding: const EdgeInsets.only(bottom: 100, top: 8),
@ -862,7 +863,8 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
if (docController.isLoading.value) { if (docController.isLoading.value) {
return _buildLoadingIndicator(); return _buildLoadingIndicator();
} }
if (!docController.hasMore.value && docs.isNotEmpty) { if (!docController.hasMore.value &&
docs.isNotEmpty) {
return _buildNoMoreIndicator(); return _buildNoMoreIndicator();
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -870,26 +872,23 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
} }
final doc = docs[index]; final doc = docs[index];
final currentDate = doc.uploadedAt != null final currentDate = DateFormat("dd MMM yyyy")
? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal()) .format(doc.uploadedAt.toLocal());
: '';
final prevDate = index > 0 final prevDate = index > 0
? (docs[index - 1].uploadedAt != null
? DateFormat("dd MMM yyyy") ? DateFormat("dd MMM yyyy")
.format(docs[index - 1].uploadedAt!.toLocal()) .format(docs[index - 1].uploadedAt.toLocal())
: '')
: null; : null;
final showDateHeader = currentDate != prevDate; final showDateHeader = currentDate != prevDate;
return _buildDocumentCard(doc, showDateHeader); return _buildDocumentCard(doc, showDateHeader);
}, },
), ),
); ),
}),
), ),
], ],
); );
} });
}
Widget _buildFAB() { Widget _buildFAB() {
return Obx(() { return Obx(() {

View File

@ -3,7 +3,6 @@ import 'package:get/get.dart';
import 'package:on_field_work/view/employees/employee_detail_screen.dart'; import 'package:on_field_work/view/employees/employee_detail_screen.dart';
import 'package:on_field_work/view/document/user_document_screen.dart'; import 'package:on_field_work/view/document/user_document_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class EmployeeProfilePage extends StatefulWidget { class EmployeeProfilePage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -15,15 +14,12 @@ class EmployeeProfilePage extends StatefulWidget {
} }
class _EmployeeProfilePageState extends State<EmployeeProfilePage> class _EmployeeProfilePageState extends State<EmployeeProfilePage>
with SingleTickerProviderStateMixin, UIMixin { with SingleTickerProviderStateMixin {
// We no longer need to listen to the TabController for setState,
// as the TabBar handles its own state updates via the controller.
late TabController _tabController; late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize TabController with 2 tabs
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
} }
@ -33,103 +29,43 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
super.dispose(); super.dispose();
} }
// --- No need for _buildSegmentedButton function anymore ---
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Accessing theme colors for consistency
final Color appBarColor = contentTheme.primary;
final Color primaryColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Employee Profile", title: "Employee Profile",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
backgroundColor: appBarColor,
), ),
body: Stack( body: Column(
children: [ children: [
// === Gradient at the top behind AppBar + Toggle === // ---------------- TabBar outside AppBar ----------------
// This container ensures the background color transitions nicely
Container( Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// === Main Content Area ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
// 🛑 NEW: The Modern TabBar Implementation 🛑
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
height: 48, // Define a specific height for the TabBar container
decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
// Style the indicator as a subtle pill/chip labelColor: Colors.black,
indicator: BoxDecoration( unselectedLabelColor: Colors.grey,
color: primaryColor.withOpacity(0.1), // Light background color for the selection indicatorColor: Colors.red,
borderRadius: BorderRadius.circular(24.0),
),
indicatorSize: TabBarIndicatorSize.tab,
// The padding is used to slightly shrink the indicator area
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
// Text styling
labelColor: primaryColor, // Selected text color is primary
unselectedLabelColor: Colors.grey.shade600, // Unselected text color is darker grey
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
// Tabs (No custom widget needed, just use the built-in Tab)
tabs: const [ tabs: const [
Tab(text: "Details"), Tab(text: "Details"),
Tab(text: "Documents"), Tab(text: "Documents"),
], ],
// Setting this to zero removes the default underline
dividerColor: Colors.transparent,
),
), ),
), ),
// 🛑 TabBarView (The Content) 🛑 // ---------------- TabBarView ----------------
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
// Details Tab
EmployeeDetailPage( EmployeeDetailPage(
employeeId: widget.employeeId, employeeId: widget.employeeId,
fromProfile: true, fromProfile: true,
), ),
// Documents Tab
UserDocumentsPage( UserDocumentsPage(
entityId: widget.employeeId, entityId: widget.employeeId,
isEmployee: true, isEmployee: true,
@ -139,9 +75,6 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
), ),
], ],
), ),
),
],
),
); );
} }
} }

View File

@ -17,7 +17,6 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart'; import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/view/employees/manage_reporting_bottom_sheet.dart'; import 'package:on_field_work/view/employees/manage_reporting_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -114,36 +113,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: _buildAppBar(),
title: "Employees", floatingActionButton: _buildFloatingActionButton(),
backgroundColor: appBarColor, body: SafeArea(
projectName: Get.find<ProjectController>().selectedProject?.name ??
'Select Project',
onBackPressed: () => Get.offNamed('/dashboard'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: GetBuilder<EmployeesScreenController>( child: GetBuilder<EmployeesScreenController>(
init: _employeeController, init: _employeeController,
tag: 'employee_screen_controller', tag: 'employee_screen_controller',
@ -174,9 +148,63 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
}, },
), ),
), ),
);
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Employees',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
], ],
), ),
floatingActionButton: _buildFloatingActionButton(), ),
],
),
),
),
); );
} }

View File

@ -14,7 +14,7 @@ import 'package:on_field_work/controller/expense/add_expense_controller.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.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/helpers/widgets/my_text.dart';
@ -82,38 +82,13 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
appBar: CustomAppBar( appBar: _AppBar(projectController: projectController),
title: "Expense Details", body: SafeArea(
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton(); if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display.")); return Center(child: MyText.bodyMedium("No data to display."));
@ -123,10 +98,8 @@ Widget build(BuildContext context) {
_checkPermissionToSubmit(expense); _checkPermissionToSubmit(expense);
}); });
final statusColor = getExpenseStatusColor( final statusColor = getExpenseStatusColor(expense.status.name,
expense.status.name, colorCode: expense.status.color);
colorCode: expense.status.color,
);
final formattedAmount = formatExpenseAmount(expense.amount); final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator( return MyRefreshIndicator(
@ -135,8 +108,7 @@ Widget build(BuildContext context) {
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
),
child: Center( child: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
@ -150,21 +122,21 @@ Widget build(BuildContext context) {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header & Status // ---------------- Header & Status ----------------
_InvoiceHeader(expense: expense), _InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Activity Logs // ---------------- Activity Logs ----------------
InvoiceLogs(logs: expense.expenseLogs), InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// ---------------- Amount & Summary ----------------
// Amount & Summary
Row( Row(
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Amount', fontWeight: 600), MyText.bodyMedium('Amount',
fontWeight: 600),
const SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodyLarge( MyText.bodyLarge(
formattedAmount, formattedAmount,
@ -174,6 +146,7 @@ Widget build(BuildContext context) {
], ],
), ),
const Spacer(), const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved) if (expense.preApproved)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -192,19 +165,19 @@ Widget build(BuildContext context) {
), ),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Parties // ---------------- Parties ----------------
_InvoicePartiesTable(expense: expense), _InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Expense Details // ---------------- Expense Details ----------------
_InvoiceDetailsTable(expense: expense), _InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Documents // ---------------- Documents ----------------
_InvoiceDocuments(documents: expense.documents), _InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Totals // ---------------- Totals ----------------
_InvoiceTotals( _InvoiceTotals(
expense: expense, expense: expense,
formattedAmount: formattedAmount, formattedAmount: formattedAmount,
@ -216,18 +189,15 @@ Widget build(BuildContext context) {
), ),
), ),
), ),
), ));
);
}), }),
), ),
],
),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton(); if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return const SizedBox.shrink(); return Center(child: MyText.bodyMedium("No data to display."));
} }
if (!_checkedPermission) { if (!_checkedPermission) {
@ -267,8 +237,10 @@ Widget build(BuildContext context) {
}) })
.toList(), .toList(),
}; };
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController()); final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData(); await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData); addCtrl.populateFieldsForEdit(editData);
@ -307,7 +279,22 @@ Widget build(BuildContext context) {
final isCreatedByCurrentUser = final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id; employeeInfo?.id == expense.createdBy.id;
if (isSubmitStatus) return isCreatedByCurrentUser; logSafe(
'🔐 Permission Logic:\n'
'🔸 Status: ${next.name}\n'
'🔸 Status ID: ${next.id}\n'
'🔸 Parsed Permissions: $parsedPermissions\n'
'🔸 Is Submit: $isSubmitStatus\n'
'🔸 Created By Current User: $isCreatedByCurrentUser',
level: LogLevel.debug,
);
if (isSubmitStatus) {
// Submit can be done ONLY by the creator
return isCreatedByCurrentUser;
}
// All other statuses - check permission normally
return permissionController.hasAnyPermission(parsedPermissions); return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) { }).map((next) {
return _statusButton(context, controller, expense, next); return _statusButton(context, controller, expense, next);
@ -317,8 +304,7 @@ Widget build(BuildContext context) {
); );
}), }),
); );
} }
Widget _statusButton(BuildContext context, ExpenseDetailController controller, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {
@ -463,6 +449,64 @@ Widget build(BuildContext context) {
} }
} }
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const _AppBar({required this.projectController});
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
backgroundColor: Colors.white,
title: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expense Details',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _InvoiceHeader extends StatelessWidget { class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense; final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense}); const _InvoiceHeader({required this.expense});

View File

@ -12,8 +12,6 @@ import 'package:on_field_work/helpers/widgets/expense/expense_main_components.da
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.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/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class ExpenseMainScreen extends StatefulWidget { class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key}); const ExpenseMainScreen({super.key});
@ -89,64 +87,36 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: ExpenseAppBar(projectController: projectController),
title: "Expense & Reimbursement", body: Column(
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'),
),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [ children: [
// ---------------- TabBar ----------------
Container( Container(
height: 80, color: Colors.white,
decoration: BoxDecoration( child: TabBar(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Current Month", "History"], labelColor: Colors.black,
selectedColor: contentTheme.primary, unselectedLabelColor: Colors.grey,
unselectedColor: Colors.grey.shade600, indicatorColor: Colors.red,
indicatorColor: contentTheme.primary, tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
), ),
// CONTENT AREA // ---------------- Gray background for rest ----------------
Expanded( Expanded(
child: Container( child: Container(
color: Colors.transparent, color: Colors.grey[100],
child: Column( child: Column(
children: [ children: [
// SEARCH & FILTER // ---------------- Search ----------------
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 0), padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: SearchAndFilter( child: SearchAndFilter(
controller: searchController, controller: searchController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@ -155,7 +125,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
), ),
), ),
// TABBAR VIEW // ---------------- TabBarView ----------------
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -171,12 +141,11 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
), ),
], ],
), ),
),
],
),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty) // Show loader or hide FAB while permissions are loading
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
final canUpload = final canUpload =
permissionController.hasPermission(Permissions.expenseUpload); permissionController.hasPermission(Permissions.expenseUpload);

View File

@ -3,9 +3,10 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/finance/advance_payment_controller.dart'; import 'package:on_field_work/controller/finance/advance_payment_controller.dart';
import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.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_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class AdvancePaymentScreen extends StatefulWidget { class AdvancePaymentScreen extends StatefulWidget {
const AdvancePaymentScreen({super.key}); const AdvancePaymentScreen({super.key});
@ -48,35 +49,12 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: _buildAppBar(),
title: "Advance Payments",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// ===== MAIN CONTENT ===== // SafeArea added so nothing hides under system navigation buttons
SafeArea( body: SafeArea(
top: false,
bottom: true, bottom: true,
child: GestureDetector( child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
@ -88,66 +66,131 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
} }
}, },
color: Colors.white, color: Colors.white,
backgroundColor: appBarColor, backgroundColor: contentTheme.primary,
strokeWidth: 2.5, strokeWidth: 2.5,
displacement: 60, displacement: 60,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
child: Padding( child: Padding(
// Extra bottom padding so content does NOT go under 3-button navbar
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20, bottom: MediaQuery.of(context).padding.bottom + 20,
), ),
child: Column( child: Column(
children: [ children: [
// ===== SEARCH BAR FLOATING OVER GRADIENT ===== _buildSearchBar(),
Padding( _buildEmployeeDropdown(context),
padding: const EdgeInsets.symmetric( _buildTopBalance(),
horizontal: 12, vertical: 8), _buildPaymentList(),
],
),
),
),
),
),
),
);
}
// ---------------- AppBar ----------------
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
// ---------------- Search ----------------
Widget _buildSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: SizedBox( child: SizedBox(
height: 38, height: 38,
child: TextField( child: TextField(
controller: _searchCtrl, controller: _searchCtrl,
focusNode: _searchFocus, focusNode: _searchFocus,
onChanged: (v) => onChanged: (v) => controller.searchQuery.value = v.trim(),
controller.searchQuery.value = v.trim(),
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric( contentPadding:
horizontal: 12, vertical: 0), const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon: const Icon(Icons.search, prefixIcon:
size: 20, color: Colors.grey), const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search Employee...', hintText: 'Search Employee...',
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide( borderSide:
color: Colors.grey.shade300, width: 1), BorderSide(color: Colors.grey.shade300, width: 1),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide( borderSide:
color: Colors.grey.shade300, width: 1), BorderSide(color: Colors.grey.shade300, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide( borderSide:
color: appBarColor, width: 1.5), BorderSide(color: contentTheme.primary, width: 1.5),
),
),
),
),
),
// ===== EMPLOYEE DROPDOWN =====
_buildEmployeeDropdown(context),
// ===== TOP BALANCE =====
_buildTopBalance(),
// ===== PAYMENTS LIST =====
_buildPaymentList(),
],
),
), ),
), ),
), ),

View File

@ -6,14 +6,13 @@ import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dar
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_card.dart'; import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.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/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/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart'; import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class FinanceScreen extends StatefulWidget { class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@ -53,54 +52,70 @@ class _FinanceScreenState extends State<FinanceScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF8F9FA), backgroundColor: const Color(0xFFF8F9FA),
appBar: CustomAppBar( appBar: PreferredSize(
title: "Finance", preferredSize: const Size.fromHeight(72),
onBackPressed: () => Get.offAllNamed( '/dashboard' ), child: AppBar(
backgroundColor: appBarColor, backgroundColor: const Color(0xFFF5F5F5),
), elevation: 0.5,
body: Stack( automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Top fade under AppBar IconButton(
Container( icon: const Icon(Icons.arrow_back_ios_new,
height: 40, color: Colors.black, size: 20),
decoration: BoxDecoration( onPressed: () => Get.offNamed('/dashboard'),
gradient: LinearGradient( ),
begin: Alignment.topCenter, MySpacing.width(8),
end: Alignment.bottomCenter, Expanded(
colors: [ child: Column(
appBarColor, crossAxisAlignment: CrossAxisAlignment.start,
appBarColor.withOpacity(0.0), mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Finance',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
], ],
), ),
), ),
),
// Bottom fade (above system buttons or FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
], ],
), ),
), ),
), ),
), ),
body: SafeArea(
// Main scrollable content top: false, // keep appbar area same
SafeArea( bottom: true, // avoid system bottom buttons
top: false,
bottom: true,
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Obx(() { child: Obx(() {
@ -137,6 +152,7 @@ class _FinanceScreenState extends State<FinanceScreen>
); );
} }
// ---- IMPORTANT FIX: Add bottom safe padding ----
final double bottomInset = final double bottomInset =
MediaQuery.of(context).viewPadding.bottom; MediaQuery.of(context).viewPadding.bottom;
@ -145,7 +161,8 @@ class _FinanceScreenState extends State<FinanceScreen>
16, 16,
16, 16,
16, 16,
bottomInset + 24, bottomInset +
24, // ensures charts never go under system buttons
), ),
child: Column( child: Column(
children: [ children: [
@ -162,8 +179,6 @@ class _FinanceScreenState extends State<FinanceScreen>
}), }),
), ),
), ),
],
),
); );
} }

View File

@ -21,7 +21,6 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart'; import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart';
import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart'; import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart';
import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart'; import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class PaymentRequestDetailScreen extends StatefulWidget { class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId; final String paymentRequestId;
@ -108,33 +107,10 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: _buildAppBar(),
title: "Payment Request Details", body: SafeArea(
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// ===== MAIN CONTENT =====
SafeArea(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
controller.paymentRequest.value == null) { controller.paymentRequest.value == null) {
@ -202,8 +178,6 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
}), }),
), ),
],
),
bottomNavigationBar: _buildBottomActionBar(), bottomNavigationBar: _buildBottomActionBar(),
); );
} }
@ -321,6 +295,65 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
}); });
} }
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Request Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
);
}
} }
class PaymentRequestPermissionHelper { class PaymentRequestPermissionHelper {

View File

@ -13,8 +13,6 @@ import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class PaymentRequestMainScreen extends StatefulWidget { class PaymentRequestMainScreen extends StatefulWidget {
const PaymentRequestMainScreen({super.key}); const PaymentRequestMainScreen({super.key});
@ -98,59 +96,33 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: _buildAppBar(),
title: "Payment Requests",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
// === MAIN CONTENT === // ------------------------
SafeArea( // FIX: SafeArea prevents content from going under 3-button navbar
top: false, // ------------------------
body: SafeArea(
bottom: true, bottom: true,
child: Column( child: Column(
children: [ children: [
PillTabBar( Container(
color: Colors.white,
child: TabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Current Month", "History"], labelColor: Colors.black,
selectedColor: contentTheme.primary, unselectedLabelColor: Colors.grey,
unselectedColor: Colors.grey.shade600, indicatorColor: Colors.red,
indicatorColor: contentTheme.primary, tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
), ),
// CONTENT AREA
Expanded( Expanded(
child: Container( child: Container(
color: Colors.transparent, color: Colors.grey[100],
child: Column( child: Column(
children: [ children: [
_buildSearchBar(), _buildSearchBar(),
@ -170,8 +142,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
], ],
), ),
), ),
],
),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty) { if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@ -195,6 +166,67 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
); );
} }
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Requests',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
Widget _buildSearchBar() { Widget _buildSearchBar() {
return Padding( return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0), padding: MySpacing.fromLTRB(12, 10, 12, 0),

View File

@ -1,377 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.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/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/launcher_utils.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/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/infra_project/infra_project_screen_details_controller.dart';
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
class InfraProjectDetailsScreen extends StatefulWidget {
final String projectId;
final String? projectName;
const InfraProjectDetailsScreen({
super.key,
required this.projectId,
this.projectName,
});
@override
State<InfraProjectDetailsScreen> createState() =>
_InfraProjectDetailsScreenState();
}
class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
with SingleTickerProviderStateMixin, UIMixin {
late final TabController _tabController;
final DynamicMenuController menuController =
Get.find<DynamicMenuController>();
final List<_InfraTab> _tabs = [];
@override
void initState() {
super.initState();
_prepareTabs();
}
void _prepareTabs() {
// Profile tab is always added
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
final allowedMenu = menuController.menuItems.where((m) => m.available);
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
_tabs.add(
_InfraTab(
name: "Task Planning",
view: DailyTaskPlanningScreen(projectId: widget.projectId),
),
);
}
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
_tabs.add(
_InfraTab(
name: "Task Progress",
view: DailyProgressReportScreen(projectId: widget.projectId),
),
);
}
_tabController = TabController(length: _tabs.length, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget _buildProfileTab() {
final controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.isNotEmpty) {
return Center(child: Text(controller.errorMessage.value));
}
final data = controller.projectDetails.value;
if (data == null) {
return const Center(child: Text("No project data available"));
}
return MyRefreshIndicator(
onRefresh: controller.fetchProjectDetails,
backgroundColor: Colors.indigo,
color: Colors.white,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildHeaderCard(data),
MySpacing.height(16),
_buildProjectInfoSection(data),
if (data.promoter != null) MySpacing.height(12),
if (data.promoter != null) _buildPromoterInfo(data.promoter!),
if (data.pmc != null) MySpacing.height(12),
if (data.pmc != null) _buildPMCInfo(data.pmc!),
MySpacing.height(40),
],
),
),
);
});
}
Widget _buildHeaderCard(dynamic data) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.work_outline, size: 35),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(data.name ?? "-", fontWeight: 700),
MySpacing.height(6),
MyText.bodySmall(data.shortName ?? "-", fontWeight: 500),
],
),
),
],
),
),
);
}
Widget _buildProjectInfoSection(dynamic data) {
return _buildSectionCard(
title: 'Project Information',
titleIcon: Icons.info_outline,
children: [
_buildDetailRow(
icon: Icons.location_on_outlined,
label: 'Address',
value: data.projectAddress ?? "-"),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'Start Date',
value: data.startDate != null
? DateFormat('d/M/yyyy').format(data.startDate!)
: "-"),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'End Date',
value: data.endDate != null
? DateFormat('d/M/yyyy').format(data.endDate!)
: "-"),
_buildDetailRow(
icon: Icons.flag_outlined,
label: 'Status',
value: data.projectStatus?.status ?? "-"),
_buildDetailRow(
icon: Icons.person_outline,
label: 'Contact Person',
value: data.contactPerson ?? "-",
isActionable: true,
onTap: () {
if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!);
}
}),
],
);
}
Widget _buildPromoterInfo(dynamic promoter) {
return _buildSectionCard(
title: 'Promoter Information',
titleIcon: Icons.business_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline,
label: 'Name',
value: promoter.name ?? "-"),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: promoter.contactNumber ?? "-",
isActionable: true,
onTap: () =>
LauncherUtils.launchPhone(promoter.contactNumber ?? "")),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: promoter.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")),
],
);
}
Widget _buildPMCInfo(dynamic pmc) {
return _buildSectionCard(
title: 'PMC Information',
titleIcon: Icons.engineering_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: pmc.contactNumber ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: pmc.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")),
],
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
VoidCallback? onTap,
bool isActionable = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: InkWell(
onTap: isActionable ? onTap : null,
borderRadius: BorderRadius.circular(5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
label,
fontSize: 12,
color: Colors.grey[600],
fontWeight: 500,
),
MySpacing.height(4),
MyText.bodyMedium(
value,
fontSize: 15,
fontWeight: 500,
color: isActionable ? Colors.blueAccent : Colors.black87,
decoration: isActionable
? TextDecoration.underline
: TextDecoration.none,
),
],
),
),
],
),
),
);
}
Widget _buildSectionCard({
required String title,
required IconData titleIcon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(titleIcon, size: 20),
MySpacing.width(8),
MyText.bodyLarge(
title,
fontSize: 16,
fontWeight: 700,
color: Colors.black87,
),
],
),
MySpacing.height(8),
const Divider(),
...children,
],
),
),
);
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Infra Projects",
onBackPressed: () => Get.back(),
projectName: widget.projectName,
backgroundColor: appBarColor,
),
body: Stack(
children: [
Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [appBarColor, appBarColor.withOpacity(0)],
),
),
),
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e.name).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
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});
}

View File

@ -1,278 +0,0 @@
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/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/controller/infra_project/infra_project_screen_controller.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
class InfraProjectScreen extends StatefulWidget {
const InfraProjectScreen({super.key});
@override
State<InfraProjectScreen> createState() => _InfraProjectScreenState();
}
class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
final TextEditingController searchController = TextEditingController();
final InfraProjectController controller = Get.put(InfraProjectController());
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchProjects();
});
searchController.addListener(() {
controller.updateSearch(searchController.text);
});
}
Future<void> _refreshProjects() async {
await controller.fetchProjects();
}
// ---------------------------------------------------------------------------
// PROJECT CARD
// ---------------------------------------------------------------------------
Widget _buildProjectCard(ProjectData project) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
shadowColor: Colors.indigo.withOpacity(0.10),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () {
Get.to(() => InfraProjectDetailsScreen(projectId: project.id!, projectName: project.name));
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TOP: Name + Status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: MyText.titleMedium(
project.name ?? "-",
fontWeight: 700,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(8),
],
),
MySpacing.height(10),
if (project.shortName != null)
_buildDetailRow(
Icons.badge_outlined,
Colors.teal,
"Short Name: ${project.shortName}",
),
MySpacing.height(8),
if (project.projectAddress != null)
_buildDetailRow(
Icons.location_on_outlined,
Colors.orange,
"Address: ${project.projectAddress}",
),
MySpacing.height(8),
if (project.contactPerson != null)
_buildDetailRow(
Icons.phone,
Colors.green,
"Contact: ${project.contactPerson}",
),
MySpacing.height(12),
if (project.teamSize != null)
_buildDetailRow(
Icons.group,
Colors.indigo,
"Team Size: ${project.teamSize}",
),
],
),
),
),
);
}
Widget _buildDetailRow(IconData icon, Color color, String value) {
return Row(
children: [
Icon(icon, size: 18, color: color),
MySpacing.width(8),
Expanded(
child: MyText.bodySmall(
value,
color: Colors.grey[900],
fontWeight: 500,
fontSize: 13,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
],
);
}
// ---------------------------------------------------------------------------
// EMPTY STATE
// ---------------------------------------------------------------------------
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.work_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No matching projects found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your filters or refresh.',
color: Colors.grey,
),
],
),
);
}
// ---------------------------------------------------------------------------
// MAIN BUILD
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Infra Projects",
projectName: 'All Infra Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Stack(
children: [
// GRADIENT BACKDROP
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0),
],
),
),
),
SafeArea(
bottom: true,
child: Column(
children: [
// SEARCH BAR
Padding(
padding: MySpacing.xy(8, 8),
child: SizedBox(
height: 35,
child: TextField(
controller: searchController,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch("");
},
);
},
),
hintText: "Search projects...",
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
// LIST
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
color: Colors.white,
child: projects.isEmpty
? _buildEmptyState()
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 100),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
),
),
],
),
);
}
}

View File

@ -9,7 +9,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/images.dart'; import 'package:on_field_work/images.dart';
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart'; import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget { class Layout extends StatefulWidget {
final Widget? child; final Widget? child;
@ -21,7 +20,7 @@ class Layout extends StatefulWidget {
State<Layout> createState() => _LayoutState(); State<Layout> createState() => _LayoutState();
} }
class _LayoutState extends State<Layout> with UIMixin { class _LayoutState extends State<Layout> {
final LayoutController controller = LayoutController(); final LayoutController controller = LayoutController();
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo(); final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
@ -58,77 +57,50 @@ class _LayoutState extends State<Layout> with UIMixin {
} }
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) { Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
final primaryColor = contentTheme.primary;
return Scaffold( return Scaffold(
key: controller.scaffoldKey, key: controller.scaffoldKey,
endDrawer: const UserProfileBar(), endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton, floatingActionButton: widget.floatingActionButton,
body: Column( body: SafeArea(
children: [
// Solid primary background area
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
),
child: SafeArea(
top: false,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () {}, onTap: () {}, // project selection removed nothing to close
child: Column(
children: [
_buildHeader(context, isMobile),
Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
key: controller.scrollKey, key: controller.scrollKey,
padding: EdgeInsets.zero, padding: EdgeInsets.symmetric(
horizontal: 0, vertical: isMobile ? 16 : 32),
child: widget.child, child: widget.child,
), ),
), ),
),
),
),
], ],
)); ),
),
),
);
} }
Widget _buildHeaderContent(bool isMobile) { /// Header Section (Project selection removed)
Widget _buildHeader(BuildContext context, bool isMobile) {
final selectedTenant = TenantService.currentTenant; final selectedTenant = TenantService.currentTenant;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Container( child: Card(
margin: const EdgeInsets.only(bottom: 18), shape: RoundedRectangleBorder(
width: double.infinity, borderRadius: BorderRadius.circular(5),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
), ),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row( child: Row(
children: [ children: [
// Logo section ClipRRect(
Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Image.asset( Image.asset(
@ -137,9 +109,7 @@ class _LayoutState extends State<Layout> with UIMixin {
width: 50, width: 50,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
if (isBetaEnvironment)
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
@ -148,7 +118,7 @@ class _LayoutState extends State<Layout> with UIMixin {
horizontal: 4, vertical: 2), horizontal: 4, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.deepPurple, color: Colors.deepPurple,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white, width: 1.2), border: Border.all(color: Colors.white, width: 1.2),
), ),
child: const Text( child: const Text(
@ -163,10 +133,10 @@ class _LayoutState extends State<Layout> with UIMixin {
), ),
], ],
), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
// Titles /// Dashboard title + current organization
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -176,8 +146,11 @@ class _LayoutState extends State<Layout> with UIMixin {
fontWeight: 700, fontWeight: 700,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
color: Colors.black87,
), ),
// MyText.bodyMedium(
// "Hi, ${employeeInfo?.firstName ?? ''}",
// color: Colors.black54,
// ),
if (selectedTenant != null) if (selectedTenant != null)
MyText.bodySmall( MyText.bodySmall(
"Organization: ${selectedTenant.name}", "Organization: ${selectedTenant.name}",
@ -189,13 +162,13 @@ class _LayoutState extends State<Layout> with UIMixin {
), ),
), ),
// Menu button with red dot if MPIN missing /// Menu Button
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.menu, color: Colors.black87), icon: const Icon(Icons.menu),
onPressed: () => onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(), controller.scaffoldKey.currentState?.openEndDrawer(),
), ),
@ -214,10 +187,11 @@ class _LayoutState extends State<Layout> with UIMixin {
), ),
), ),
], ],
), )
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -247,6 +247,7 @@ class _UserProfileBarState extends State<UserProfileBar>
final tenants = tenantSwitchController.tenants; final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer(); if (tenants.isEmpty) return _noTenantContainer();
// If only one organization, don't show switch option // If only one organization, don't show switch option
if (tenants.length == 1) { if (tenants.length == 1) {
final selectedTenant = tenants.first; final selectedTenant = tenants.first;

View File

@ -14,9 +14,7 @@ import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/routes.dart'; import 'package:on_field_work/routes.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
final bool isOffline; const MyApp({super.key});
const MyApp({super.key, required this.isOffline});
Future<String> _getInitialRoute() async { Future<String> _getInitialRoute() async {
try { try {
@ -42,62 +40,6 @@ class MyApp extends StatelessWidget {
} }
} }
// REVISED: Helper Widget to show a full-screen, well-designed offline status
Widget _buildConnectivityOverlay(BuildContext context) {
// If not offline, return an empty widget.
if (!isOffline) return const SizedBox.shrink();
// Otherwise, return a full-screen overlay.
return Directionality(
textDirection: AppTheme.textDirection,
child: Scaffold(
backgroundColor:
Colors.grey.shade100, // Light background for the offline state
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
color: Colors.red.shade700, // Prominent color
size: 100,
),
const SizedBox(height: 24),
const Text(
"You Are Offline",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0),
child: Text(
"Please check your internet connection and try again.",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.black54,
),
),
),
const SizedBox(height: 32),
// Optional: Add a button for the user to potentially refresh/retry
// ElevatedButton(
// onPressed: () {
// // Add logic to re-check connectivity or navigate (if possible)
// },
// child: const Text("RETRY"),
// ),
],
),
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AppNotifier>( return Consumer<AppNotifier>(
@ -129,18 +71,9 @@ class MyApp extends StatelessWidget {
getPages: getPageRoute(), getPages: getPageRoute(),
builder: (context, child) { builder: (context, child) {
NavigationService.registerContext(context); NavigationService.registerContext(context);
return Directionality(
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
// This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection, textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(), child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
); );
}, },
localizationsDelegates: [ localizationsDelegates: [

View File

@ -13,7 +13,6 @@ import 'package:on_field_work/model/service_project/service_project_allocation_b
import 'package:on_field_work/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/view/service_project/jobs_tab.dart'; import 'package:on_field_work/view/service_project/jobs_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class ServiceProjectDetailsScreen extends StatefulWidget { class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId; final String projectId;
@ -430,8 +429,6 @@ class _ServiceProjectDetailsScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
@ -439,38 +436,28 @@ class _ServiceProjectDetailsScreenState
projectName: widget.projectName, projectName: widget.projectName,
onBackPressed: () => Get.toNamed('/dashboard/service-projects'), onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
), ),
body: Stack( body: SafeArea(
child: Column(
children: [ children: [
// === TOP FADE BELOW APPBAR === // TabBar
Container( Container(
height: 80, color: Colors.white,
decoration: BoxDecoration( child: TabBar(
gradient: LinearGradient( controller: _tabController,
begin: Alignment.topCenter, labelColor: Colors.black,
end: Alignment.bottomCenter, unselectedLabelColor: Colors.grey,
colors: [ indicatorColor: Colors.red,
appBarColor, indicatorWeight: 3,
appBarColor.withOpacity(0.0), isScrollable: false,
tabs: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
], ],
), ),
), ),
),
SafeArea( // TabBarView
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Profile", "Jobs", "Teams"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary.withOpacity(0.1),
height: 48,
),
// === TABBAR VIEW ===
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
@ -480,8 +467,7 @@ class _ServiceProjectDetailsScreenState
if (controller.errorMessage.value.isNotEmpty && if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {
return Center( return Center(
child: child: MyText.bodyMedium(controller.errorMessage.value));
MyText.bodyMedium(controller.errorMessage.value));
} }
return TabBarView( return TabBarView(
@ -500,8 +486,6 @@ class _ServiceProjectDetailsScreenState
], ],
), ),
), ),
],
),
floatingActionButton: _tabController.index == 1 floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended( ? FloatingActionButton.extended(
onPressed: () { onPressed: () {

View File

@ -18,8 +18,6 @@ import 'dart:io';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/helpers/widgets/serviceProject/add_comment_widget.dart';
class JobDetailsScreen extends StatefulWidget { class JobDetailsScreen extends StatefulWidget {
final String jobId; final String jobId;
@ -41,8 +39,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
final TextEditingController _dueDateController = TextEditingController(); final TextEditingController _dueDateController = TextEditingController();
final TextEditingController _tagTextController = TextEditingController(); final TextEditingController _tagTextController = TextEditingController();
// local selected lists used while editing
final RxList<Assignee> _selectedAssignees = <Assignee>[].obs; final RxList<Assignee> _selectedAssignees = <Assignee>[].obs;
final RxList<Tag> _selectedTags = <Tag>[].obs; final RxList<Tag> _selectedTags = <Tag>[].obs;
final RxBool isEditing = false.obs; final RxBool isEditing = false.obs;
File? imageAttachment; File? imageAttachment;
@ -50,12 +50,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.find<ServiceProjectDetailsController>(); controller = Get.find<ServiceProjectDetailsController>();
// fetch and seed local selected lists
// Fetch job detail first controller.fetchJobDetail(widget.jobId).then((_) {
controller.fetchJobDetail(widget.jobId).then((_) async {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job != null) { if (job != null) {
// Populate form fields
_selectedTags.value = _selectedTags.value =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList(); (job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_titleController.text = job.title ?? ''; _titleController.text = job.title ?? '';
@ -67,21 +65,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
job.dueDate ?? '', job.dueDate ?? '',
format: "yyyy-MM-dd"); format: "yyyy-MM-dd");
_selectedAssignees.value = job.assignees ?? []; _selectedAssignees.value = job.assignees ?? [];
// 🔹 Fetch job status only if existing status ID present
final existingStatusId = job.status?.id;
if (existingStatusId != null) {
await controller.fetchJobStatus(statusId: existingStatusId);
// Set selectedJobStatus to match existing status ID
if (controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
} }
}); });
} }
@ -107,20 +90,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
} }
Future<void> _editJob() async { Future<void> _editJob() async {
_processTagsInput(); // process any new tag input _processTagsInput();
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job == null) return; if (job == null) return;
final List<Map<String, dynamic>> operations = []; final List<Map<String, dynamic>> operations = [];
// 1 Title
final trimmedTitle = _titleController.text.trim(); final trimmedTitle = _titleController.text.trim();
if (trimmedTitle != job.title) { if (trimmedTitle != job.title) {
operations operations
.add({"op": "replace", "path": "/title", "value": trimmedTitle}); .add({"op": "replace", "path": "/title", "value": trimmedTitle});
} }
// 2 Description
final trimmedDescription = _descriptionController.text.trim(); final trimmedDescription = _descriptionController.text.trim();
if (trimmedDescription != job.description) { if (trimmedDescription != job.description) {
operations.add({ operations.add({
@ -130,7 +111,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}); });
} }
// 3 Start & Due Date
final startDate = DateTime.tryParse(_startDateController.text); final startDate = DateTime.tryParse(_startDateController.text);
final dueDate = DateTime.tryParse(_dueDateController.text); final dueDate = DateTime.tryParse(_dueDateController.text);
@ -150,27 +130,32 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}); });
} }
// 4 Assignees // Assignees payload (keep same approach)
final originalAssignees = job.assignees ?? []; final originalAssignees = job.assignees ?? [];
final assigneesPayload = originalAssignees.map((a) { final assigneesPayload = originalAssignees.map((a) {
final isSelected = _selectedAssignees.any((s) => s.id == a.id); final isSelected = _selectedAssignees.any((s) => s.id == a.id);
return {"employeeId": a.id, "isActive": isSelected}; return {"employeeId": a.id, "isActive": isSelected};
}).toList(); }).toList();
// add newly added assignees
for (var s in _selectedAssignees) { for (var s in _selectedAssignees) {
if (!originalAssignees.any((a) => a.id == s.id)) { if (!(originalAssignees.any((a) => a.id == s.id))) {
assigneesPayload.add({"employeeId": s.id, "isActive": true}); assigneesPayload.add({"employeeId": s.id, "isActive": true});
} }
} }
operations.add( operations.add(
{"op": "replace", "path": "/assignees", "value": assigneesPayload}); {"op": "replace", "path": "/assignees", "value": assigneesPayload});
// 5 Tags // TAGS: build robust payload using original tags and current selection
final originalTags = job.tags ?? []; final originalTags = job.tags ?? [];
final currentTags = _selectedTags.toList(); final currentTags = _selectedTags.toList();
// Only add tags operation if something changed
if (_tagsAreDifferent(originalTags, currentTags)) { if (_tagsAreDifferent(originalTags, currentTags)) {
final List<Map<String, dynamic>> finalTagsPayload = []; final List<Map<String, dynamic>> finalTagsPayload = [];
// 1) For existing original tags - we need to mark isActive true/false depending on whether they're in currentTags
for (var ot in originalTags) { for (var ot in originalTags) {
final isSelected = currentTags.any((ct) => final isSelected = currentTags.any((ct) =>
(ct.id != null && ct.id == ot.id) || (ct.id != null && ct.id == ot.id) ||
@ -182,25 +167,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}); });
} }
// 2) Add newly created tags from currentTags that don't have a valid id (id == "0" or null)
for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) { for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) {
finalTagsPayload.add({"name": ct.name, "isActive": true}); finalTagsPayload.add({
} "name": ct.name,
"isActive": true,
operations });
.add({"op": "replace", "path": "/tags", "value": finalTagsPayload}); }
}
operations.add({
// 6 Job Status "op": "replace",
final selectedStatus = controller.selectedJobStatus.value; "path": "/tags",
if (selectedStatus != null && selectedStatus.id != job.status?.id) { "value": finalTagsPayload,
operations.add({
"op": "replace",
"path": "/statusId", // make sure API expects this field
"value": selectedStatus.id
}); });
} }
// 7 Check if anything changed
if (operations.isEmpty) { if (operations.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Info", title: "Info",
@ -209,7 +190,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
return; return;
} }
// 8 Call API
final success = await ApiService.editServiceProjectJobApi( final success = await ApiService.editServiceProjectJobApi(
jobId: job.id ?? "", jobId: job.id ?? "",
operations: operations, operations: operations,
@ -221,13 +201,16 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
message: "Job updated successfully", message: "Job updated successfully",
type: SnackbarType.success); type: SnackbarType.success);
// Re-fetch job detail & update tags locally // re-fetch job detail and update local selected tags from server response
await controller.fetchJobDetail(widget.jobId); await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data; final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) { if (updatedJob != null) {
_selectedTags.value = (updatedJob.tags ?? []) _selectedTags.value = (updatedJob.tags ?? [])
.map((t) => Tag(id: t.id, name: t.name)) .map((t) => Tag(id: t.id, name: t.name))
.toList(); .toList();
// UI refresh to reflect tags instantly
setState(() {}); setState(() {});
} }
@ -818,195 +801,31 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
); );
} }
Widget _buildJobStatusCard() {
final job = controller.jobDetail.value?.data;
if (job == null) return const SizedBox();
// Existing status info
final statusName = job.status?.displayName ?? "N/A";
Color statusColor;
switch (job.status?.level) {
case 1:
statusColor = Colors.green;
break;
case 2:
statusColor = Colors.orange;
break;
case 3:
statusColor = Colors.blue;
break;
case 4:
statusColor = Colors.red;
break;
default:
statusColor = Colors.grey;
}
final editing = isEditing.value;
// Ensure selectedJobStatus initialized
if (editing && controller.selectedJobStatus.value == null) {
final existingStatusId = job.status?.id;
if (existingStatusId != null && controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
return _buildSectionCard(
title: "Job Status",
titleIcon: Icons.flag_outlined,
children: [
// 1 Display existing status
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(Icons.flag, color: statusColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: statusColor),
),
const SizedBox(height: 2),
Text(
"Level: ${job.status?.level ?? '-'}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
),
],
),
const SizedBox(height: 16),
// 2 PopupMenuButton for new selection
if (editing)
Obx(() {
final selectedStatus = controller.selectedJobStatus.value;
final statuses = controller.jobStatusList;
return PopupMenuButton<JobStatus>(
onSelected: (val) => controller.selectedJobStatus.value = val,
itemBuilder: (_) => statuses
.map(
(s) => PopupMenuItem(
value: s,
child: Text(s.displayName ?? "N/A"),
),
)
.toList(),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedStatus?.displayName ?? "Select Job Status",
style:
TextStyle(color: Colors.grey.shade700, fontSize: 14),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final projectName = widget.projectName; final projectName = widget.projectName;
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Job Details Screen", title: "Job Details Screen",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
projectName: projectName, projectName: projectName),
backgroundColor: appBarColor,
),
floatingActionButton: Obx(() => FloatingActionButton.extended( floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed: onPressed:
isEditing.value ? _editJob : () => isEditing.value = true, isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: appBarColor, backgroundColor: contentTheme.primary,
label: MyText.bodyMedium( label: MyText.bodyMedium(isEditing.value ? "Save" : "Edit",
isEditing.value ? "Save" : "Edit", color: Colors.white, fontWeight: 600),
color: Colors.white,
fontWeight: 600,
),
icon: Icon(isEditing.value ? Icons.save : Icons.edit), icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)), )),
body: Stack( body: Obx(() {
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Bottom fade (for smooth transition above FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60, // adjust based on FAB height
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
// Main scrollable content
Obx(() {
if (controller.isJobDetailLoading.value) { if (controller.isJobDetailLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (controller.jobDetailErrorMessage.value.isNotEmpty) { if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center( return Center(
child: MyText.bodyMedium( child: MyText.bodyMedium(controller.jobDetailErrorMessage.value));
controller.jobDetailErrorMessage.value));
} }
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
@ -1019,7 +838,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildJobStatusCard(),
_buildAttendanceCard(), _buildAttendanceCard(),
_buildSectionCard( _buildSectionCard(
title: "Job Info", title: "Job Info",
@ -1028,14 +846,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
_editableRow("Title", _titleController), _editableRow("Title", _titleController),
_editableRow("Description", _descriptionController), _editableRow("Description", _descriptionController),
_dateRangePicker(), _dateRangePicker(),
], ]),
),
MySpacing.height(12), MySpacing.height(12),
_buildSectionCard( _buildSectionCard(
title: "Project Branch", title: "Project Branch",
titleIcon: Icons.account_tree_outlined, titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()], children: [_branchDisplay()]),
),
MySpacing.height(16), MySpacing.height(16),
_buildSectionCard( _buildSectionCard(
title: "Assignees", title: "Assignees",
@ -1047,31 +863,16 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
titleIcon: Icons.label_outline, titleIcon: Icons.label_outline,
children: [_tagEditor()]), children: [_tagEditor()]),
MySpacing.height(16), MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false)) if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard( _buildSectionCard(
title: "Update Logs", title: "Update Logs",
titleIcon: Icons.history, titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]), children: [JobTimeline(logs: job.updateLogs ?? [])]),
// NEW CARD ADDED HERE
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Comment Section",
titleIcon: Icons.comment_outlined,
children: [
AddCommentWidget(
jobId: job.id ?? "",
jobTicketId: job.jobTicketUId ?? ""),
]),
// END NEW CARD
MySpacing.height(80), MySpacing.height(80),
], ],
), ),
); );
}), }),
],
),
); );
} }
} }

View File

@ -181,33 +181,17 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Service Projects", title: "Service Projects",
projectName: 'All Service Projects', projectName: 'All Service Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'), onBackPressed: () => Get.toNamed('/dashboard'),
), ),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content // FIX 1: Entire body wrapped in SafeArea
SafeArea( body: SafeArea(
bottom: true, bottom: true,
child: Column( child: Column(
children: [ children: [
@ -225,12 +209,12 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
const EdgeInsets.symmetric(horizontal: 12), const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search, prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
suffixIcon: suffixIcon: ValueListenableBuilder<TextEditingValue>(
ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController, valueListenable: searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) if (value.text.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear,
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
@ -246,13 +230,11 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
borderSide: borderSide: BorderSide(color: Colors.grey.shade300),
BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
borderSide: borderSide: BorderSide(color: Colors.grey.shade300),
BorderSide(color: Colors.grey.shade300),
), ),
), ),
), ),
@ -266,6 +248,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;
return MyRefreshIndicator( return MyRefreshIndicator(
@ -276,8 +259,11 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
? _buildEmptyState() ? _buildEmptyState()
: ListView.separated( : ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
// FIX 2: Increased bottom padding for landscape
padding: MySpacing.only( padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 120), left: 8, right: 8, top: 4, bottom: 120),
itemCount: projects.length, itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) => itemBuilder: (_, index) =>
@ -289,8 +275,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
], ],
), ),
), ),
],
),
); );
} }
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Assuming 'package:on_field_work/images.dart' correctly provides 'Images.logoDark'
import 'package:on_field_work/images.dart'; import 'package:on_field_work/images.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
@ -9,9 +8,8 @@ class SplashScreen extends StatefulWidget {
const SplashScreen({ const SplashScreen({
super.key, super.key,
this.message = this.message,
'GET WORK DONE, ANYWHERE.', // Default message for a modern look this.logoSize = 120,
this.logoSize = 150, // Slightly larger logo
this.backgroundColor = Colors.white, this.backgroundColor = Colors.white,
}); });
@ -22,59 +20,20 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
// Animation for the logo's vertical float effect late Animation<double> _animation;
late Animation<double> _floatAnimation;
// Animation for logo's initial scale-in
late Animation<double> _scaleAnimation;
// Animation for logo and text fade-in
late Animation<double> _opacityAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = AnimationController(
duration: duration: const Duration(seconds: 1),
const Duration(seconds: 3), // Longer duration for complex sequence
vsync: this, vsync: this,
); )..repeat(reverse: true);
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration) _animation = Tween<double>(begin: 0.0, end: 8.0).animate(
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
CurvedAnimation(
parent: _controller,
curve:
const Interval(0.0, 0.4, curve: Curves.easeOutBack), // Bouncy start
),
); );
// Overall fade-in: from 0.0 to 1.0 (happens in the first 50% of the duration)
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
),
);
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations)
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
// Start the complex animation sequence
_controller.forward().then((_) {
// After the initial scale/fade, switch to repeating the float animation
if (mounted) {
_controller.repeat(
min: 0.4, // Start repeat from the float interval
max: 1.0,
reverse: true,
);
}
});
} }
@override @override
@ -83,73 +42,79 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose(); super.dispose();
} }
// A simple, modern custom progress indicator Widget _buildAnimatedDots() {
Widget _buildProgressIndicator() { return Row(
return SizedBox( mainAxisAlignment: MainAxisAlignment.center,
width: 60, children: List.generate(3, (index) {
child: LinearProgressIndicator( return AnimatedBuilder(
backgroundColor: Colors.blueAccent.withOpacity(0.2), animation: _animation,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blueAccent), builder: (context, child) {
double opacity;
if (index == 0) {
opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0);
} else if (index == 1) {
opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0);
} else {
opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(opacity),
shape: BoxShape.circle,
), ),
); );
},
);
}),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
// Full screen display, no SafeArea needed for a full bleed splash body: SafeArea(
body: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Animated Logo (Scale, Opacity, and Float) // Logo with slight bounce animation
FadeTransition( ScaleTransition(
opacity: _opacityAnimation, scale: Tween(begin: 0.8, end: 1.0).animate(
child: AnimatedBuilder( CurvedAnimation(
animation: _floatAnimation, parent: _controller,
builder: (context, child) { curve: Curves.easeInOut,
return Transform.translate( ),
offset: Offset(0, _floatAnimation.value), ),
child: ScaleTransition(
scale: _scaleAnimation,
child: SizedBox( child: SizedBox(
width: widget.logoSize, width: widget.logoSize,
height: widget.logoSize, height: widget.logoSize,
// Replace with your actual logo image widget
child: Image.asset(Images.logoDark), child: Image.asset(Images.logoDark),
), ),
), ),
);
},
),
),
const SizedBox(height: 30), const SizedBox(height: 20),
// Text message
// Text Message (Fades in slightly after logo)
if (widget.message != null) if (widget.message != null)
FadeTransition( Text(
opacity: _opacityAnimation,
child: Text(
widget.message!, widget.message!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 18,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: Colors.grey.shade700, color: Colors.black87,
letterSpacing: 1.2,
), ),
), ),
), const SizedBox(height: 30),
// Animated loading dots
const SizedBox(height: 40), _buildAnimatedDots(),
// Modern Loading Indicator
_buildProgressIndicator(),
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -19,8 +19,7 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
class DailyProgressReportScreen extends StatefulWidget { class DailyProgressReportScreen extends StatefulWidget {
final String projectId; const DailyProgressReportScreen({super.key});
const DailyProgressReportScreen({super.key, required this.projectId});
@override @override
State<DailyProgressReportScreen> createState() => State<DailyProgressReportScreen> createState() =>
@ -63,15 +62,21 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
} }
} }
}); });
final initialProjectId = projectController.selectedProjectId.value;
// Use projectId passed from parent instead of global selectedProjectId
final initialProjectId = widget.projectId;
if (initialProjectId.isNotEmpty) { if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId; dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId); dailyTaskController.fetchTaskData(initialProjectId);
} }
// Removed the ever<ProjectController> block to keep it independent // Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
} }
@override @override
@ -83,13 +88,69 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Stack( appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
SafeArea( IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator( child: MyRefreshIndicator(
onRefresh: _refreshData, onRefresh: _refreshData,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
@ -104,7 +165,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
Padding( Padding(
padding: MySpacing.x(15), padding: MySpacing.x(15),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment:
MainAxisAlignment.end,
children: [ children: [
InkWell( InkWell(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
@ -120,8 +182,9 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
color: Colors.black, color: Colors.black,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon(Icons.tune, Icon(Icons.tune,
size: 20, color: Colors.black), size: 20, color: Colors.black),
], ],
), ),
), ),
@ -143,12 +206,11 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
), ),
), ),
],
),
); );
} }
Future<void> _openFilterSheet() async { Future<void> _openFilterSheet() async {
// Fetch filter data first
if (dailyTaskController.taskFilterData == null) { if (dailyTaskController.taskFilterData == null) {
await dailyTaskController await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? ''); .fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
@ -245,27 +307,32 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final isLoading = dailyTaskController.isLoading.value; final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks; final groupedTasks = dailyTaskController.groupedDailyTasks;
// 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) { if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader(); return SkeletonLoaders.dailyProgressReportSkeletonLoader();
} }
// No data available
if (groupedTasks.isEmpty) { if (groupedTasks.isEmpty) {
return Center( return Center(
child: MyText.bodySmall( child: MyText.bodySmall(
"No Progress Report Found for selected filters.", "No Progress Report Found",
fontWeight: 600, fontWeight: 600,
), ),
); );
} }
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList() final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a)); ..sort((a, b) => b.compareTo(a));
// 🔹 Auto expand if only one date present
if (sortedDates.length == 1 && if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) { !dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]); dailyTaskController.expandedDates.add(sortedDates[0]);
} }
// 🧱 Return a scrollable column of cards
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -284,6 +351,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🗓 Date Header
GestureDetector( GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey), onTap: () => dailyTaskController.toggleDate(dateKey),
child: Padding( child: Padding(
@ -308,6 +376,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
), ),
), ),
// 🔽 Task List (expandable)
Obx(() { Obx(() {
if (!dailyTaskController.expandedDates if (!dailyTaskController.expandedDates
.contains(dateKey)) { .contains(dateKey)) {
@ -345,12 +415,15 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName, MyText.bodyMedium(activityName,
fontWeight: 600), fontWeight: 600),
const SizedBox(height: 2), const SizedBox(height: 2),
MyText.bodySmall(location, MyText.bodySmall(location,
color: Colors.grey), color: Colors.grey),
const SizedBox(height: 8), const SizedBox(height: 8),
// 👥 Team Members
GestureDetector( GestureDetector(
onTap: () => _showTeamMembersBottomSheet( onTap: () => _showTeamMembersBottomSheet(
task.teamMembers), task.teamMembers),
@ -368,6 +441,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall( MyText.bodySmall(
"Completed: $completed / $planned", "Completed: $completed / $planned",
fontWeight: 600, fontWeight: 600,
@ -412,6 +487,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700], : Colors.red[700],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
@ -470,6 +547,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
); );
}), }),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value Obx(() => dailyTaskController.isLoadingMore.value
? const Padding( ? const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),

View File

@ -7,6 +7,7 @@ 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/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:percent_indicator/percent_indicator.dart'; import 'package:percent_indicator/percent_indicator.dart';
import 'package:on_field_work/model/dailyTaskPlanning/assign_task_bottom_sheet .dart'; import 'package:on_field_work/model/dailyTaskPlanning/assign_task_bottom_sheet .dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
@ -16,9 +17,7 @@ import 'package:on_field_work/controller/tenant/service_controller.dart';
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart'; import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
class DailyTaskPlanningScreen extends StatefulWidget { class DailyTaskPlanningScreen extends StatefulWidget {
final String projectId; // Optional projectId from parent DailyTaskPlanningScreen({super.key});
DailyTaskPlanningScreen({super.key, required this.projectId});
@override @override
State<DailyTaskPlanningScreen> createState() => State<DailyTaskPlanningScreen> createState() =>
@ -31,31 +30,100 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Get.put(DailyTaskPlanningController()); Get.put(DailyTaskPlanningController());
final PermissionController permissionController = final PermissionController permissionController =
Get.put(PermissionController()); Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController()); final ServiceController serviceController = Get.put(ServiceController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Use widget.projectId if passed; otherwise fallback to selectedProjectId final projectId = projectController.selectedProjectId.value;
final projectId = widget.projectId;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
// Now this will fetch only services + building list (no deep infra)
dailyTaskPlanningController.fetchTaskData(projectId); dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId); serviceController.fetchServices(projectId);
} }
// Whenever project changes, fetch buildings & services (still lazy load infra per building)
ever<String>(
projectController.selectedProjectId,
(newProjectId) {
if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController.fetchServices(newProjectId);
}
},
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Stack( appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
SafeArea( IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Task Planning',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator( child: MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
final projectId = widget.projectId; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
try { try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData( await dailyTaskPlanningController.fetchTaskData(
projectId, projectId,
serviceId: serviceController.selectedService?.id, serviceId: serviceController.selectedService?.id,
@ -88,12 +156,13 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
controller: serviceController, controller: serviceController,
height: 40, height: 40,
onSelectionChanged: (service) async { onSelectionChanged: (service) async {
final projectId = widget.projectId; final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
await dailyTaskPlanningController await dailyTaskPlanningController.fetchTaskData(
.fetchTaskData(
projectId, projectId,
serviceId: service?.id, serviceId:
service?.id, // <-- pass selected service
); );
} }
}, },
@ -112,8 +181,6 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
), ),
), ),
), ),
],
),
); );
} }
@ -160,7 +227,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final buildings = dailyTasks final buildings = dailyTasks
.expand((task) => task.buildings) .expand((task) => task.buildings)
.where((building) => .where((building) =>
(building.plannedWork) > 0 || (building.completedWork) > 0) (building.plannedWork ) > 0 ||
(building.completedWork ) > 0)
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {
@ -199,14 +267,16 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
}); });
if (expanded && !buildingLoaded && !buildingLoading) { if (expanded && !buildingLoaded && !buildingLoading) {
final projectId = widget.projectId; // fetch infra details for this building lazily
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchBuildingInfra( await dailyTaskPlanningController.fetchBuildingInfra(
building.id.toString(), building.id.toString(),
projectId, projectId,
serviceController.selectedService?.id, serviceController.selectedService?.id,
); );
setMainState(() {}); setMainState(() {}); // rebuild to reflect loaded data
} }
} }
}, },
@ -250,7 +320,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: MyText.bodySmall( child: MyText.bodySmall(
"No Progress Report Found for this Project", "No Progress Report Found",
fontWeight: 600, fontWeight: 600,
), ),
) )

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+18 version: 1.0.0+16
environment: environment:
sdk: ^3.5.3 sdk: ^3.5.3