Compare commits
4 Commits
main
...
Dev_Manish
| Author | SHA1 | Date | |
|---|---|---|---|
| c6fbee7083 | |||
| d453ec40fc | |||
| 30d18d5ac6 | |||
| 66c013f797 |
@ -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,
|
||||||
|
|||||||
@ -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;
|
|
||||||
final attendanceSelectedRange = '15D'.obs;
|
|
||||||
final attendanceIsChartView = true.obs;
|
|
||||||
final isAttendanceLoading = false.obs;
|
|
||||||
|
|
||||||
// Project Progress
|
|
||||||
final projectChartData = <ChartTaskData>[].obs;
|
|
||||||
final projectSelectedRange = '15D'.obs;
|
|
||||||
final projectIsChartView = true.obs;
|
|
||||||
final isProjectLoading = false.obs;
|
|
||||||
|
|
||||||
// Overview Counts
|
|
||||||
final totalProjects = 0.obs;
|
|
||||||
final ongoingProjects = 0.obs;
|
|
||||||
final isProjectsLoading = false.obs;
|
|
||||||
|
|
||||||
final totalTasks = 0.obs;
|
|
||||||
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
|
// Projects overview
|
||||||
// =========================
|
// =========================
|
||||||
final isPurchaseInvoiceLoading = true.obs;
|
final RxInt totalProjects = 0.obs;
|
||||||
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
|
final RxInt ongoingProjects = 0.obs;
|
||||||
// Constants
|
final RxBool isProjectsLoading = false.obs;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Tasks overview
|
||||||
|
// =========================
|
||||||
|
final RxInt totalTasks = 0.obs;
|
||||||
|
final RxInt completedTasks = 0.obs;
|
||||||
|
final RxBool isTasksLoading = false.obs;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Teams overview
|
||||||
|
// =========================
|
||||||
|
final RxInt totalEmployees = 0.obs;
|
||||||
|
final RxInt inToday = 0.obs;
|
||||||
|
final RxBool isTeamsLoading = false.obs;
|
||||||
|
|
||||||
|
// Common ranges
|
||||||
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
|
||||||
// =========================
|
// =========================
|
||||||
// 2. COMPUTED PROPERTIES
|
final RxBool isPendingExpensesLoading = false.obs;
|
||||||
|
final Rx<PendingExpensesData?> pendingExpensesData =
|
||||||
|
Rx<PendingExpensesData?>(null);
|
||||||
// =========================
|
// =========================
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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(),
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
State<MainWrapper> createState() => _MainWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainWrapperState extends State<MainWrapper> {
|
||||||
|
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
|
||||||
|
final Connectivity _connectivity = Connectivity();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeConnectivity();
|
||||||
|
_connectivity.onConnectivityChanged.listen((results) {
|
||||||
|
setState(() => _connectivityStatus = results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeConnectivity() async {
|
||||||
|
final result = await _connectivity.checkConnectivity();
|
||||||
|
setState(() => _connectivityStatus = result);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 1. Use FutureBuilder to check the current connectivity status ONCE.
|
final bool isOffline =
|
||||||
return FutureBuilder<List<ConnectivityResult>>(
|
_connectivityStatus.contains(ConnectivityResult.none);
|
||||||
future: Connectivity().checkConnectivity(),
|
return isOffline
|
||||||
builder: (context, initialSnapshot) {
|
? const MaterialApp(
|
||||||
// If the initial check is still running, display a standard loading screen.
|
debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||||
if (!initialSnapshot.hasData) {
|
: const MyApp();
|
||||||
return const MaterialApp(
|
|
||||||
home: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Once the initial status is known, use StreamBuilder for real-time updates.
|
|
||||||
return StreamBuilder<List<ConnectivityResult>>(
|
|
||||||
stream: Connectivity().onConnectivityChanged,
|
|
||||||
// 💡 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.
|
|
||||||
return MyApp(isOffline: isOffline);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
@ -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)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,25 +872,22 @@ 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() {
|
||||||
|
|||||||
@ -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>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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(),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
@ -83,37 +83,12 @@ 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);
|
||||||
@ -319,7 +306,6 @@ Widget build(BuildContext context) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
||||||
ExpenseDetailModel expense, dynamic next) {
|
ExpenseDetailModel expense, dynamic next) {
|
||||||
Color primary = Colors.red;
|
Color primary = Colors.red;
|
||||||
@ -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});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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});
|
|
||||||
}
|
|
||||||
@ -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]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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: () {
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user