Compare commits

...

58 Commits

Author SHA1 Message Date
9ec7dee0f1 Merge pull request 'Collection_Purchase_Widget' (#87) from Collection_Purchase_Widget into main
Reviewed-on: #87
2025-12-06 05:29:39 +00:00
48a96a703b addedapi for purchase invoice 2025-12-05 17:24:57 +05:30
8686d696f0 improved dashboard controller 2025-12-05 17:00:22 +05:30
5d73fd6f4f added api for dashboard for collection widget 2025-12-05 16:52:24 +05:30
1717cd5e2b added mytext 2025-12-05 10:53:36 +05:30
717f0c92af feat: Add CompactPurchaseInvoiceDashboard widget and integrate into dashboard screen
- Implemented a new widget for displaying purchase invoice metrics with internal dummy data.
- Integrated the CompactPurchaseInvoiceDashboard into the dashboard screen layout.
- Updated imports in dashboard_screen.dart to include the new purchase invoice dashboard widget.
2025-12-04 17:54:48 +05:30
633a75fe92 fix: Update base URL in ApiEndpoints and increment app version to 1.0.0+18 2025-12-03 17:38:20 +05:30
8fb32c7c8e Refactor dashboard screen layout and improve loading state handling
- Simplified initialization of DynamicMenuController.
- Added loading skeleton for employee quick action cards.
- Removed daily task planning and daily progress report from card order.
- Adjusted grid layout parameters for better responsiveness.
- Cleaned up code formatting for improved readability.
2025-12-03 17:08:25 +05:30
3dfa6e5877 feat: Add infrastructure project details and list models
- Implemented ProjectDetailsResponse and ProjectData models for handling project details.
- Created ProjectsResponse and ProjectsPageData models for listing infrastructure projects.
- Added InfraProjectScreen and InfraProjectDetailsScreen for displaying project information.
- Integrated search functionality in InfraProjectScreen to filter projects.
- Updated DailyTaskPlanningScreen and DailyProgressReportScreen to accept projectId as a parameter.
- Removed unnecessary dependencies and cleaned up code for better maintainability.
2025-12-03 16:49:46 +05:30
03e3e7b5db feat: Enhance Dashboard with Attendance and Infra Projects
- Added employee attendance fetching in DashboardController.
- Introduced loading state for employees in the dashboard.
- Updated API endpoints to include attendance for the dashboard.
- Created a new InfraProjectsMainScreen with tab navigation for task planning and progress reporting.
- Improved UI components for better user experience in the dashboard.
- Refactored project selection and quick actions in the dashboard.
- Added permission constants for infrastructure projects.
2025-12-03 13:09:48 +05:30
cf85c17d75 Update base URL in ApiEndpoints to point to the correct API endpoint 2025-12-01 15:13:44 +05:30
66445b1e54 Add dynamic app version display in WelcomeScreen 2025-12-01 15:08:22 +05:30
7c86d0c5c2 Refactor connectivity handling in MainWrapper and enhance offline experience in MyApp 2025-12-01 15:04:59 +05:30
012d40cd57 Implement job comments feature: add comment widget, API endpoints, and controller methods for fetching and posting comments 2025-12-01 14:38:58 +05:30
d4d678d98a Remove padding from card wrapper and comment out quick actions in dashboard for layout adjustments 2025-11-29 15:33:49 +05:30
6ed069d924 Replace CircularProgressIndicator with skeleton loaders in dashboard; reintroduce project dropdown list with search functionality 2025-11-29 15:09:10 +05:30
c9e73771b0 Refactor CustomAppBar to StatefulWidget; implement project selection dropdown and improve UI interactions 2025-11-29 15:02:03 +05:30
ed2eb014d8 Refactor attendance management screens for improved readability and g 2025-11-29 14:36:44 +05:30
3ad48016f3 Enhance splash screen with improved animations and loading indicator; update logo size and message for better visibility 2025-11-29 12:34:30 +05:30
37ce612fca Enhance attendance management with tabbed navigation and permission handling; improve UI consistency and loading states 2025-11-29 12:34:22 +05:30
7bef2e9d89 Add job status management to service project details screen 2025-11-28 18:30:08 +05:30
341d779499 Refactor service project job handling and improve tag management 2025-11-28 15:34:13 +05:30
65fbef3441 Enhance UI and Navigation
- Added navigation to the dashboard after applying the theme in ThemeController.
- Introduced a new PillTabBar widget for a modern tab design across multiple screens.
- Updated dashboard screen to improve button actions and UI consistency.
- Refactored contact detail screen to streamline layout and enhance gradient effects.
- Implemented PillTabBar in directory main screen, expense screen, and payment request screen for consistent tab navigation.
- Improved layout structure in user document screen and employee profile screen for better user experience.
- Enhanced service project details screen with a modern tab bar implementation.
2025-11-28 14:48:39 +05:30
259f2aa928 Refactor UI components to use CustomAppBar and improve layout consistency
- Replaced existing AppBar implementations with CustomAppBar in multiple screens including PaymentRequestDetailScreen, PaymentRequestMainScreen, ServiceProjectDetailsScreen, JobDetailsScreen, DailyProgressReportScreen, DailyTaskPlanningScreen, and ServiceProjectScreen.
- Enhanced visual hierarchy by adding gradient backgrounds behind app bars for better aesthetics.
- Streamlined SafeArea usage to ensure proper content display across different devices.
- Improved code readability and maintainability by removing redundant code and consolidating UI elements.
2025-11-27 19:07:24 +05:30
84156167ea Merge pull request 'Dev_Manish_Bug' (#85) from Dev_Manish_Bug into main
Reviewed-on: #85
2025-11-27 05:31:48 +00:00
260b762724 Merge branch 'Dev_Manish_Bug' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Dev_Manish_Bug 2025-11-27 10:55:29 +05:30
951dd22ecc bug fixed for 1776, 1775, 1736, 1626 2025-11-27 10:55:12 +05:30
33ae5c0333 bug fixed for 1776, 1775, 1736, 1626 2025-11-26 15:20:20 +05:30
60bc53afef refactor: remove project selection from layout and update dashboard layout 2025-11-25 18:13:22 +05:30
90a3a85753 Merge pull request 'Dev_Manish_24/11' (#84) from Dev_Manish_24/11 into main
Reviewed-on: #84
2025-11-25 10:13:59 +00:00
71a48750bd Merge branch 'Dev_Manish_24/11' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Dev_Manish_24/11 2025-11-25 15:32:10 +05:30
e2bee52820 update screen with employee should place at same place after selecting 2025-11-25 15:32:01 +05:30
bb723b91e5 height change to 0.85 2025-11-25 15:32:01 +05:30
55122b5b13 update for tag should submit after space 2025-11-25 15:32:00 +05:30
2700864adf added safe area to support mobile screen horizontally 2025-11-25 15:32:00 +05:30
18fbfaa42d enhacement of UI for mobile screen responsiveness 2025-11-25 15:32:00 +05:30
3e8bd1c41d tag can submit after space 2025-11-25 15:32:00 +05:30
4d2b05cdc2 multiple tags can add using space 2025-11-25 15:32:00 +05:30
ddb1440211 implemented new multi select role bottom sheet 2025-11-25 15:32:00 +05:30
e4f55d82f7 reenhacenment of employee selector 2025-11-25 15:32:00 +05:30
081849f964 added needed vaiables for employee selector 2025-11-25 15:32:00 +05:30
a3b95b4d07 reenhacement of employee selector 2025-11-25 15:32:00 +05:30
ed4a558894 reenhancement of employee selector 2025-11-25 15:32:00 +05:30
e2897e4dde reenhancement of employee selector 2025-11-25 15:32:00 +05:30
08777176df update screen with employee should place at same place after selecting 2025-11-25 15:30:06 +05:30
81f74004b8 height change to 0.85 2025-11-25 13:58:47 +05:30
3fa578f1b4 feat: update Kotlin plugin version and modify API endpoints for improved functionality 2025-11-25 13:19:00 +05:30
28c1c36e07 update for tag should submit after space 2025-11-25 13:18:15 +05:30
24bfccfdf6 added safe area to support mobile screen horizontally 2025-11-25 12:45:52 +05:30
5bed5bd2f4 enhacement of UI for mobile screen responsiveness 2025-11-25 12:17:45 +05:30
41112a3eea tag can submit after space 2025-11-24 16:44:09 +05:30
9eb72a60ac multiple tags can add using space 2025-11-24 15:42:38 +05:30
b401e98658 implemented new multi select role bottom sheet 2025-11-24 15:28:31 +05:30
56602328ca reenhacenment of employee selector 2025-11-24 15:15:35 +05:30
aece165c38 added needed vaiables for employee selector 2025-11-24 15:10:31 +05:30
38626ebef0 reenhacement of employee selector 2025-11-24 15:08:03 +05:30
6b58085434 reenhancement of employee selector 2025-11-24 15:00:52 +05:30
5e1379b74b reenhancement of employee selector 2025-11-24 14:56:09 +05:30
81 changed files with 11471 additions and 5931 deletions

View File

@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false id "org.jetbrains.kotlin.android" version "2.2.21" apply false
id("com.google.gms.google-services") version "4.4.2" apply false id("com.google.gms.google-services") version "4.4.2" apply false
} }

View File

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

View File

@ -7,191 +7,201 @@ 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
// 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;
// =========================
// Project progress overview
// =========================
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
final RxString projectSelectedRange = '15D'.obs;
final RxBool projectIsChartView = true.obs;
final RxBool isProjectLoading = false.obs;
// =========================
// Projects overview
// =========================
final RxInt totalProjects = 0.obs;
final RxInt ongoingProjects = 0.obs;
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'];
// Inject ProjectController
final ProjectController projectController = Get.put(ProjectController()); final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// ========================= // =========================
// Expense Category Report // 1. STATE VARIABLES
// ========================= // =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData = // Attendance
Rx<ExpenseTypeReportData?>(null); final roleWiseData = <Map<String, dynamic>>[].obs;
final Rx<DateTime> expenseReportStartDate = 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; DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs; final expenseReportEndDate = DateTime.now().obs;
// =========================
// Monthly Expense Report final isMonthlyExpenseLoading = false.obs;
// ========================= final monthlyExpenseList = <MonthlyExpenseData>[].obs;
final RxBool isMonthlyExpenseLoading = false.obs; final selectedMonthlyExpenseDuration =
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs; MonthlyExpenseDuration.twelveMonths.obs;
final selectedMonthsCount = 12.obs;
final RxInt selectedMonthsCount = 12.obs; final expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs; final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
void updateSelectedExpenseType(ExpenseTypeModel? type) { // Teams/Employees
selectedExpenseType.value = type; final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs;
// Debug print to verify // Collection
print('Selected: ${type?.name ?? "All Types"}'); final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
if (type == null) { // =========================
fetchMonthlyExpenses(); // 2. COMPUTED PROPERTIES
} else { // =========================
fetchMonthlyExpenses(categoryId: type.id);
} int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
// DSO Calculation Constants
static const double _w0_30 = 15.0;
static const double _w30_60 = 45.0;
static const double _w60_90 = 75.0;
static const double _w90_plus = 105.0;
double get calculatedDSO {
final data = collectionOverviewData.value;
if (data == null || data.totalDueAmount == 0) return 0.0;
final double weightedDue = (data.bucket0To30Amount * _w0_30) +
(data.bucket30To60Amount * _w30_60) +
(data.bucket60To90Amount * _w60_90) +
(data.bucket90PlusAmount * _w90_plus);
return weightedDue / data.totalDueAmount;
} }
// =========================
// 3. LIFECYCLE
// =========================
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info);
logSafe( // Project Selection Listener
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info,
);
fetchAllDashboardData();
// React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData(); if (id.isNotEmpty) {
fetchAllDashboardData();
fetchTodaysAttendance(id);
}
}); });
// Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
fetchExpenseTypeReport( if (projectController.selectedProjectId.value.isNotEmpty) {
startDate: expenseReportStartDate.value, fetchExpenseTypeReport(
endDate: expenseReportEndDate.value, startDate: expenseReportStartDate.value,
); endDate: expenseReportEndDate.value,
);
}
}); });
// React to range changes
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
} }
// ========================= // =========================
// Helper Methods // 4. USER ACTIONS
// ========================= // =========================
int _getDaysFromRange(String range) {
switch (range) { void updateAttendanceRange(String range) =>
case '7D': attendanceSelectedRange.value = range;
return 7; void updateProjectRange(String range) => projectSelectedRange.value = range;
case '15D': void toggleAttendanceChartView(bool isChart) =>
return 15; attendanceIsChartView.value = isChart;
case '30D': void toggleProjectChartView(bool isChart) =>
return 30; projectIsChartView.value = isChart;
case '3M':
return 90; void updateSelectedExpenseType(ExpenseTypeModel? type) {
case '6M': selectedExpenseType.value = type;
return 180; fetchMonthlyExpenses(categoryId: type?.id);
default: }
return 7;
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
// Efficient Map lookup instead of Switch
const durationMap = {
MonthlyExpenseDuration.oneMonth: 1,
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() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async {
final id = projectController.selectedProjectId.value;
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
}
// =========================
// 5. DATA FETCHING (API)
// =========================
/// 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;
} }
} }
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
void updateAttendanceRange(String range) {
attendanceSelectedRange.value = range;
logSafe('Attendance range updated to $range', level: LogLevel.debug);
}
void updateProjectRange(String range) {
projectSelectedRange.value = range;
logSafe('Project range updated to $range', level: LogLevel.debug);
}
void toggleAttendanceChartView(bool isChart) {
attendanceIsChartView.value = isChart;
logSafe('Attendance chart view toggled to: $isChart',
level: LogLevel.debug);
}
void toggleProjectChartView(bool isChart) {
projectIsChartView.value = isChart;
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
}
// =========================
// Manual Refresh Methods
// =========================
Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData();
}
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshTasks() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// Fetch All Dashboard Data
// =========================
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(),
@ -204,248 +214,150 @@ class DashboardController extends GetxController {
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
), ),
fetchMonthlyExpenses(), fetchMonthlyExpenses(),
fetchMasterData() fetchMasterData(),
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]); ]);
} }
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { Future<void> fetchCollectionOverview() async {
selectedMonthlyExpenseDuration.value = duration; final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
// Set months count based on selection await _executeApiCall(isCollectionOverviewLoading, () async {
switch (duration) { final response =
case MonthlyExpenseDuration.oneMonth: await ApiService.getCollectionOverview(projectId: projectId);
selectedMonthsCount.value = 1; collectionOverviewData.value =
break; (response?.success == true) ? response!.data : null;
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;
}
// Re-fetch updated data Future<void> fetchTodaysAttendance(String projectId) async {
fetchMonthlyExpenses(); await _executeApiCall(isLoadingEmployees, () async {
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 expenseTypesData = await ApiService.getMasterExpenseTypes(); final data = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) { if (data is List) {
expenseTypes.value = expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
} }
} catch (e) { } catch (_) {}
logSafe('Error fetching master data', level: LogLevel.error, error: e);
}
} }
Future<void> fetchMonthlyExpenses({String? categoryId}) async { Future<void> fetchMonthlyExpenses({String? categoryId}) async {
try { await _executeApiCall(isMonthlyExpenseLoading, () async {
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: months, months: selectedMonthsCount.value,
); );
monthlyExpenseList.value =
(response?.success == true) ? response!.data : [];
});
}
if (response != null && response.success) { Future<void> fetchPurchaseInvoiceOverview() async {
monthlyExpenseList.value = response.data; final projectId = projectController.selectedProjectId.value;
logSafe('Monthly Expense Report fetched successfully.', if (projectId.isEmpty) return;
level: LogLevel.info);
} else { await _executeApiCall(isPurchaseInvoiceLoading, () async {
monthlyExpenseList.clear(); final response = await ApiService.getPurchaseInvoiceOverview(
logSafe('Failed to fetch Monthly Expense Report.', projectId: projectId,
level: LogLevel.error); );
} purchaseInvoiceOverviewData.value =
} catch (e, st) { (response?.success == true) ? response!.data : null;
monthlyExpenseList.clear(); });
logSafe('Error fetching Monthly Expense Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isMonthlyExpenseLoading.value = false;
}
} }
Future<void> fetchPendingExpenses() async { Future<void> fetchPendingExpenses() async {
final String projectId = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (id.isEmpty) return;
try { await _executeApiCall(isPendingExpensesLoading, () async {
isPendingExpensesLoading.value = true; final response = await ApiService.getPendingExpensesApi(projectId: id);
final response = pendingExpensesData.value =
await ApiService.getPendingExpensesApi(projectId: projectId); (response?.success == true) ? response!.data : null;
});
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 String projectId = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (id.isEmpty) return;
try { await _executeApiCall(isAttendanceLoading, () async {
isAttendanceLoading.value = true; final response = await ApiService.getDashboardAttendanceOverview(
final List<dynamic>? response = id, getAttendanceDays());
await ApiService.getDashboardAttendanceOverview( roleWiseData.value =
projectId, getAttendanceDays()); response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
});
if (response != null) {
roleWiseData.value =
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 startDate, required DateTime endDate}) async {
required DateTime endDate, final id = projectController.selectedProjectId.value;
}) async { if (id.isEmpty) return;
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: projectId, projectId: id,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate,
); );
expenseTypeReportData.value =
if (response != null && response.success) { (response?.success == true) ? response!.data : null;
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 String projectId = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (id.isEmpty) return;
try { await _executeApiCall(isProjectLoading, () async {
isProjectLoading.value = true;
final response = await ApiService.getProjectProgress( final response = await ApiService.getProjectProgress(
projectId: projectId, days: getProjectDays()); projectId: id, days: getProjectDays());
if (response?.success == true) {
if (response != null && response.success) { projectChartData.value = response!.data
projectChartData.value = .map((d) => ChartTaskData.fromProjectData(d))
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList(); .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 {
if (projectId.isEmpty) return; await _executeApiCall(isTasksLoading, () async {
try {
isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId); final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) {
if (response != null && response.success) { totalTasks.value = response!.data?.totalTasks ?? 0;
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 {
if (projectId.isEmpty) return; await _executeApiCall(isTeamsLoading, () async {
try {
isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId); final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) {
if (response != null && response.success) { totalEmployees.value = response!.data?.totalEmployees ?? 0;
totalEmployees.value = response.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0; inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else { } else {
totalEmployees.value = 0; totalEmployees.value = 0;
inToday.value = 0; inToday.value = 0;
logSafe('Failed to fetch teams', level: LogLevel.error);
} }
} catch (e, st) { });
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Error fetching teams',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTeamsLoading.value = false;
}
} }
} }

View File

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

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -50,10 +51,22 @@ class AddExpenseController extends GetxController {
final isEditMode = false.obs; final isEditMode = false.obs;
final isSearchingEmployees = false.obs; final isSearchingEmployees = false.obs;
// --- Paid By (Single + Multi Selection Support) ---
// single selection
final selectedPaidBy = Rxn<EmployeeModel>();
// helper setters
void setSelectedPaidBy(EmployeeModel? emp) {
selectedPaidBy.value = emp;
}
// --- Dropdown Selections & Data --- // --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>(); final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>(); final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>(); // final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs; final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>(); final selectedTransactionDate = Rxn<DateTime>();

View File

@ -14,6 +14,7 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.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/finance/expense_category_model.dart'; import 'package:on_field_work/model/finance/expense_category_model.dart';
import 'package:on_field_work/model/finance/currency_list_model.dart'; import 'package:on_field_work/model/finance/currency_list_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
class AddPaymentRequestController extends GetxController { class AddPaymentRequestController extends GetxController {
// Loading States // Loading States
@ -32,7 +33,7 @@ class AddPaymentRequestController extends GetxController {
// Selected Values // Selected Values
final selectedProject = Rx<Map<String, dynamic>?>(null); final selectedProject = Rx<Map<String, dynamic>?>(null);
final selectedCategory = Rx<ExpenseCategory?>(null); final selectedCategory = Rx<ExpenseCategory?>(null);
final selectedPayee = ''.obs; final selectedPayee = Rx<EmployeeModel?>(null);
final selectedCurrency = Rx<Currency?>(null); final selectedCurrency = Rx<Currency?>(null);
final isAdvancePayment = false.obs; final isAdvancePayment = false.obs;
final selectedDueDate = Rx<DateTime?>(null); final selectedDueDate = Rx<DateTime?>(null);
@ -161,7 +162,7 @@ class AddPaymentRequestController extends GetxController {
try { try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera); final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) { if (pickedFile != null) {
isProcessingAttachment.value = true; isProcessingAttachment.value = true;
File imageFile = File(pickedFile.path); File imageFile = File(pickedFile.path);
// Add timestamp to the captured image // Add timestamp to the captured image
@ -184,7 +185,7 @@ class AddPaymentRequestController extends GetxController {
selectedProject.value = project; selectedProject.value = project;
void selectCategory(ExpenseCategory category) => void selectCategory(ExpenseCategory category) =>
selectedCategory.value = category; selectedCategory.value = category;
void selectPayee(String payee) => selectedPayee.value = payee; void selectPayee(EmployeeModel payee) => selectedPayee.value = payee;
void selectCurrency(Currency currency) => selectedCurrency.value = currency; void selectCurrency(Currency currency) => selectedCurrency.value = currency;
void addAttachment(File file) => attachments.add(file); void addAttachment(File file) => attachments.add(file);
@ -268,7 +269,7 @@ class AddPaymentRequestController extends GetxController {
"amount": double.tryParse(amountController.text.trim()) ?? 0, "amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '', "currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(), "description": descriptionController.text.trim(),
"payee": selectedPayee.value, "payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(), "dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value, "isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) { "billAttachments": billAttachments.map((a) {
@ -337,7 +338,7 @@ class AddPaymentRequestController extends GetxController {
"amount": double.tryParse(amountController.text.trim()) ?? 0, "amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '', "currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(), "description": descriptionController.text.trim(),
"payee": selectedPayee.value, "payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(), "dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value, "isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) { "billAttachments": billAttachments.map((a) {
@ -388,7 +389,7 @@ class AddPaymentRequestController extends GetxController {
return _errorSnackbar("Please select a project"); return _errorSnackbar("Please select a project");
if (selectedCategory.value == null) if (selectedCategory.value == null)
return _errorSnackbar("Please select a category"); return _errorSnackbar("Please select a category");
if (selectedPayee.value.isEmpty) if (selectedPayee.value == null)
return _errorSnackbar("Please select a payee"); return _errorSnackbar("Please select a payee");
if (selectedCurrency.value == null) if (selectedCurrency.value == null)
return _errorSnackbar("Please select currency"); return _errorSnackbar("Please select currency");
@ -408,7 +409,7 @@ class AddPaymentRequestController extends GetxController {
descriptionController.clear(); descriptionController.clear();
selectedProject.value = null; selectedProject.value = null;
selectedCategory.value = null; selectedCategory.value = null;
selectedPayee.value = ''; selectedPayee.value = null;
selectedCurrency.value = null; selectedCurrency.value = null;
isAdvancePayment.value = false; isAdvancePayment.value = false;
attachments.clear(); attachments.clear();

View File

@ -281,6 +281,7 @@ class PaymentRequestDetailController extends GetxController {
String? tdsPercentage, String? tdsPercentage,
}) async { }) async {
isLoading.value = true; isLoading.value = true;
try { try {
final success = await ApiService.updateExpensePaymentRequestStatusApi( final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId, paymentRequestId: _requestId,
@ -295,25 +296,14 @@ class PaymentRequestDetailController extends GetxController {
); );
if (success) { if (success) {
showAppSnackbar( // Controller refreshes the data but does not show snackbars.
title: 'Success',
message: 'Payment submitted successfully',
type: SnackbarType.success);
await fetchPaymentRequestDetail(); await fetchPaymentRequestDetail();
paymentRequestController.fetchPaymentRequests(); paymentRequestController.fetchPaymentRequests();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status. Please try again.',
type: SnackbarType.error);
} }
return success; return success;
} catch (e) { } catch (e) {
showAppSnackbar( // Controller returns false on error; UI will show the snackbar.
title: 'Error',
message: 'Something went wrong: $e',
type: SnackbarType.error);
return false; return false;
} finally { } finally {
isLoading.value = false; isLoading.value = false;

View File

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

View File

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

View File

@ -15,6 +15,9 @@ 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();
@ -52,6 +55,10 @@ 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);
@ -103,7 +110,7 @@ class PermissionController extends GetxController {
} }
void _startAutoRefresh() { void _startAutoRefresh() {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { _refreshTimer = Timer.periodic(const 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) {
@ -117,8 +124,6 @@ class PermissionController extends GetxController {
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
// logSafe("Checking permission $permissionId: $hasPerm",
// level: LogLevel.debug);
return hasPerm; return hasPerm;
} }

View File

@ -63,26 +63,34 @@ class AddServiceProjectJobController extends GetxController {
return; return;
} }
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
isLoading.value = true; isLoading.value = true;
final success = await ApiService.createServiceProjectJobApi( final jobId = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(), title: titleCtrl.text.trim(),
description: descCtrl.text.trim(), description: descCtrl.text.trim(),
projectId: projectId, projectId: projectId,
branchId: selectedBranch.value?.id, branchId: selectedBranch.value?.id,
assignees: assigneeIds.map((id) => {"id": id}).toList(), assignees: selectedAssignees // payload mapping
.map((e) => {"employeeId": e.id, "isActive": true})
.toList(),
startDate: startDate.value!, startDate: startDate.value!,
dueDate: dueDate.value!, dueDate: dueDate.value!,
tags: enteredTags.map((tag) => {"name": tag}).toList(), tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true})
.toList(),
); );
isLoading.value = false; isLoading.value = false;
if (success) { if (jobId != null) {
if (Get.isRegistered<ServiceProjectDetailsController>()) { if (Get.isRegistered<ServiceProjectDetailsController>()) {
Get.find<ServiceProjectDetailsController>().refreshJobsAfterAdd(); final detailsCtrl = Get.find<ServiceProjectDetailsController>();
// 🔥 1. Refresh job LIST
detailsCtrl.refreshJobsAfterAdd();
// 🔥 2. Refresh job DETAILS (FULL DATA - including tags and assignees)
await detailsCtrl.fetchJobDetail(jobId);
} }
Get.back(); Get.back();

View File

@ -6,10 +6,12 @@ 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 --------------------
@ -28,6 +30,8 @@ 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;
@ -41,7 +45,16 @@ 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() {
@ -110,6 +123,41 @@ 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";
@ -271,6 +319,91 @@ class ServiceProjectDetailsController extends GetxController {
} }
} }
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,

View File

@ -3,7 +3,7 @@ class ApiEndpoints {
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api"; // static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api"; // static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterCurrencies = "/Master/currencies/list";
@ -36,6 +36,10 @@ 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";
@ -44,6 +48,7 @@ class ApiEndpoints {
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team"; static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
@ -152,4 +157,14 @@ class ApiEndpoints {
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/list"; static const String getTeamRoles = "/master/team-roles/list";
static const String getServiceProjectBranches = "/serviceproject/branch/list"; static const String getServiceProjectBranches = "/serviceproject/branch/list";
static const String getMasterJobStatus = "/Master/job-status/list";
static const String addJobComment = "/ServiceProject/job/add/comment";
static const String getJobCommentList = "/ServiceProject/job/comment/list";
// Infra Project Module API Endpoints
static const String getInfraProjectsList = "/project/list";
static const String getInfraProjectDetail = "/project/details";
} }

View File

@ -40,6 +40,14 @@ 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;
@ -311,6 +319,274 @@ 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,
@ -595,8 +871,7 @@ class ApiService {
return null; return null;
} }
/// Create a new Service Project Job static Future<String?> createServiceProjectJobApi({
static Future<bool> createServiceProjectJobApi({
required String title, required String title,
required String description, required String description,
required String projectId, required String projectId,
@ -623,32 +898,22 @@ class ApiService {
try { try {
final response = await _postRequest(endpoint, body); final response = await _postRequest(endpoint, body);
if (response == null) { if (response == null) return null;
logSafe("Create Service Project Job failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Create Service Project Job response status: ${response.statusCode}");
logSafe("Create Service Project Job response body: ${response.body}");
final json = jsonDecode(response.body); final json = jsonDecode(response.body);
if (json['success'] == true) { if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}"); final jobId = json['data']?['id'];
return true; logSafe("Service Project Job created successfully: $jobId");
} else { return jobId;
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
} }
return null;
} catch (e, stack) { } catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e", logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error); level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe("StackTrace: $stack", level: LogLevel.debug);
return false; return null;
} }
} }
@ -671,8 +936,7 @@ class ApiService {
'pageNumber': pageNumber.toString(), 'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(), 'pageSize': pageSize.toString(),
'isActive': isActive.toString(), 'isActive': isActive.toString(),
if (isArchive) if (isArchive) 'isArchive': 'true',
'isArchive': 'true',
}; };
final response = await _getRequest(endpoint, queryParams: queryParams); final response = await _getRequest(endpoint, queryParams: queryParams);
@ -3209,6 +3473,30 @@ class ApiService {
res != null ? _parseResponse(res, label: 'Employees') : null); res != null ? _parseResponse(res, label: 'Employees') : null);
} }
static Future<List<EmployeeModel>?> getAttendanceForDashboard(
String projectId) async {
String endpoint = ApiEndpoints.getAttendanceForDashboard.replaceFirst(
':projectId',
projectId,
);
final res = await _getRequest(endpoint);
if (res == null) return null;
final data = _parseResponse(res, label: 'Dashboard Attendance');
if (data == null) return null;
// Wrap single object in a list if needed
if (data is Map<String, dynamic>) {
return [EmployeeModel.fromJson(data)];
} else if (data is List) {
return data.map((e) => EmployeeModel.fromJson(e)).toList();
}
return null;
}
static Future<List<dynamic>?> getRegularizationLogs( static Future<List<dynamic>?> getRegularizationLogs(
String projectId, { String projectId, {
String? organizationId, String? organizationId,

View File

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

View File

@ -163,6 +163,9 @@ class MenuItems {
/// Service Projects /// Service Projects
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b"; static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b";
/// Infrastructure Projects
static const String infraProjects = "5fab4b88-c9a0-417b-aca2-130980fdb0cf";
} }
/// Contains all job status IDs used across the application. /// Contains all job status IDs used across the application.

View File

@ -3,95 +3,228 @@ 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 StatelessWidget implements PreferredSizeWidget { class CustomAppBar extends StatefulWidget
with UIMixin
implements PreferredSizeWidget {
final String title; final String title;
final String? projectName; final String? projectName; // If passed, show static text
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
final Color? backgroundColor;
const CustomAppBar({ CustomAppBar({
super.key, super.key,
required this.title, required this.title,
this.projectName, this.projectName,
this.onBackPressed, this.onBackPressed,
this.backgroundColor,
}); });
@override @override
Widget build(BuildContext context) { Size get preferredSize => const Size.fromHeight(72);
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: onBackPressed ?? () => Get.back(),
),
MySpacing.width(5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// TITLE
MyText.titleLarge(
title,
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2), @override
State<CustomAppBar> createState() => _CustomAppBarState();
}
// PROJECT NAME ROW class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
GetBuilder<ProjectController>( final ProjectController projectController = Get.find();
builder: (projectController) { OverlayEntry? _overlayEntry;
// NEW LOGIC simple and safe final LayerLink _layerLink = LayerLink();
final displayProjectName =
projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row( void _toggleDropdown() {
children: [ if (_overlayEntry == null) {
const Icon( _overlayEntry = _createOverlayEntry();
Icons.work_outline, Overlay.of(context).insert(_overlayEntry!);
size: 14, } else {
color: Colors.grey, _overlayEntry?.remove();
), _overlayEntry = null;
MySpacing.width(4), }
Expanded( }
child: MyText.bodySmall(
displayProjectName, OverlayEntry _createOverlayEntry() {
fontWeight: 600, final renderBox = context.findRenderObject() as RenderBox;
overflow: TextOverflow.ellipsis, final size = renderBox.size;
color: Colors.grey[700], 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
Size get preferredSize => const Size.fromHeight(72); Widget build(BuildContext context) {
final Color effectiveBackgroundColor =
widget.backgroundColor ?? contentTheme.primary;
const Color onPrimaryColor = Colors.white;
final bool showDropdown = widget.projectName == null;
return AppBar(
backgroundColor: effectiveBackgroundColor,
elevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 0,
shadowColor: Colors.transparent,
leading: Padding(
padding: MySpacing.only(left: 16),
child: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: onPrimaryColor,
size: 20,
),
onPressed: widget.onBackPressed ?? () => Get.back(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
title: Padding(
padding: MySpacing.only(right: 16, left: 8),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(
widget.title,
fontWeight: 800,
color: onPrimaryColor,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
MySpacing.height(3),
showDropdown
? CompositedTransformTarget(
link: _layerLink,
child: GestureDetector(
onTap: _toggleDropdown,
child: Row(
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: Obx(() {
final projectName = projectController
.selectedProject?.name ??
'Select Project';
return MyText.bodySmall(
projectName,
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: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: MyText.bodySmall(
widget.projectName!,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
],
),
),
],
),
),
actions: [
Padding(
padding: MySpacing.only(right: 16),
child: IconButton(
icon: const Icon(Icons.home, color: onPrimaryColor, size: 24),
onPressed: () => Get.offAllNamed('/dashboard'),
),
),
],
);
}
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
} }

View File

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

View File

@ -0,0 +1,717 @@
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(),
);
}
}

View File

@ -95,7 +95,7 @@ class _SearchAndFilterState extends State<SearchAndFilter> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0), padding: MySpacing.fromLTRB(12, 10, 12, 8),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@ -179,13 +179,6 @@ class ToggleButtonsRow extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F0F0), color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@ -286,82 +279,84 @@ class ExpenseList extends StatelessWidget {
return Center(child: MyText.bodyMedium('No expenses found.')); return Center(child: MyText.bodyMedium('No expenses found.'));
} }
return ListView.separated( return SafeArea(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), bottom: true,
itemCount: expenseList.length, child: ListView.separated(
separatorBuilder: (_, __) => padding: const EdgeInsets.fromLTRB(12, 12, 12, 100),
Divider(color: Colors.grey.shade300, height: 20), itemCount: expenseList.length,
itemBuilder: (context, index) { separatorBuilder: (_, __) =>
final expense = expenseList[index]; Divider(color: Colors.grey.shade300, height: 20),
final formattedDate = DateTimeUtils.convertUtcToLocal( itemBuilder: (context, index) {
expense.transactionDate.toIso8601String(), final expense = expenseList[index];
format: 'dd MMM yyyy', final formattedDate = DateTimeUtils.convertUtcToLocal(
); expense.transactionDate.toIso8601String(),
format: 'dd MMM yyyy',
);
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () async { onTap: () async {
await Get.to( await Get.to(() => ExpenseDetailScreen(expenseId: expense.id));
() => ExpenseDetailScreen(expenseId: expense.id), },
); child: Padding(
}, padding: const EdgeInsets.symmetric(vertical: 8),
child: Padding( child: Column(
padding: const EdgeInsets.symmetric(vertical: 8), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Row(
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Row( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, MyText.bodyMedium(expense.expenseCategory.name,
children: [ fontWeight: 600),
MyText.bodyMedium(expense.expenseCategory.name, Row(
fontWeight: 600), children: [
Row( MyText.bodyMedium('${expense.formattedAmount}',
children: [ fontWeight: 600),
MyText.bodyMedium('${expense.formattedAmount}', if (expense.status.name.toLowerCase() ==
fontWeight: 600), 'draft') ...[
if (expense.status.name.toLowerCase() == 'draft') ...[ const SizedBox(width: 8),
const SizedBox(width: 8), GestureDetector(
GestureDetector( onTap: () =>
onTap: () => _showDeleteConfirmation(context, expense),
_showDeleteConfirmation(context, expense), child: const Icon(Icons.delete,
child: const Icon(Icons.delete, color: Colors.red, size: 20),
color: Colors.red, size: 20), ),
), ],
], ],
],
),
],
),
const SizedBox(height: 6),
Row(
children: [
MyText.bodySmall(formattedDate, fontWeight: 500),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(int.parse(
'0xff${expense.status.color.substring(1)}'))
.withOpacity(0.5),
borderRadius: BorderRadius.circular(5),
), ),
child: MyText.bodySmall( ],
expense.status.name, ),
color: Colors.white, const SizedBox(height: 6),
fontWeight: 500, Row(
children: [
MyText.bodySmall(formattedDate, fontWeight: 500),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(int.parse(
'0xff${expense.status.color.substring(1)}'))
.withOpacity(0.5),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
expense.status.name,
color: Colors.white,
fontWeight: 500,
),
), ),
), ],
], ),
), ],
], ),
), ),
), ),
), );
); },
}, ),
); );
} }
} }

View File

@ -1,14 +1,27 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
import 'package:share_plus/share_plus.dart';
import 'package:gallery_saver_plus/gallery_saver.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
class ImageViewerDialog extends StatefulWidget { class ImageViewerDialog extends StatefulWidget {
final List<dynamic> imageSources; final List<dynamic> imageSources;
final int initialIndex; final int initialIndex;
final List<String>? captions;
final String? title;
const ImageViewerDialog({ const ImageViewerDialog({
Key? key, Key? key,
required this.imageSources, required this.imageSources,
required this.initialIndex, required this.initialIndex,
this.captions,
this.title,
}) : super(key: key); }) : super(key: key);
@override @override
@ -28,93 +41,303 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
_controller = PageController(initialPage: widget.initialIndex); _controller = PageController(initialPage: widget.initialIndex);
} }
Future<void> shareImage(dynamic image) async {
try {
if (isFile(image)) {
await Share.shareXFiles([XFile(image.path)],
text: 'Check out this image!');
} else if (image is String) {
await Share.share(image, subject: 'Check out this image!');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to share image: $e')),
);
}
}
Future<void> downloadImage(dynamic image) async {
try {
if (isFile(image)) {
await GallerySaver.saveImage(image.path);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Image saved to gallery')),
);
} else if (image is String) {
final response = await http.get(Uri.parse(image));
final bytes = response.bodyBytes;
final tempDir = await getTemporaryDirectory();
final filePath =
'${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}.png';
final file = await File(filePath).writeAsBytes(bytes);
await GallerySaver.saveImage(file.path);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Image saved to gallery')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save image: $e')),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double dialogHeight = MediaQuery.of(context).size.height * 0.55; return Scaffold(
backgroundColor: Colors.black,
return Dialog( body: Stack(
backgroundColor: Colors.transparent, children: [
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 100), Container(
child: ClipRRect( decoration: const BoxDecoration(
borderRadius: BorderRadius.circular(16), gradient: LinearGradient(
child: Container( colors: [Color(0xFF101018), Color(0xFF050509)],
height: dialogHeight, begin: Alignment.topCenter,
decoration: BoxDecoration( end: Alignment.bottomCenter,
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
), ),
], ),
), ),
child: Column(
children: [
// Top Close Button
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: const Icon(Icons.close, size: 26),
onPressed: () => Navigator.of(context).pop(),
splashRadius: 22,
tooltip: 'Close',
),
),
// Image Viewer // Add vertical padding to avoid overlap with header/footer
Expanded( Padding(
child: PageView.builder( padding:
controller: _controller, const EdgeInsets.only(top: 72, bottom: 110), // Adjust as needed
itemCount: widget.imageSources.length, child: PageView.builder(
onPageChanged: (index) { controller: _controller,
setState(() => currentIndex = index); itemCount: widget.imageSources.length,
}, onPageChanged: (index) => setState(() => currentIndex = index),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = widget.imageSources[index]; final item = widget.imageSources[index];
return Padding( final ImageProvider provider = isFile(item)
padding: const EdgeInsets.symmetric(horizontal: 12), ? FileImage(item)
child: isFile(item) : CachedNetworkImageProvider(item);
? Image.file(item, fit: BoxFit.contain)
: Image.network( return Center(
item, child: ClipRRect(
fit: BoxFit.contain, borderRadius: BorderRadius.circular(20),
loadingBuilder: (context, child, loadingProgress) { child: PhotoView(
if (loadingProgress == null) return child; imageProvider: provider,
return Center( backgroundDecoration:
child: CircularProgressIndicator( const BoxDecoration(color: Colors.transparent),
value: loadingProgress.expectedTotalBytes != null loadingBuilder: (context, event) =>
? loadingProgress.cumulativeBytesLoaded / const Center(child: CircularProgressIndicator()),
(loadingProgress.expectedTotalBytes ?? 1) errorBuilder: (context, error, stackTrace) =>
: null, const Center(
), child: Icon(Icons.broken_image,
); size: 64, color: Colors.white54),
}, ),
errorBuilder: (context, error, stackTrace) => minScale: PhotoViewComputedScale.contained,
const Center( maxScale: PhotoViewComputedScale.covered * 3,
child: Icon(Icons.broken_image, size: 48, color: Colors.grey), ),
), ),
);
},
),
),
// Top blurred app bar with back button and title
SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
height: 50,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.4),
blurRadius: 8,
offset: Offset(0, 3),
),
],
),
child: Row(
children: [
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () => Navigator.of(context).pop(),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.arrow_back_ios_new_rounded,
color: Colors.white, size: 24),
), ),
); ),
}, ),
), const SizedBox(width: 8),
), Expanded(
child: Text(
// Index Indicator widget.title ?? 'Preview',
Padding( style: const TextStyle(
padding: const EdgeInsets.only(top: 8, bottom: 12), color: Colors.white,
child: Text( fontSize: 18,
'${currentIndex + 1} / ${widget.imageSources.length}', fontWeight: FontWeight.w600,
style: const TextStyle( letterSpacing: 0.5,
color: Colors.black87, ),
fontSize: 14, maxLines: 1,
fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis,
),
),
],
),
), ),
), ),
), ),
], ),
), ),
),
// Bottom control panel with blur and rounded corners
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.4),
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'${currentIndex + 1}/${widget.imageSources.length}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(width: 16),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.imageSources.length, (index) {
final bool active = index == currentIndex;
return AnimatedContainer(
duration:
const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(
horizontal: 4),
height: 8,
width: active ? 22 : 8,
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white54,
borderRadius: BorderRadius.circular(30),
boxShadow: active
? [
BoxShadow(
color: Colors.white70,
blurRadius: 6,
spreadRadius: 1,
)
]
: [],
),
);
}),
),
),
const SizedBox(width: 16),
Tooltip(
message: 'Download Image',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () async {
final confirmed = await Get.dialog<bool>(
ConfirmDialog(
title: "Download Image",
message:
"Do you want to download this image to your device?",
confirmText: "Download",
cancelText: "Cancel",
onConfirm: () async {
// Call your existing download method here for the current image
await downloadImage(widget
.imageSources[currentIndex]);
},
confirmColor: Colors.blueAccent,
confirmIcon: Icons.download_rounded,
icon: Icons.download_rounded,
errorMessage:
"Failed to download image. Please try again.",
loadingText: "Downloading…",
),
barrierDismissible: false,
);
// Optionally handle if the dialog was cancelled (confirmed == false or null)
if (confirmed != true) {
// Download cancelled by user
}
},
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(Icons.download_rounded,
color: Colors.white, size: 28),
),
),
),
),
],
),
if (widget.captions != null &&
widget.captions!.isNotEmpty &&
currentIndex < widget.captions!.length)
Padding(
padding: const EdgeInsets.only(top: 8),
child: AnimatedOpacity(
opacity: 1.0,
duration: const Duration(milliseconds: 400),
child: Text(
widget.captions![currentIndex],
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
),
),
),
),
],
), ),
); );
} }

View File

@ -68,7 +68,7 @@ class ConfirmDialog extends StatelessWidget {
maxWidth: 480, maxWidth: 480,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16), padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: _ContentView( child: _ContentView(
title: title, title: title,
message: message, message: message,
@ -158,59 +158,60 @@ class _ContentView extends StatelessWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.6), color: colorScheme.outlineVariant.withValues(alpha: 0.6),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Align( Align(
alignment: Alignment.center, alignment: Alignment.center,
child: SizedBox( child: SizedBox(
width: double.infinity, // allow full available width width: double.infinity,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Obx( child: Obx(
() => _DialogButton( () => _DialogButton(
text: cancelText, text: cancelText,
icon: cancelIcon, icon: cancelIcon,
color: Colors.transparent, color: Colors.transparent,
textColor: colorScheme.onSurface, textColor: colorScheme.onSurface,
isFilled: false, isFilled: false,
isLoading: false, isLoading: false,
onPressed: loading.value ? null : () => Navigator.pop(context, false), onPressed: loading.value
? null
: () => Navigator.pop(context, false),
),
),
),
const SizedBox(width: 20),
Expanded(
child: Obx(
() => _DialogButton(
text: loading.value ? loadingText : confirmText,
icon: confirmIcon,
color: confirmColor,
textColor: Colors.white,
isFilled: true,
isLoading: loading.value,
onPressed: () async {
try {
loading.value = true;
await onConfirm();
Navigator.pop(context, true);
} catch (e) {
showAppSnackbar(
title: "Error",
message: errorMessage,
type: SnackbarType.error,
);
} finally {
loading.value = false;
}
},
),
),
),
],
), ),
), ),
), ),
const SizedBox(width: 20),
Expanded(
child: Obx(
() => _DialogButton(
text: loading.value ? loadingText : confirmText,
icon: confirmIcon,
color: confirmColor,
textColor: Colors.white,
isFilled: true,
isLoading: loading.value,
onPressed: () async {
try {
loading.value = true;
await onConfirm();
Navigator.pop(context, true);
} catch (e) {
showAppSnackbar(
title: "Error",
message: errorMessage,
type: SnackbarType.error,
);
} finally {
loading.value = false;
}
},
),
),
),
],
),
),
),
], ],
); );
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -6,7 +6,6 @@ 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 {
@ -55,38 +54,37 @@ Widget _buildErrorApp() => const MaterialApp(
), ),
); );
class MainWrapper extends StatefulWidget { class MainWrapper extends StatelessWidget {
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) {
final bool isOffline = // 1. Use FutureBuilder to check the current connectivity status ONCE.
_connectivityStatus.contains(ConnectivityResult.none); return FutureBuilder<List<ConnectivityResult>>(
return isOffline future: Connectivity().checkConnectivity(),
? const MaterialApp( builder: (context, initialSnapshot) {
debugShowCheckedModeBanner: false, home: OfflineScreen()) // If the initial check is still running, display a standard loading screen.
: const MyApp(); if (!initialSnapshot.hasData) {
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);
},
);
},
);
} }
} }

View File

@ -23,6 +23,7 @@ class AttendanceActionButton extends StatefulWidget {
} }
class _AttendanceActionButtonState extends State<AttendanceActionButton> { class _AttendanceActionButtonState extends State<AttendanceActionButton> {
final attendanceController = Get.find<AttendanceController>();
late final String uniqueLogKey; late final String uniqueLogKey;
@override @override
@ -189,12 +190,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) { if (selectedProjectId.isNotEmpty) {
await controller.fetchTodaysAttendance(selectedProjectId); await attendanceController.fetchProjectData(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId); attendanceController.update(['attendance_dashboard_controller']);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
controller.update();
} }
} }
@ -273,13 +271,9 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12), textStyle: const TextStyle(fontSize: 12),
), ),
child: isUploading child: isUploading
? Container( ? const Text(
width: 60, 'Loading...',
height: 14, style: TextStyle(fontSize: 12, color: Colors.white),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
) )
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -2,10 +2,8 @@ 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 {
@ -27,21 +25,6 @@ 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,
@ -51,12 +34,8 @@ 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) => items itemBuilder: (context) =>
.map((e) => PopupMenuItem<String>( items.map((e) => PopupMenuItem<String>(value: e, child: MyText(e))).toList(),
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(
@ -107,48 +86,11 @@ class _AttendanceFilterBottomSheetState
); );
} }
List<Widget> buildMainFilters() { List<Widget> _buildFilters() {
final hasRegularizationPermission = widget.permissionController final List<Widget> widgets = [];
.hasPermission(Permissions.regularizeAttendance);
final viewOptions = [ // Organization selector
{'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 = [
// 🔹 View Section
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 filter
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(
@ -165,24 +107,6 @@ class _AttendanceFilterBottomSheetState
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
); );
} else if (widget.controller.organizations.isEmpty) { } else if (widget.controller.organizations.isEmpty) {
return Center( return Center(
@ -200,8 +124,8 @@ class _AttendanceFilterBottomSheetState
}), }),
]); ]);
// 🔹 Date Range (only for Attendance Logs) // Date range (only for Attendance Logs)
if (tempSelectedTab == 'attendanceLogs') { if (widget.selectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
const Divider(), const Divider(),
Padding( Padding(
@ -211,14 +135,12 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600), child: MyText.titleSmall("Date Range", fontWeight: 600),
), ),
), ),
// Reusable DateRangePickerWidget
DateRangePickerWidget( DateRangePickerWidget(
startDate: widget.controller.startDateAttendance, startDate: widget.controller.startDateAttendance,
endDate: widget.controller.endDateAttendance, endDate: widget.controller.endDateAttendance,
startLabel: "Start Date", startLabel: "Start Date",
endLabel: "End Date", endLabel: "End Date",
onDateRangeSelected: (start, end) { onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
setState(() {}); setState(() {});
}, },
), ),
@ -230,19 +152,23 @@ class _AttendanceFilterBottomSheetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return SafeArea(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
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: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.only(bottom: 24),
children: buildMainFilters(), child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildFilters(),
),
),
), ),
), ),
); );

View File

@ -12,6 +12,8 @@ import 'package:on_field_work/helpers/widgets/tenant/organization_selector.dart'
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart'; import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart'; import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/model/tenant/tenant_services_model.dart'; import 'package:on_field_work/model/tenant/tenant_services_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/employees/multiple_select_role_bottomsheet.dart';
class AssignTaskBottomSheet extends StatefulWidget { class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation; final String workLocation;
@ -43,14 +45,15 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final DailyTaskPlanningController controller = Get.find(); final DailyTaskPlanningController controller = Get.find();
final ProjectController projectController = Get.find(); final ProjectController projectController = Get.find();
final OrganizationController orgController = Get.put(OrganizationController()); final OrganizationController orgController =
Get.put(OrganizationController());
final ServiceController serviceController = Get.put(ServiceController()); final ServiceController serviceController = Get.put(ServiceController());
final TextEditingController targetController = TextEditingController(); final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId; String? selectedProjectId;
String? selectedRoleId;
Organization? selectedOrganization; Organization? selectedOrganization;
Service? selectedService; Service? selectedService;
@ -79,12 +82,14 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
serviceId: selectedService?.id, serviceId: selectedService?.id,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id); await controller.fetchTaskData(
selectedProjectId,
serviceId: selectedService?.id,
);
} }
@override @override
void dispose() { void dispose() {
_employeeListScrollController.dispose();
targetController.dispose(); targetController.dispose();
descriptionController.dispose(); descriptionController.dispose();
super.dispose(); super.dispose();
@ -92,20 +97,21 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() => BaseBottomSheet( return Obx(
title: "Assign Task", () => BaseBottomSheet(
child: _buildAssignTaskForm(), title: "Assign Task",
onCancel: () => Get.back(), child: _buildAssignTaskForm(),
onSubmit: _onAssignTaskPressed, onCancel: () => Get.back(),
isSubmitting: controller.isAssigningTask.value, onSubmit: _onAssignTaskPressed,
)); isSubmitting: controller.isAssigningTask.value,
),
);
} }
Widget _buildAssignTaskForm() { Widget _buildAssignTaskForm() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Organization Selector
SizedBox( SizedBox(
height: 50, height: 50,
child: OrganizationSelector( child: OrganizationSelector(
@ -117,9 +123,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}, },
), ),
), ),
MySpacing.height(12), MySpacing.height(12),
// Service Selector
SizedBox( SizedBox(
height: 50, height: 50,
child: ServiceSelector( child: ServiceSelector(
@ -131,49 +137,75 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}, },
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
_infoRow(Icons.location_on, "Work Location",
// Work Location Info "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
const Divider(),
_infoRow( _infoRow(
Icons.location_on, Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
"Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}",
),
const Divider(), const Divider(),
// Pending Task Info
_infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Role Selector
GestureDetector( GestureDetector(
onTap: _onRoleMenuPressed, onTap: _onRoleMenuPressed,
child: Row( child: Row(children: [
children: [ MyText.titleMedium("Select Team :", fontWeight: 600),
MyText.titleMedium("Select Team :", fontWeight: 600), const SizedBox(width: 4),
const SizedBox(width: 4), const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), ]),
],
),
), ),
MySpacing.height(8), MySpacing.height(8),
// Employee List /// TEAM SELECT BOX
Container( GestureDetector(
constraints: const BoxConstraints(maxHeight: 180), onTap: _openEmployeeSelectionSheet,
decoration: BoxDecoration( child: Container(
border: Border.all(color: Colors.grey.shade300), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
borderRadius: BorderRadius.circular(6), decoration: BoxDecoration(
), border: Border.all(color: Colors.grey.shade300),
child: _buildEmployeeList(), borderRadius: BorderRadius.circular(6),
), ),
MySpacing.height(8), child: Row(
children: [
const Icon(Icons.group, color: Colors.black54),
const SizedBox(width: 10),
// Selected Employees Chips // Expanded name area
Expanded(
child: Obx(() {
final count = controller.selectedEmployees.length;
if (count == 0) {
return MyText(
"Select team members",
color: Colors.grey.shade700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
final names = controller.selectedEmployees
.map((e) => e.name)
.join(", ");
return Text(
names,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
);
}),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
)),
),
MySpacing.height(8),
_buildSelectedEmployees(), _buildSelectedEmployees(),
MySpacing.height(8), MySpacing.height(8),
// Target Input
_buildTextField( _buildTextField(
icon: Icons.track_changes, icon: Icons.track_changes,
label: "Target for Today :", label: "Target for Today :",
@ -182,9 +214,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validatorType: "target", validatorType: "target",
), ),
MySpacing.height(16), MySpacing.height(16),
// Description Input
_buildTextField( _buildTextField(
icon: Icons.description, icon: Icons.description,
label: "Description :", label: "Description :",
@ -198,7 +230,8 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
} }
void _onRoleMenuPressed() { void _onRoleMenuPressed() {
final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final Size screenSize = overlay.size; final Size screenSize = overlay.size;
showMenu( showMenu(
@ -211,69 +244,18 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
), ),
items: [ items: [
const PopupMenuItem(value: 'all', child: Text("All Roles")), const PopupMenuItem(value: 'all', child: Text("All Roles")),
...controller.roles.map((role) { ...controller.roles.map(
return PopupMenuItem( (role) => PopupMenuItem(
value: role['id'].toString(), value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'), child: Text(role['name'] ?? 'Unknown Role'),
); ),
}), ),
], ],
).then((value) { ).then((value) {
if (value != null) controller.onRoleSelected(value == 'all' ? null : value); if (value != null) {
}); selectedRoleId = value == 'all' ? null : value;
} controller.onRoleSelected(selectedRoleId);
Widget _buildEmployeeList() {
return Obx(() {
if (controller.isFetchingEmployees.value) {
return Center(child: CircularProgressIndicator());
} }
final filteredEmployees = controller.selectedRoleId.value == null
? controller.employees
: controller.employees
.where((e) => e.jobRoleID.toString() == controller.selectedRoleId.value)
.toList();
if (filteredEmployees.isEmpty) {
return Center(child: Text("No employees available for selected role."));
}
return Scrollbar(
controller: _employeeListScrollController,
thumbVisibility: true,
child: ListView.builder(
controller: _employeeListScrollController,
itemCount: filteredEmployees.length,
itemBuilder: (context, index) {
final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id];
return Obx(() => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: Checkbox(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
value: rxBool?.value ?? false,
onChanged: (selected) {
if (rxBool != null) {
rxBool.value = selected ?? false;
controller.updateSelectedEmployees();
}
},
fillColor: MaterialStateProperty.resolveWith((states) =>
states.contains(MaterialState.selected)
? const Color.fromARGB(255, 95, 132, 255)
: Colors.transparent),
checkColor: Colors.white,
side: const BorderSide(color: Colors.black),
),
title: Text(employee.name, style: const TextStyle(fontSize: 14)),
visualDensity: VisualDensity.compact,
));
},
),
);
}); });
} }
@ -285,20 +267,14 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: controller.selectedEmployees.map((e) { children: controller.selectedEmployees.map((e) {
return Obx(() { return Chip(
final isSelected = controller.uploadingStates[e.id]?.value ?? false; label: Text(e.name, style: const TextStyle(color: Colors.white)),
if (!isSelected) return Container(); backgroundColor: const Color.fromARGB(255, 95, 132, 255),
deleteIcon: const Icon(Icons.close, color: Colors.white),
return Chip( onDeleted: () {
label: Text(e.name, style: const TextStyle(color: Colors.white)), controller.selectedEmployees.remove(e);
backgroundColor: const Color.fromARGB(255, 95, 132, 255), },
deleteIcon: const Icon(Icons.close, color: Colors.white), );
onDeleted: () {
controller.uploadingStates[e.id]?.value = false;
controller.updateSelectedEmployees();
},
);
});
}).toList(), }).toList(),
); );
}); });
@ -328,10 +304,15 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
maxLines: maxLines, maxLines: maxLines,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)), border: OutlineInputBorder(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), borderRadius: BorderRadius.circular(6),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
validator: (value) => this.controller.formFieldValidator(value, fieldType: validatorType), validator: (value) => this
.controller
.formFieldValidator(value, fieldType: validatorType),
), ),
], ],
); );
@ -350,32 +331,70 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
text: TextSpan( text: TextSpan(
children: [ children: [
WidgetSpan( WidgetSpan(
child: MyText.titleMedium("$title: ", fontWeight: 600, color: Colors.black), child: MyText.titleMedium(
"$title: ",
fontWeight: 600,
color: Colors.black,
),
),
TextSpan(
text: value,
style: const TextStyle(color: Colors.black),
), ),
TextSpan(text: value, style: const TextStyle(color: Colors.black)),
], ],
), ),
), ),
), )
], ],
), ),
); );
} }
Future<void> _openEmployeeSelectionSheet() async {
final result = await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: MultipleSelectRoleBottomSheet(
projectId: selectedProjectId!,
organizationId: selectedOrganization?.id,
serviceId: selectedService?.id,
roleId: selectedRoleId,
initiallySelected: controller.selectedEmployees.toList(),
scrollController: ScrollController(),
),
),
);
if (result != null) {
controller.selectedEmployees.assignAll(result);
}
}
void _onAssignTaskPressed() { void _onAssignTaskPressed() {
final selectedTeam = controller.uploadingStates.entries final selectedTeam = controller.selectedEmployees;
.where((e) => e.value.value)
.map((e) => e.key)
.toList();
if (selectedTeam.isEmpty) { if (selectedTeam.isEmpty) {
showAppSnackbar(title: "Team Required", message: "Please select at least one team member", type: SnackbarType.error); showAppSnackbar(
title: "Team Required",
message: "Please select at least one team member",
type: SnackbarType.error,
);
return; return;
} }
final target = double.tryParse(targetController.text.trim()); final target = double.tryParse(targetController.text.trim());
if (target == null || target <= 0) { if (target == null || target <= 0) {
showAppSnackbar(title: "Invalid Input", message: "Please enter a valid target number", type: SnackbarType.error); showAppSnackbar(
title: "Invalid Input",
message: "Please enter a valid target number",
type: SnackbarType.error,
);
return; return;
} }
@ -390,7 +409,11 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
if (description.isEmpty) { if (description.isEmpty) {
showAppSnackbar(title: "Description Required", message: "Please enter a description", type: SnackbarType.error); showAppSnackbar(
title: "Description Required",
message: "Please enter a description",
type: SnackbarType.error,
);
return; return;
} }
@ -398,7 +421,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
workItemId: widget.workItemId, workItemId: widget.workItemId,
plannedTask: target.toInt(), plannedTask: target.toInt(),
description: description, description: description,
taskTeam: selectedTeam, taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs
assignmentDate: widget.assignmentDate, assignmentDate: widget.assignmentDate,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
serviceId: selectedService?.id, serviceId: selectedService?.id,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/controller/task_planning/daily_task_controller.dart'; import 'package:on_field_work/controller/task_planning/daily_task_controller.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:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
@ -23,82 +24,85 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
filterData.services, filterData.services,
].any((list) => list.isNotEmpty); ].any((list) => list.isNotEmpty);
return BaseBottomSheet( return SafeArea(
title: "Filter Tasks", // PREVENTS GOING UNDER NAV BUTTONS
submitText: "Apply", bottom: true,
showButtons: hasFilters, child: BaseBottomSheet(
onCancel: () => Get.back(), title: "Filter Tasks",
onSubmit: () { submitText: "Apply",
if (controller.selectedProjectId != null) { showButtons: hasFilters,
controller.fetchTaskData( onCancel: () => Get.back(),
controller.selectedProjectId!, onSubmit: () {
); if (controller.selectedProjectId != null) {
} controller.fetchTaskData(controller.selectedProjectId!);
}
Get.back(); Get.back();
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
child: hasFilters padding: const EdgeInsets.only(bottom: 40), // EXTRA SAFETY PADDING
? Column( child: hasFilters
crossAxisAlignment: CrossAxisAlignment.start, ? Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Align( children: [
alignment: Alignment.centerRight, Align(
child: TextButton( alignment: Alignment.centerRight,
onPressed: () { child: TextButton(
controller.clearTaskFilters(); onPressed: () {
}, controller.clearTaskFilters();
child: MyText( },
"Reset Filter", child: MyText(
style: const TextStyle( "Reset Filter",
color: Colors.red, style: const TextStyle(
fontWeight: FontWeight.w600, color: Colors.red,
fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
), MySpacing.height(8),
MySpacing.height(8), _multiSelectField(
_multiSelectField( label: "Buildings",
label: "Buildings", items: filterData.buildings,
items: filterData.buildings, fallback: "Select Buildings",
fallback: "Select Buildings", selectedValues: controller.selectedBuildings,
selectedValues: controller.selectedBuildings, ),
), _multiSelectField(
_multiSelectField( label: "Floors",
label: "Floors", items: filterData.floors,
items: filterData.floors, fallback: "Select Floors",
fallback: "Select Floors", selectedValues: controller.selectedFloors,
selectedValues: controller.selectedFloors, ),
), _multiSelectField(
_multiSelectField( label: "Activities",
label: "Activities", items: filterData.activities,
items: filterData.activities, fallback: "Select Activities",
fallback: "Select Activities", selectedValues: controller.selectedActivities,
selectedValues: controller.selectedActivities, ),
), _multiSelectField(
_multiSelectField( label: "Services",
label: "Services", items: filterData.services,
items: filterData.services, fallback: "Select Services",
fallback: "Select Services", selectedValues: controller.selectedServices,
selectedValues: controller.selectedServices, ),
), MySpacing.height(8),
MySpacing.height(8), _dateRangeSelector(context),
_dateRangeSelector(context), ],
], )
) : Center(
: Center( child: Padding(
child: Padding( padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.all(24.0), child: MyText(
child: MyText( "No filters available",
"No filters available", style: const TextStyle(color: Colors.grey),
style: const TextStyle(color: Colors.grey), ),
), ),
), ),
), ),
), ),
); );
} }
// MULTI SELECT FIELD
Widget _multiSelectField({ Widget _multiSelectField({
required String label, required String label,
required List<dynamic> items, required List<dynamic> items,
@ -117,6 +121,7 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
.where((item) => selectedValues.contains(item.id)) .where((item) => selectedValues.contains(item.id))
.map((item) => item.name) .map((item) => item.name)
.join(", "); .join(", ");
final displayText = final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback; selectedNames.isNotEmpty ? selectedNames : fallback;
@ -146,27 +151,23 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
child: StatefulBuilder( child: StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final isChecked = selectedValues.contains(item.id); final isChecked = selectedValues.contains(item.id);
return CheckboxListTile( return CheckboxListTile(
dense: true, dense: true,
value: isChecked, value: isChecked,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name), title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white, checkColor: Colors.white,
side: const BorderSide( side: const BorderSide(
color: Colors.black, width: 1.5), color: Colors.black, width: 1.5),
fillColor: fillColor:
MaterialStateProperty.resolveWith<Color>( MaterialStateProperty.resolveWith<Color>(
(states) { (states) =>
if (states.contains(MaterialState.selected)) { states.contains(MaterialState.selected)
return Colors.indigo; ? Colors.indigo
} : Colors.white,
return Colors.white;
},
), ),
onChanged: (val) { onChanged: (val) {
if (val == true) { if (val == true) {
selectedValues.add(item.id); selectedValues.add(item.id);
@ -212,6 +213,7 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
); );
} }
// DATE RANGE PICKER
Widget _dateRangeSelector(BuildContext context) { Widget _dateRangeSelector(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

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

View File

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

View File

@ -315,15 +315,31 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
height: 48, height: 48,
child: TextField( child: TextField(
controller: tagCtrl, controller: tagCtrl,
onChanged: controller.filterSuggestions, onChanged: (value) {
if (value.endsWith(" ") || value.endsWith(",")) {
final cleaned = value.trim().replaceAll(",", "");
if (cleaned.isNotEmpty) {
controller.addEnteredTag(cleaned);
}
tagCtrl.clear();
controller.clearSuggestions();
} else {
controller.filterSuggestions(value);
}
},
onSubmitted: (v) { onSubmitted: (v) {
controller.addEnteredTag(v); if (v.trim().isNotEmpty) {
controller.addEnteredTag(v.trim());
}
tagCtrl.clear(); tagCtrl.clear();
controller.clearSuggestions(); controller.clearSuggestions();
}, },
decoration: _inputDecoration("Start typing to add tags"), decoration: _inputDecoration("Start typing to add tags"),
), ),
), ),
Obx(() => controller.filteredSuggestions.isEmpty Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox.shrink() ? const SizedBox.shrink()
: Container( : Container(
@ -353,7 +369,10 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
}, },
), ),
)), )),
MySpacing.height(8), MySpacing.height(8),
// TAG CHIPS
Obx(() => Wrap( Obx(() => Wrap(
spacing: 8, spacing: 8,
children: controller.enteredTags children: controller.enteredTags

View File

@ -9,6 +9,7 @@ import 'package:on_field_work/helpers/widgets/my_spacing.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/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/directory/contact_bucket_list_model.dart'; import 'package:on_field_work/model/directory/contact_bucket_list_model.dart';
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
class EditBucketBottomSheet { class EditBucketBottomSheet {
static void show( static void show(
@ -21,10 +22,8 @@ class EditBucketBottomSheet {
final nameController = TextEditingController(text: bucket.name); final nameController = TextEditingController(text: bucket.name);
final descController = TextEditingController(text: bucket.description); final descController = TextEditingController(text: bucket.description);
final searchController = TextEditingController();
final selectedIds = RxSet<String>({...bucket.employeeIds}); final selectedIds = RxSet<String>({...bucket.employeeIds});
final searchText = ''.obs;
InputDecoration _inputDecoration(String label) { InputDecoration _inputDecoration(String label) {
return InputDecoration( return InputDecoration(
@ -84,6 +83,15 @@ class EditBucketBottomSheet {
} }
} }
Future<void> _handleSubmitBottomSheet(BuildContext sheetContext) async {
await _handleSubmit();
// close bottom sheet safely
if (Navigator.of(sheetContext).canPop()) {
Navigator.of(sheetContext).pop();
}
}
Widget _formContent() { Widget _formContent() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -101,117 +109,72 @@ class EditBucketBottomSheet {
MySpacing.height(20), MySpacing.height(20),
MyText.labelLarge('Shared With', fontWeight: 600), MyText.labelLarge('Shared With', fontWeight: 600),
MySpacing.height(8), MySpacing.height(8),
Obx(() => TextField(
controller: searchController,
onChanged: (value) => searchText.value = value.toLowerCase(),
decoration: InputDecoration(
hintText: 'Search employee...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: searchText.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
searchController.clear();
searchText.value = '';
},
)
: null,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
)),
MySpacing.height(8),
Obx(() { Obx(() {
final filtered = allEmployees.where((emp) { if (selectedIds.isEmpty) return const SizedBox.shrink();
final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase();
return fullName.contains(searchText.value);
}).toList();
return SizedBox( final selectedEmployees =
height: 180, allEmployees.where((e) => selectedIds.contains(e.id)).toList();
child: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final emp = filtered[index];
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
return Obx(() => Theme( return Wrap(
data: Theme.of(context).copyWith( spacing: 8,
unselectedWidgetColor: Colors.grey.shade500, children: selectedEmployees.map((emp) {
checkboxTheme: CheckboxThemeData( final fullName = '${emp.firstName} ${emp.lastName}'.trim();
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4)), return Chip(
side: const BorderSide(color: Colors.grey), label: Text(fullName),
fillColor: onDeleted: emp.id == ownerId
MaterialStateProperty.resolveWith((states) { ? null
if (states.contains(MaterialState.selected)) { : () => selectedIds.remove(emp.id),
return Colors.blueAccent; );
} }).toList(),
return Colors.white;
}),
checkColor: MaterialStateProperty.all(Colors.white),
),
),
child: CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
visualDensity: const VisualDensity(vertical: -4),
controlAffinity: ListTileControlAffinity.leading,
value: selectedIds.contains(emp.id),
onChanged: emp.id == ownerId
? null
: (val) {
if (val == true) {
selectedIds.add(emp.id);
} else {
selectedIds.remove(emp.id);
}
},
title: Row(
children: [
Expanded(
child: MyText.bodyMedium(
fullName.isNotEmpty ? fullName : 'Unnamed',
fontWeight: 600,
),
),
if (emp.id == ownerId)
Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
fontWeight: 600,
color: Colors.red,
),
),
],
),
subtitle: emp.jobRole.isNotEmpty
? MyText.bodySmall(
emp.jobRole,
color: Colors.grey.shade600,
)
: null,
),
));
},
),
); );
}), }),
MySpacing.height(8),
// --- Open new EmployeeSelectionBottomSheet ---
GestureDetector(
onTap: () async {
final initiallySelected = allEmployees
.where((e) => selectedIds.contains(e.id))
.toList();
final result = await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (_) => EmployeeSelectionBottomSheet(
initiallySelected: initiallySelected,
multipleSelection: true,
title: "Shared With",
),
);
if (result != null) {
selectedIds
..clear()
..addAll(result.map((e) => e.id));
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: const [
Icon(Icons.search, color: Colors.grey),
SizedBox(width: 8),
Expanded(child: Text("Search & Select Employees")),
],
),
),
),
MySpacing.height(8),
const SizedBox.shrink(),
], ],
); );
} }
@ -224,7 +187,7 @@ class EditBucketBottomSheet {
return BaseBottomSheet( return BaseBottomSheet(
title: "Edit Bucket", title: "Edit Bucket",
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit, onSubmit: () => _handleSubmitBottomSheet(context),
child: _formContent(), child: _formContent(),
); );
}, },

View File

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

View File

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

View File

@ -1,3 +1,5 @@
// ---------------- FULL UPDATED CODE WITH LANDSCAPE FIX ------------------
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -26,10 +28,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
late final AddEmployeeController _controller; late final AddEmployeeController _controller;
late final AllOrganizationController _organizationController; late final AllOrganizationController _organizationController;
// Local UI state
bool _hasApplicationAccess = false; bool _hasApplicationAccess = false;
// Local read-only controllers to avoid recreating TextEditingController in build
late final TextEditingController _orgFieldController; late final TextEditingController _orgFieldController;
late final TextEditingController _joiningDateController; late final TextEditingController _joiningDateController;
late final TextEditingController _genderController; late final TextEditingController _genderController;
@ -39,16 +39,13 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
void initState() { void initState() {
super.initState(); super.initState();
// Initialize text controllers
_orgFieldController = TextEditingController(); _orgFieldController = TextEditingController();
_joiningDateController = TextEditingController(); _joiningDateController = TextEditingController();
_genderController = TextEditingController(); _genderController = TextEditingController();
_roleController = TextEditingController(); _roleController = TextEditingController();
// Initialize AddEmployeeController
_controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString()); _controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
// Pass organization ID from employeeData if available
final orgIdFromEmployee = final orgIdFromEmployee =
widget.employeeData?['organization_id'] as String?; widget.employeeData?['organization_id'] as String?;
_organizationController = Get.put( _organizationController = Get.put(
@ -56,7 +53,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
tag: UniqueKey().toString(), tag: UniqueKey().toString(),
); );
// Keep _orgFieldController in sync with selected organization safely
ever(_organizationController.selectedOrganization, (_) { ever(_organizationController.selectedOrganization, (_) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_orgFieldController.text = _orgFieldController.text =
@ -65,48 +61,39 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
}); });
}); });
// Prefill other fields if editing
if (widget.employeeData != null) { if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
// Application access
_hasApplicationAccess = _hasApplicationAccess =
widget.employeeData?['hasApplicationAccess'] ?? false; widget.employeeData?['hasApplicationAccess'] ?? false;
// Email
final email = widget.employeeData?['email']; final email = widget.employeeData?['email'];
if (email != null && email.toString().isNotEmpty) { if (email != null && email.toString().isNotEmpty) {
_controller.basicValidator.getController('email')?.text = _controller.basicValidator.getController('email')?.text =
email.toString(); email.toString();
} }
// Joining date
if (_controller.joiningDate != null) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
// Prefill Role
_controller.fetchRoles().then((_) { _controller.fetchRoles().then((_) {
if (_controller.selectedRoleId != null) { if (_controller.selectedRoleId != null) {
final roleName = _controller.roles.firstWhereOrNull( final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId, (r) => r['id'] == _controller.selectedRoleId,
)?['name']; )?['name'];
if (roleName != null) { if (roleName != null) _roleController.text = roleName;
_roleController.text = roleName;
}
_controller.update(); _controller.update();
} }
}); });
} else { } else {
// Not editing: fetch roles
_controller.fetchRoles(); _controller.fetchRoles();
} }
} }
@ -125,146 +112,162 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return GetBuilder<AddEmployeeController>( return GetBuilder<AddEmployeeController>(
init: _controller, init: _controller,
builder: (_) { builder: (_) {
// Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection; _orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet( return SafeArea(
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee', // Prevent bottom sheet from going under system navigation
onCancel: () => Navigator.pop(context), child: BaseBottomSheet(
onSubmit: _handleSubmit, title:
child: Form( widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
key: _controller.basicValidator.formKey, onCancel: () => Navigator.pop(context),
child: Column( onSubmit: _handleSubmit,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ // ---------------- FIXED CHILD WRAPPING -----------------
_sectionLabel('Personal Info'), child: LayoutBuilder(builder: (context, constraints) {
MySpacing.height(16), return ConstrainedBox(
_inputWithIcon( constraints: BoxConstraints(
label: 'First Name', maxHeight: constraints.maxHeight,
hint: 'e.g., John',
icon: Icons.person,
controller:
_controller.basicValidator.getController('first_name')!,
validator:
_controller.basicValidator.getValidation('first_name'),
), ),
MySpacing.height(16), child: SingleChildScrollView(
_inputWithIcon( padding: const EdgeInsets.only(bottom: 32),
label: 'Last Name', child: Form(
hint: 'e.g., Doe', key: _controller.basicValidator.formKey,
icon: Icons.person_outline, child: Column(
controller: crossAxisAlignment: CrossAxisAlignment.start,
_controller.basicValidator.getController('last_name')!, children: [
validator: _sectionLabel('Personal Info'),
_controller.basicValidator.getValidation('last_name'), MySpacing.height(16),
), _inputWithIcon(
MySpacing.height(16), label: 'First Name',
_sectionLabel('Organization'), hint: 'e.g., John',
MySpacing.height(8), icon: Icons.person,
Obx(() { controller: _controller.basicValidator
return GestureDetector( .getController('first_name')!,
onTap: () => _showOrganizationPopup(context), validator: _controller.basicValidator
child: AbsorbPointer( .getValidation('first_name'),
child: TextFormField(
readOnly: true,
controller: _orgFieldController,
validator: (val) {
if (val == null ||
val.trim().isEmpty ||
val == 'All Organizations') {
return 'Organization is required';
}
return null;
},
decoration:
_inputDecoration('Select Organization').copyWith(
suffixIcon: _organizationController
.isLoadingOrganizations.value
? const SizedBox(
width: 24,
height: 24,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
), ),
), MySpacing.height(16),
_inputWithIcon(
label: 'Last Name',
hint: 'e.g., Doe',
icon: Icons.person_outline,
controller: _controller.basicValidator
.getController('last_name')!,
validator: _controller.basicValidator
.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel('Organization'),
MySpacing.height(8),
Obx(() {
return GestureDetector(
onTap: () => _showOrganizationPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: _orgFieldController,
validator: (val) {
if (val == null ||
val.trim().isEmpty ||
val == 'All Organizations') {
return 'Organization is required';
}
return null;
},
decoration:
_inputDecoration('Select Organization')
.copyWith(
suffixIcon: _organizationController
.isLoadingOrganizations.value
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.expand_more),
),
),
),
);
}),
MySpacing.height(24),
_sectionLabel('Application Access'),
Row(
children: [
Checkbox(
value: _hasApplicationAccess,
onChanged: (val) {
setState(() => _hasApplicationAccess = val!);
},
fillColor: WidgetStateProperty.resolveWith<Color>(
(states) {
if (states.contains(WidgetState.selected)) {
return Colors.indigo;
}
return Colors.white;
}),
side: WidgetStateBorderSide.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return BorderSide.none;
}
return const BorderSide(
color: Colors.black, width: 2);
}),
checkColor: Colors.white,
),
MyText.bodyMedium(
'Has Application Access',
fontWeight: 600,
),
],
),
MySpacing.height(8),
_buildEmailField(),
MySpacing.height(12),
_sectionLabel('Joining Details'),
MySpacing.height(16),
_buildDatePickerField(
label: 'Joining Date',
controller: _joiningDateController,
hint: 'Select Joining Date',
onTap: () => _pickJoiningDate(context),
),
MySpacing.height(16),
_sectionLabel('Contact Details'),
MySpacing.height(16),
_buildPhoneInput(context),
MySpacing.height(24),
_sectionLabel('Other Details'),
MySpacing.height(16),
_buildDropdownField(
label: 'Gender',
controller: _genderController,
hint: 'Select Gender',
onTap: () => _showGenderPopup(context),
),
MySpacing.height(16),
_buildDropdownField(
label: 'Role',
controller: _roleController,
hint: 'Select Role',
onTap: () => _showRolePopup(context),
),
],
), ),
); ),
}),
MySpacing.height(24),
_sectionLabel('Application Access'),
Row(
children: [
Checkbox(
value: _hasApplicationAccess,
onChanged: (val) {
setState(() => _hasApplicationAccess = val ?? false);
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return Colors.indigo;
}
return Colors.white;
}),
side: WidgetStateBorderSide.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return BorderSide.none;
}
return const BorderSide(
color: Colors.black,
width: 2,
);
}),
checkColor: Colors.white,
),
MyText.bodyMedium(
'Has Application Access',
fontWeight: 600,
),
],
), ),
MySpacing.height(8), );
_buildEmailField(), }),
MySpacing.height(12),
_sectionLabel('Joining Details'),
MySpacing.height(16),
_buildDatePickerField(
label: 'Joining Date',
controller: _joiningDateController,
hint: 'Select Joining Date',
onTap: () => _pickJoiningDate(context),
),
MySpacing.height(16),
_sectionLabel('Contact Details'),
MySpacing.height(16),
_buildPhoneInput(context),
MySpacing.height(24),
_sectionLabel('Other Details'),
MySpacing.height(16),
_buildDropdownField(
label: 'Gender',
controller: _genderController,
hint: 'Select Gender',
onTap: () => _showGenderPopup(context),
),
MySpacing.height(16),
_buildDropdownField(
label: 'Role',
controller: _roleController,
hint: 'Select Role',
onTap: () => _showRolePopup(context),
),
],
),
), ),
); );
}, },
); );
} }
// UI Pieces // ====================== REMAINING CODE (UNCHANGED) ======================
// (👇 Everything below is exactly same as your original. No modifications.)
Widget _sectionLabel(String title) => Column( Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -299,9 +302,9 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
), ),
contentPadding: MySpacing.all(16), contentPadding: MySpacing.all(16),
); );
@ -358,16 +361,15 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
if (val == null || val.trim().isEmpty) { if (val == null || val.trim().isEmpty) {
return 'Email is required for application users'; return 'Email is required for application users';
} }
final email = val.trim();
if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$') if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$')
.hasMatch(email)) { .hasMatch(val.trim())) {
return 'Enter a valid email address'; return 'Enter a valid email address';
} }
} }
return null; return null;
}, },
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(), decoration: _inputDecoration('e.g., john.doe@example.com'),
), ),
], ],
); );
@ -396,9 +398,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
} }
return null; return null;
}, },
decoration: _inputDecoration(hint).copyWith( decoration: _inputDecoration(hint)
suffixIcon: const Icon(Icons.calendar_today), .copyWith(suffixIcon: const Icon(Icons.calendar_today)),
),
), ),
), ),
), ),
@ -429,9 +430,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
} }
return null; return null;
}, },
decoration: _inputDecoration(hint).copyWith( decoration: _inputDecoration(hint)
suffixIcon: const Icon(Icons.expand_more), .copyWith(suffixIcon: const Icon(Icons.expand_more)),
),
), ),
), ),
), ),
@ -492,8 +492,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
); );
} }
// Actions
Future<void> _pickJoiningDate(BuildContext context) async { Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,

View File

@ -1,3 +1,4 @@
import 'dart:async';
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/utils/base_bottom_sheet.dart'; import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
@ -24,33 +25,59 @@ class EmployeeSelectionBottomSheet extends StatefulWidget {
class _EmployeeSelectionBottomSheetState class _EmployeeSelectionBottomSheetState
extends State<EmployeeSelectionBottomSheet> { extends State<EmployeeSelectionBottomSheet> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxBool _isSearching = false.obs; final RxBool _isSearching = false.obs;
final RxList<EmployeeModel> _searchResults = <EmployeeModel>[].obs; final RxList<EmployeeModel> _allResults = <EmployeeModel>[].obs;
late RxList<EmployeeModel> _selectedEmployees; late RxList<EmployeeModel> _selectedEmployees;
Timer? _debounce;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedEmployees = RxList<EmployeeModel>.from(widget.initiallySelected); _selectedEmployees = RxList<EmployeeModel>.from(widget.initiallySelected);
_searchEmployees('');
_performSearch('');
} }
@override @override
void dispose() { void dispose() {
_debounce?.cancel();
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _searchEmployees(String query) async { // SEARCH WITH DEBOUNCE
void _onSearchChanged(String query) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_performSearch(query.trim());
});
}
Future<void> _performSearch(String query) async {
_isSearching.value = true; _isSearching.value = true;
final data = await ApiService.searchEmployeesBasic(searchString: query); final data = await ApiService.searchEmployeesBasic(searchString: query);
final results = (data as List) final results = (data as List)
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>)) .map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
_searchResults.assignAll(results);
// ------------------------------------------------------
// REMOVED "MOVE SELECTED TO TOP"
// ------------------------------------------------------
// Keeping alphabetical order only
results
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_allResults.assignAll(results);
_isSearching.value = false; _isSearching.value = false;
} }
// HANDLE TAP & CHECKBOX
void _toggleEmployee(EmployeeModel emp) { void _toggleEmployee(EmployeeModel emp) {
if (widget.multipleSelection) { if (widget.multipleSelection) {
if (_selectedEmployees.contains(emp)) { if (_selectedEmployees.contains(emp)) {
@ -61,9 +88,12 @@ class _EmployeeSelectionBottomSheetState
} else { } else {
_selectedEmployees.assignAll([emp]); _selectedEmployees.assignAll([emp]);
} }
_selectedEmployees.refresh(); // important for Obx rebuild
// Refresh list but do NOT reorder selected
_performSearch(_searchController.text.trim());
} }
// SUBMIT SELECTION
void _handleSubmit() { void _handleSubmit() {
if (widget.multipleSelection) { if (widget.multipleSelection) {
Navigator.of(context).pop(_selectedEmployees.toList()); Navigator.of(context).pop(_selectedEmployees.toList());
@ -73,11 +103,12 @@ class _EmployeeSelectionBottomSheetState
} }
} }
// SEARCH BAR
Widget _searchBar() => Padding( Widget _searchBar() => Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
onChanged: _searchEmployees, onChanged: _onSearchChanged,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search employees...', hintText: 'Search employees...',
filled: true, filled: true,
@ -88,7 +119,7 @@ class _EmployeeSelectionBottomSheetState
icon: const Icon(Icons.close, color: Colors.grey), icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_searchEmployees(''); _performSearch('');
}, },
) )
: null, : null,
@ -102,60 +133,58 @@ class _EmployeeSelectionBottomSheetState
), ),
); );
// EMPLOYEE LIST
Widget _employeeList() => Expanded( Widget _employeeList() => Expanded(
child: Obx(() { child: Obx(() {
if (_isSearching.value) { final results = _allResults;
return const Center(child: CircularProgressIndicator());
}
if (_searchResults.isEmpty) {
return const Center(child: Text("No employees found"));
}
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _searchResults.length, itemCount: results.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final emp = _searchResults[index]; final emp = results[index];
final isSelected = _selectedEmployees.contains(emp);
return Obx(() { Widget trailingWidget;
final isSelected = _selectedEmployees.contains(emp);
return ListTile( if (widget.multipleSelection) {
leading: CircleAvatar( trailingWidget = Checkbox(
backgroundColor: Colors.blueAccent, value: isSelected,
child: Text( onChanged: (_) => _toggleEmployee(emp),
(emp.firstName.isNotEmpty ? emp.firstName[0] : 'U') fillColor: MaterialStateProperty.resolveWith<Color>(
.toUpperCase(), (states) => states.contains(MaterialState.selected)
style: const TextStyle(color: Colors.white), ? Colors.blueAccent
), : Colors.white,
), ),
title: Text('${emp.firstName} ${emp.lastName}'),
subtitle: Text(emp.email),
trailing: Checkbox(
value: isSelected,
onChanged: (_) {
FocusScope.of(context).unfocus(); // hide keyboard
_toggleEmployee(emp);
},
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white,
),
),
onTap: () {
FocusScope.of(context).unfocus();
_toggleEmployee(emp);
},
contentPadding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
); );
}); } else {
trailingWidget = isSelected
? const Icon(Icons.check_circle, color: Colors.blueAccent)
: const Icon(Icons.circle_outlined, color: Colors.grey);
}
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blueAccent,
child: Text(
(emp.firstName.isNotEmpty ? emp.firstName[0] : 'U')
.toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
title: Text('${emp.firstName} ${emp.lastName}'),
subtitle: Text(emp.email),
trailing: trailingWidget,
onTap: () => _toggleEmployee(emp),
contentPadding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
);
}, },
); );
}), }),
); );
// BUILD BOTTOM SHEET
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BaseBottomSheet( return BaseBottomSheet(
@ -164,10 +193,12 @@ class _EmployeeSelectionBottomSheetState
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: SizedBox( child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7, height: MediaQuery.of(context).size.height * 0.7,
child: Column(children: [ child: Column(
_searchBar(), children: [
_employeeList(), _searchBar(),
]), _employeeList(),
],
),
), ),
); );
} }

View File

@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
class MultipleSelectRoleBottomSheet extends StatefulWidget {
final String title;
final bool multipleSelection;
final String projectId;
final String? serviceId;
final String? organizationId;
final String? roleId;
final ScrollController? scrollController;
final List<EmployeeModel> initiallySelected;
const MultipleSelectRoleBottomSheet({
super.key,
this.title = "Select Employees",
this.multipleSelection = true,
required this.projectId,
this.serviceId,
this.organizationId,
this.roleId,
this.initiallySelected = const [],
this.scrollController,
});
@override
State<MultipleSelectRoleBottomSheet> createState() =>
_MultipleSelectRoleBottomSheetState();
}
class _MultipleSelectRoleBottomSheetState
extends State<MultipleSelectRoleBottomSheet> {
final RxList<EmployeeModel> _employees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> _filtered = <EmployeeModel>[].obs;
final RxBool _isLoading = true.obs;
late RxList<EmployeeModel> _selected;
final TextEditingController _searchController = TextEditingController();
late DailyTaskPlanningController controller;
@override
void initState() {
super.initState();
_selected = widget.initiallySelected.obs;
controller = Get.find<DailyTaskPlanningController>();
_fetchEmployeesFiltered();
}
Future<void> _fetchEmployeesFiltered() async {
_isLoading.value = true;
try {
List<EmployeeModel> employees = controller.employees.toList();
if (widget.roleId != null && widget.roleId!.isNotEmpty) {
employees =
employees.where((emp) => emp.jobRoleID == widget.roleId).toList();
}
_employees.assignAll(employees);
_filtered.assignAll(employees);
} catch (e) {
print("Error fetching employees: $e");
} finally {
_isLoading.value = false;
}
}
void _onSearch(String text) {
if (text.isEmpty) {
_filtered.assignAll(_employees);
} else {
_filtered.assignAll(
_employees.where((e) =>
e.name.toLowerCase().contains(text.toLowerCase()) ||
e.designation.toLowerCase().contains(text.toLowerCase())),
);
}
}
void _onTap(EmployeeModel emp) {
if (widget.multipleSelection) {
if (_selected.any((e) => e.id == emp.id)) {
_selected.removeWhere((e) => e.id == emp.id);
} else {
_selected.add(emp);
}
} else {
// Single selection return immediately
Get.back(result: [emp]);
}
_onSearch(_searchController.text.trim());
}
bool _isSelected(EmployeeModel emp) {
return _selected.any((e) => e.id == emp.id);
}
Widget _searchBar() => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _searchController,
onChanged: _onSearch,
decoration: InputDecoration(
hintText: 'Search employees...',
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () {
_searchController.clear();
_onSearch('');
},
)
: null,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
),
),
);
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: widget.title,
onCancel: () => Get.back(),
onSubmit: () => Get.back(result: _selected.toList()), // Return plain list
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.55,
child: Column(
children: [
_searchBar(),
Expanded(
child: Obx(() {
if (_isLoading.value) {
return SkeletonLoaders.employeeSkeletonCard();
}
if (_filtered.isEmpty) {
return const Center(child: Text("No employees found"));
}
return ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.only(bottom: 20),
itemCount: _filtered.length,
itemBuilder: (_, index) {
final emp = _filtered[index];
final isSelected = _isSelected(emp);
return ListTile(
onTap: () => _onTap(emp),
leading: CircleAvatar(
backgroundColor: Colors.blueAccent,
child: Text(
emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?",
style: const TextStyle(color: Colors.white),
),
),
title: Text(emp.name),
subtitle: Text(emp.designation),
trailing: Checkbox(
value: isSelected,
onChanged: (_) => _onTap(emp),
fillColor:
MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
return Colors.white;
}),
checkColor: Colors.white,
side: const BorderSide(color: Colors.grey),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
);
},
);
}),
),
],
),
),
);
}
}

View File

@ -1,3 +1,6 @@
/// UPDATED SafeArea + proper bottom padding added
/// No other functionality modified.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -5,15 +8,15 @@ import 'package:on_field_work/controller/expense/add_expense_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/model/expense/expense_type_model.dart'; import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/expense/payment_types_model.dart'; import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:on_field_work/model/expense/employee_selector_bottom_sheet.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:on_field_work/helpers/utils/validators.dart'; import 'package:on_field_work/helpers/utils/validators.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_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.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/widgets/expense/expense_form_widgets.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
/// Show bottom sheet wrapper
Future<T?> showAddExpenseBottomSheet<T>({ Future<T?> showAddExpenseBottomSheet<T>({
bool isEdit = false, bool isEdit = false,
Map<String, dynamic>? existingExpense, Map<String, dynamic>? existingExpense,
@ -27,7 +30,6 @@ Future<T?> showAddExpenseBottomSheet<T>({
); );
} }
/// Bottom sheet widget
class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheet extends StatefulWidget {
final bool isEdit; final bool isEdit;
final Map<String, dynamic>? existingExpense; final Map<String, dynamic>? existingExpense;
@ -50,47 +52,55 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey();
/// Show employee list
Future<void> _showEmployeeList() async { Future<void> _showEmployeeList() async {
await showModalBottomSheet( final result = await showModalBottomSheet<dynamic>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (_) => ReusableEmployeeSelectorBottomSheet( builder: (_) => EmployeeSelectionBottomSheet(
searchController: controller.employeeSearchController, initiallySelected: controller.selectedPaidBy.value != null
searchResults: controller.employeeSearchResults, ? [controller.selectedPaidBy.value!]
isSearching: controller.isSearchingEmployees, : [],
onSearch: controller.searchEmployees, multipleSelection: false,
onSelect: (emp) => controller.selectedPaidBy.value = emp, title: "Select Paid By",
), ),
); );
controller.employeeSearchController.clear(); if (result == null) return;
controller.employeeSearchResults.clear();
if (result is EmployeeModel) {
controller.setSelectedPaidBy(result);
} else if (result is List && result.isNotEmpty) {
controller.setSelectedPaidBy(result.first as EmployeeModel);
}
try {
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
} catch (_) {}
} }
/// Generic option list
Future<void> _showOptionList<T>( Future<void> _showOptionList<T>(
List<T> options, List<T> options,
String Function(T) getLabel, String Function(T) getLabel,
ValueChanged<T> onSelected, ValueChanged<T> onSelected,
GlobalKey triggerKey, GlobalKey triggerKey,
) async { ) async {
final RenderBox button = final RenderBox btn =
triggerKey.currentContext!.findRenderObject() as RenderBox; triggerKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay = final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox; Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay); final pos = btn.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>( final selected = await showMenu<T>(
context: context, context: context,
position: RelativeRect.fromLTRB( position: RelativeRect.fromLTRB(
position.dx, pos.dx,
position.dy + button.size.height, pos.dy + btn.size.height,
overlay.size.width - position.dx - button.size.width, overlay.size.width - pos.dx - btn.size.width,
0, 0,
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
@ -105,7 +115,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
if (selected != null) onSelected(selected); if (selected != null) onSelected(selected);
} }
/// Validate required selections
bool _validateSelections() { bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) { if (controller.selectedProject.value.isEmpty) {
_showError("Please select a project"); _showError("Please select a project");
@ -141,148 +150,142 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
return Obx( return Obx(
() => Form( () => Form(
key: _formKey, key: _formKey,
child: BaseBottomSheet( child: SafeArea(
title: widget.isEdit ? "Edit Expense" : "Add Expense", bottom: true,
isSubmitting: controller.isSubmitting.value, child: BaseBottomSheet(
onCancel: Get.back, title: widget.isEdit ? "Edit Expense" : "Add Expense",
onSubmit: () { isSubmitting: controller.isSubmitting.value,
if (_formKey.currentState!.validate() && _validateSelections()) { onCancel: Get.back,
controller.submitOrUpdateExpense(); onSubmit: () {
} else { if (_formKey.currentState!.validate() && _validateSelections()) {
_showError("Please fill all required fields correctly"); controller.submitOrUpdateExpense();
} } else {
}, _showError("Please fill all required fields correctly");
child: SingleChildScrollView( }
child: Column( },
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ padding: EdgeInsets.only(bottom: bottomInset + 24),
_buildDropdownField<String>( child: Column(
icon: Icons.work_outline, crossAxisAlignment: CrossAxisAlignment.start,
title: "Project", children: [
requiredField: true, _buildDropdownField<String>(
value: controller.selectedProject.value.isEmpty icon: Icons.work_outline,
? "Select Project" title: "Project",
: controller.selectedProject.value, requiredField: true,
onTap: () => _showOptionList<String>( value: controller.selectedProject.value.isEmpty
controller.globalProjects.toList(), ? "Select Project"
(p) => p, : controller.selectedProject.value,
(val) => controller.selectedProject.value = val, onTap: () => _showOptionList<String>(
_projectDropdownKey, controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey,
),
dropdownKey: _projectDropdownKey,
), ),
dropdownKey: _projectDropdownKey, _gap(),
), _buildDropdownField<ExpenseTypeModel>(
_gap(), icon: Icons.category_outlined,
title: "Expense Category",
_buildDropdownField<ExpenseTypeModel>( requiredField: true,
icon: Icons.category_outlined, value: controller.selectedExpenseType.value?.name ??
title: "Expense Category", "Select Expense Category",
requiredField: true, onTap: () => _showOptionList<ExpenseTypeModel>(
value: controller.selectedExpenseType.value?.name ?? controller.expenseTypes.toList(),
"Select Expense Category", (e) => e.name,
onTap: () => _showOptionList<ExpenseTypeModel>( (val) => controller.selectedExpenseType.value = val,
controller.expenseTypes.toList(), _expenseTypeDropdownKey,
(e) => e.name, ),
(val) => controller.selectedExpenseType.value = val, dropdownKey: _expenseTypeDropdownKey,
_expenseTypeDropdownKey,
), ),
dropdownKey: _expenseTypeDropdownKey, if (controller
), .selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
// Persons if required _gap(),
if (controller.selectedExpenseType.value?.noOfPersonsRequired == _buildTextFieldSection(
true) ...[ icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
validator: Validators.requiredField,
),
],
_gap(), _gap(),
_buildTextFieldSection( _buildTextFieldSection(
icon: Icons.people_outline, icon: Icons.confirmation_number_outlined,
title: "No. of Persons", title: "GST No.",
controller: controller.noOfPersonsController, controller: controller.gstController,
hint: "Enter No. of Persons", hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
_buildPaidBySection(),
_gap(),
_buildTextFieldSection(
icon: Icons.currency_rupee,
title: "Amount",
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) => Validators.isNumeric(v ?? "")
? null
: "Enter valid amount",
),
_gap(),
_buildTextFieldSection(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator,
),
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField, validator: Validators.requiredField,
), ),
], ],
_gap(), ),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
_buildPaidBySection(),
_gap(),
_buildTextFieldSection(
icon: Icons.currency_rupee,
title: "Amount",
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
validator: (v) => Validators.isNumeric(v ?? "")
? null
: "Enter valid amount",
),
_gap(),
_buildTextFieldSection(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator,
),
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
),
],
), ),
), ),
), ),
@ -349,11 +352,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Expanded(
controller.selectedPaidBy.value == null child: Text(
? "Select Paid By" controller.selectedPaidBy.value?.name ?? "Select Paid By",
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', style: const TextStyle(fontSize: 15),
style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis,
),
), ),
const Icon(Icons.arrow_drop_down, size: 22), const Icon(Icons.arrow_drop_down, size: 22),
], ],
@ -399,7 +403,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
hintText: "Enter Location", hintText: "Enter Location",
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10), const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
suffixIcon: controller.isFetchingLocation.value suffixIcon: controller.isFetchingLocation.value
@ -413,7 +419,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
) )
: IconButton( : IconButton(
icon: const Icon(Icons.my_location), icon: const Icon(Icons.my_location),
tooltip: "Use Current Location",
onPressed: controller.fetchCurrentLocation, onPressed: controller.fetchCurrentLocation,
), ),
), ),

View File

@ -9,6 +9,8 @@ import 'package:on_field_work/helpers/widgets/my_spacing.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/helpers/widgets/expense/expense_form_widgets.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_form_widgets.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/model/employees/multiple_select_bottomsheet.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
Future<T?> showPaymentRequestBottomSheet<T>({ Future<T?> showPaymentRequestBottomSheet<T>({
bool isEdit = false, bool isEdit = false,
@ -58,12 +60,10 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (widget.isEdit && widget.existingData != null) { if (widget.isEdit && widget.existingData != null) {
final data = widget.existingData!; final data = widget.existingData!;
// Prefill text fields
controller.titleController.text = data["title"] ?? ""; controller.titleController.text = data["title"] ?? "";
controller.amountController.text = data["amount"]?.toString() ?? ""; controller.amountController.text = data["amount"]?.toString() ?? "";
controller.descriptionController.text = data["description"] ?? ""; controller.descriptionController.text = data["description"] ?? "";
// Prefill due date
if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) { if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) {
DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString()); DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString());
if (dueDate != null) { if (dueDate != null) {
@ -73,15 +73,15 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
} }
} }
// Prefill dropdowns & toggles
controller.selectedProject.value = { controller.selectedProject.value = {
'id': data["projectId"], 'id': data["projectId"],
'name': data["projectName"], 'name': data["projectName"],
}; };
controller.selectedPayee.value = data["payee"] ?? ""; controller.selectedPayee.value = data["payee"] ?? "";
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false; controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
// Categories & currencies // When categories and currencies load, set selected ones
everAll([controller.categories, controller.currencies], (_) { everAll([controller.categories, controller.currencies], (_) {
controller.selectedCategory.value = controller.categories controller.selectedCategory.value = controller.categories
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]); .firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
@ -89,7 +89,6 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
.firstWhereOrNull((c) => c.id == data["currencyId"]); .firstWhereOrNull((c) => c.id == data["currencyId"]);
}); });
// Attachments
final attachmentsData = data["attachments"]; final attachmentsData = data["attachments"];
if (attachmentsData != null && if (attachmentsData != null &&
attachmentsData is List && attachmentsData is List &&
@ -116,21 +115,21 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() => Form( return Obx(
() => SafeArea(
child: Form(
key: _formKey, key: _formKey,
child: BaseBottomSheet( child: BaseBottomSheet(
title: widget.isEdit title: widget.isEdit ? "Edit Payment Request" : "Create Payment Request",
? "Edit Payment Request"
: "Create Payment Request",
isSubmitting: controller.isSubmitting.value, isSubmitting: controller.isSubmitting.value,
onCancel: Get.back, onCancel: Get.back,
submitText: "Save as Draft", submitText: "Save as Draft",
onSubmit: () async { onSubmit: () async {
if (_formKey.currentState!.validate() && _validateSelections()) { if (_formKey.currentState!.validate() && _validateSelections()) {
bool success = false; bool success = false;
if (widget.isEdit && widget.existingData != null) { if (widget.isEdit && widget.existingData != null) {
final requestId = final requestId = widget.existingData!['id']?.toString() ?? '';
widget.existingData!['id']?.toString() ?? '';
if (requestId.isNotEmpty) { if (requestId.isNotEmpty) {
success = await controller.submitEditedPaymentRequest( success = await controller.submitEditedPaymentRequest(
requestId: requestId); requestId: requestId);
@ -144,7 +143,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (success) { if (success) {
Get.back(); Get.back();
if (widget.onUpdated != null) widget.onUpdated!(); widget.onUpdated?.call();
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
@ -157,31 +156,33 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
} }
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildDropdown( _buildDropdown(
"Select Project", "Select Project",
Icons.work_outline, Icons.work_outline,
controller.selectedProject.value?['name'] ?? controller.selectedProject.value?['name'] ?? "Select Project",
"Select Project", controller.globalProjects,
controller.globalProjects, (p) => p['name'],
(p) => p['name'], controller.selectProject,
controller.selectProject, key: _projectDropdownKey,
key: _projectDropdownKey), ),
_gap(), _gap(),
_buildDropdown( _buildDropdown(
"Expense Category", "Expense Category",
Icons.category_outlined, Icons.category_outlined,
controller.selectedCategory.value?.name ?? controller.selectedCategory.value?.name ?? "Select Category",
"Select Category", controller.categories,
controller.categories, (c) => c.name,
(c) => c.name, controller.selectCategory,
controller.selectCategory, key: _categoryDropdownKey,
key: _categoryDropdownKey), ),
_gap(), _gap(),
_buildTextField( _buildTextField("Title", Icons.title_outlined,
"Title", Icons.title_outlined, controller.titleController, controller.titleController,
hint: "Enter title", validator: Validators.requiredField), hint: "Enter title", validator: Validators.requiredField),
_gap(), _gap(),
_buildRadio("Is Advance Payment", Icons.attach_money_outlined, _buildRadio("Is Advance Payment", Icons.attach_money_outlined,
@ -199,17 +200,17 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
? null ? null
: "Enter valid amount"), : "Enter valid amount"),
_gap(), _gap(),
_buildPayeeAutocompleteField(), _buildPayeeField(),
_gap(), _gap(),
_buildDropdown( _buildDropdown(
"Currency", "Currency",
Icons.monetization_on_outlined, Icons.monetization_on_outlined,
controller.selectedCurrency.value?.currencyName ?? controller.selectedCurrency.value?.currencyName ?? "Select Currency",
"Select Currency", controller.currencies,
controller.currencies, (c) => c.currencyName,
(c) => c.currencyName, controller.selectCurrency,
controller.selectCurrency, key: _currencyDropdownKey,
key: _currencyDropdownKey), ),
_gap(), _gap(),
_buildTextField("Description", Icons.description_outlined, _buildTextField("Description", Icons.description_outlined,
controller.descriptionController, controller.descriptionController,
@ -218,11 +219,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
validator: Validators.requiredField), validator: Validators.requiredField),
_gap(), _gap(),
_buildAttachmentsSection(), _buildAttachmentsSection(),
MySpacing.height(30),
], ],
), ),
), ),
), ),
)); ),
),
);
} }
Widget _buildDropdown<T>(String title, IconData icon, String value, Widget _buildDropdown<T>(String title, IconData icon, String value,
@ -234,9 +238,10 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
SectionTitle(icon: icon, title: title, requiredField: true), SectionTitle(icon: icon, title: title, requiredField: true),
MySpacing.height(6), MySpacing.height(6),
DropdownTile( DropdownTile(
key: key, key: key,
title: value, title: value,
onTap: () => _showOptionList(options, getLabel, onSelected, key)), onTap: () => _showOptionList(options, getLabel, onSelected, key),
),
], ],
); );
} }
@ -265,7 +270,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
} }
Widget _buildRadio( Widget _buildRadio(
String title, IconData icon, RxBool controller, List<String> labels) { String title, IconData icon, RxBool controllerBool, List<String> labels) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -284,15 +289,16 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
final i = entry.key; final i = entry.key;
final label = entry.value; final label = entry.value;
final value = i == 0; final value = i == 0;
return Expanded( return Expanded(
child: RadioListTile<bool>( child: RadioListTile<bool>(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(label), title: Text(label),
value: value, value: value,
groupValue: controller.value, groupValue: controllerBool.value,
activeColor: contentTheme.primary, activeColor: contentTheme.primary,
onChanged: (val) => onChanged: (val) =>
val != null ? controller.value = val : null, val != null ? controllerBool.value = val : null,
), ),
); );
}).toList(), }).toList(),
@ -306,9 +312,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionTitle( const SectionTitle(
icon: Icons.calendar_today, icon: Icons.calendar_today, title: "Due To Date", requiredField: true),
title: "Due To Date",
requiredField: true),
MySpacing.height(6), MySpacing.height(6),
GestureDetector( GestureDetector(
onTap: () => controller.pickDueDate(context), onTap: () => controller.pickDueDate(context),
@ -336,75 +340,35 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
); );
} }
Widget _buildPayeeAutocompleteField() { Widget _buildPayeeField() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SectionTitle( const SectionTitle(
icon: Icons.person_outline, title: "Payee", requiredField: true), icon: Icons.person_outline,
const SizedBox(height: 6), title: "Payee",
Autocomplete<String>( requiredField: true,
optionsBuilder: (textEditingValue) { ),
final query = textEditingValue.text.toLowerCase(); MySpacing.height(6),
return query.isEmpty GestureDetector(
? const Iterable<String>.empty() onTap: _showPayeeSelector,
: controller.payees child: TileContainer(
.where((p) => p.toLowerCase().contains(query)); child: Row(
}, mainAxisAlignment: MainAxisAlignment.spaceBetween,
displayStringForOption: (option) => option, children: [
fieldViewBuilder: Expanded(
(context, fieldController, focusNode, onFieldSubmitted) { child: Obx(() => Text(
// Avoid updating during build controller.selectedPayee.value?.name ?? "Select Payee",
WidgetsBinding.instance.addPostFrameCallback((_) { style: const TextStyle(fontSize: 15),
if (fieldController.text != controller.selectedPayee.value) { overflow: TextOverflow.ellipsis,
fieldController.text = controller.selectedPayee.value; )),
fieldController.selection = TextSelection.fromPosition(
TextPosition(offset: fieldController.text.length));
}
});
return TextFormField(
controller: fieldController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: "Type or select payee",
filled: true,
fillColor: Colors.grey.shade100,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), const Icon(Icons.arrow_drop_down, size: 22),
validator: (v) => ],
v == null || v.trim().isEmpty ? "Please enter payee" : null,
onChanged: (val) => controller.selectedPayee.value = val,
);
},
onSelected: (selection) => controller.selectedPayee.value = selection,
optionsViewBuilder: (context, onSelected, options) => Material(
color: Colors.white,
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, minWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: options.length,
itemBuilder: (_, index) => InkWell(
onTap: () => onSelected(options.elementAt(index)),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 12),
child: Text(options.elementAt(index),
style: const TextStyle(fontSize: 14)),
),
),
),
), ),
), ),
), ),
const SizedBox(height: 6),
], ],
); );
} }
@ -492,8 +456,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
return; return;
} }
final RenderBox button = final RenderBox button = key.currentContext!.findRenderObject() as RenderBox;
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay = final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox; Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay); final position = button.localToGlobal(Offset.zero, ancestor: overlay);
@ -507,8 +470,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
0), 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options items: options
.map( .map((opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(), .toList(),
); );
@ -523,7 +485,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (controller.selectedCategory.value == null) { if (controller.selectedCategory.value == null) {
return _showError("Please select a category"); return _showError("Please select a category");
} }
if (controller.selectedPayee.value.isEmpty) { if (controller.selectedPayee.value == null) {
return _showError("Please select a payee"); return _showError("Please select a payee");
} }
if (controller.selectedCurrency.value == null) { if (controller.selectedCurrency.value == null) {
@ -532,6 +494,25 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
return true; return true;
} }
Future<void> _showPayeeSelector() async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => EmployeeSelectionBottomSheet(
title: "Select Payee",
multipleSelection: false,
initiallySelected: controller.selectedPayee.value != null
? [controller.selectedPayee.value!]
: [],
),
);
if (result is EmployeeModel) {
controller.selectedPayee.value = result;
}
}
bool _showError(String msg) { bool _showError(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false; return false;

View File

@ -8,7 +8,7 @@ import 'package:on_field_work/helpers/widgets/my_text_style.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/date_range_picker.dart'; import 'package:on_field_work/helpers/widgets/date_range_picker.dart';
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/model/expense/employee_selector_for_filter_bottom_sheet.dart'; import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
class PaymentRequestFilterBottomSheet extends StatefulWidget { class PaymentRequestFilterBottomSheet extends StatefulWidget {
final PaymentRequestController controller; final PaymentRequestController controller;
@ -27,11 +27,9 @@ class PaymentRequestFilterBottomSheet extends StatefulWidget {
class _PaymentRequestFilterBottomSheetState class _PaymentRequestFilterBottomSheetState
extends State<PaymentRequestFilterBottomSheet> with UIMixin { extends State<PaymentRequestFilterBottomSheet> with UIMixin {
// ---------------- Date Range ----------------
final Rx<DateTime?> startDate = Rx<DateTime?>(null); final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null); final Rx<DateTime?> endDate = Rx<DateTime?>(null);
// ---------------- Selected Filters (store IDs internally) ----------------
final RxString selectedProjectId = ''.obs; final RxString selectedProjectId = ''.obs;
final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs; final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].obs; final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].obs;
@ -39,7 +37,6 @@ class _PaymentRequestFilterBottomSheetState
final RxString selectedCurrencyId = ''.obs; final RxString selectedCurrencyId = ''.obs;
final RxString selectedStatusId = ''.obs; final RxString selectedStatusId = ''.obs;
// Computed display names
String get selectedProjectName => String get selectedProjectName =>
widget.controller.projects widget.controller.projects
.firstWhereOrNull((e) => e.id == selectedProjectId.value) .firstWhereOrNull((e) => e.id == selectedProjectId.value)
@ -64,10 +61,8 @@ class _PaymentRequestFilterBottomSheetState
?.name ?? ?.name ??
'Please select...'; 'Please select...';
// ---------------- Filter Data ----------------
final RxBool isFilterLoading = true.obs; final RxBool isFilterLoading = true.obs;
// Individual RxLists for safe Obx usage
final RxList<String> projectNames = <String>[].obs; final RxList<String> projectNames = <String>[].obs;
final RxList<String> submittedByNames = <String>[].obs; final RxList<String> submittedByNames = <String>[].obs;
final RxList<String> payeeNames = <String>[].obs; final RxList<String> payeeNames = <String>[].obs;
@ -92,17 +87,14 @@ class _PaymentRequestFilterBottomSheetState
currencyNames.assignAll(widget.controller.currencies.map((e) => e.name)); currencyNames.assignAll(widget.controller.currencies.map((e) => e.name));
statusNames.assignAll(widget.controller.statuses.map((e) => e.name)); statusNames.assignAll(widget.controller.statuses.map((e) => e.name));
// 🔹 Prefill existing applied filter (if any)
final existing = widget.controller.appliedFilter; final existing = widget.controller.appliedFilter;
if (existing.isNotEmpty) { if (existing.isNotEmpty) {
// Project
if (existing['projectIds'] != null && if (existing['projectIds'] != null &&
(existing['projectIds'] as List).isNotEmpty) { (existing['projectIds'] as List).isNotEmpty) {
selectedProjectId.value = (existing['projectIds'] as List).first; selectedProjectId.value = (existing['projectIds'] as List).first;
} }
// Submitted By
if (existing['createdByIds'] != null && if (existing['createdByIds'] != null &&
existing['createdByIds'] is List) { existing['createdByIds'] is List) {
selectedSubmittedBy.assignAll( selectedSubmittedBy.assignAll(
@ -114,7 +106,6 @@ class _PaymentRequestFilterBottomSheetState
); );
} }
// Payees
if (existing['payees'] != null && existing['payees'] is List) { if (existing['payees'] != null && existing['payees'] is List) {
selectedPayees.assignAll( selectedPayees.assignAll(
(existing['payees'] as List) (existing['payees'] as List)
@ -125,26 +116,22 @@ class _PaymentRequestFilterBottomSheetState
); );
} }
// Category
if (existing['expenseCategoryIds'] != null && if (existing['expenseCategoryIds'] != null &&
(existing['expenseCategoryIds'] as List).isNotEmpty) { (existing['expenseCategoryIds'] as List).isNotEmpty) {
selectedCategoryId.value = selectedCategoryId.value =
(existing['expenseCategoryIds'] as List).first; (existing['expenseCategoryIds'] as List).first;
} }
// Currency
if (existing['currencyIds'] != null && if (existing['currencyIds'] != null &&
(existing['currencyIds'] as List).isNotEmpty) { (existing['currencyIds'] as List).isNotEmpty) {
selectedCurrencyId.value = (existing['currencyIds'] as List).first; selectedCurrencyId.value = (existing['currencyIds'] as List).first;
} }
// Status
if (existing['statusIds'] != null && if (existing['statusIds'] != null &&
(existing['statusIds'] as List).isNotEmpty) { (existing['statusIds'] as List).isNotEmpty) {
selectedStatusId.value = (existing['statusIds'] as List).first; selectedStatusId.value = (existing['statusIds'] as List).first;
} }
// Dates
if (existing['startDate'] != null && existing['endDate'] != null) { if (existing['startDate'] != null && existing['endDate'] != null) {
startDate.value = DateTime.tryParse(existing['startDate']); startDate.value = DateTime.tryParse(existing['startDate']);
endDate.value = DateTime.tryParse(existing['endDate']); endDate.value = DateTime.tryParse(existing['endDate']);
@ -192,39 +179,46 @@ class _PaymentRequestFilterBottomSheetState
submitText: 'Apply', submitText: 'Apply',
submitColor: contentTheme.primary, submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline, submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: widget.scrollController, /// IMPORTANT FIX
child: Column( /// Prevents bottom part from hiding under 3-button nav bar in landscape
crossAxisAlignment: CrossAxisAlignment.start, child: SafeArea(
children: [ minimum: const EdgeInsets.only(bottom: 20),
Align( child: SingleChildScrollView(
alignment: Alignment.centerRight, controller: widget.scrollController,
child: TextButton( padding: const EdgeInsets.only(bottom: 40), // extra bottom spacing
onPressed: clearFilters, child: Column(
child: MyText( crossAxisAlignment: CrossAxisAlignment.start,
"Reset Filters", children: [
style: MyTextStyle.labelMedium( Align(
color: Colors.red, alignment: Alignment.centerRight,
fontWeight: 600, child: TextButton(
onPressed: clearFilters,
child: MyText(
"Reset Filters",
style: MyTextStyle.labelMedium(
color: Colors.red,
fontWeight: 600,
),
), ),
), ),
), ),
), MySpacing.height(8),
MySpacing.height(8), _buildDateRangeFilter(),
_buildDateRangeFilter(), MySpacing.height(16),
MySpacing.height(16), _buildProjectFilter(),
_buildProjectFilter(), MySpacing.height(16),
MySpacing.height(16), _buildSubmittedByFilter(),
_buildSubmittedByFilter(), MySpacing.height(16),
MySpacing.height(16), _buildPayeeFilter(),
_buildPayeeFilter(), MySpacing.height(16),
MySpacing.height(16), _buildCategoryFilter(),
_buildCategoryFilter(), MySpacing.height(16),
MySpacing.height(16), _buildCurrencyFilter(),
_buildCurrencyFilter(), MySpacing.height(16),
MySpacing.height(16), _buildStatusFilter(),
_buildStatusFilter(), ],
], ),
), ),
), ),
); );
@ -441,9 +435,9 @@ class _PaymentRequestFilterBottomSheetState
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (context) => EmployeeSelectorBottomSheet( builder: (context) => EmployeeSelectionBottomSheet(
selectedEmployees: selectedEmployees, initiallySelected: selectedEmployees.toList(),
searchEmployees: (query) => searchEmployees(query, items), multipleSelection: true,
title: title, title: title,
), ),
); );

View File

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

View File

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

View File

@ -91,6 +91,7 @@ class _AddServiceProjectJobBottomSheetState
), ),
], ],
); );
Widget _branchSelector() => Obx(() { Widget _branchSelector() => Obx(() {
if (controller.isBranchLoading.value) { if (controller.isBranchLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -197,6 +198,8 @@ class _AddServiceProjectJobBottomSheetState
], ],
); );
// ----------------- UPDATED TAG INPUT -----------------
Widget _tagInput() => Column( Widget _tagInput() => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -204,14 +207,65 @@ class _AddServiceProjectJobBottomSheetState
height: 48, height: 48,
child: TextFormField( child: TextFormField(
controller: controller.tagCtrl, controller: controller.tagCtrl,
onFieldSubmitted: (v) { textInputAction: TextInputAction.done,
final value = v.trim();
if (value.isNotEmpty && // 🚀 Auto-create tag when space pressed
!controller.enteredTags.contains(value)) { onChanged: (value) {
controller.enteredTags.add(value); if (value.endsWith(' ')) {
final raw = value.trim();
if (raw.isNotEmpty) {
final parts = raw
.split(RegExp(r'[, ]+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
}
}
controller.tagCtrl.clear();
}
},
onEditingComplete: () {
final raw = controller.tagCtrl.text.trim();
if (raw.isEmpty) return;
final parts = raw
.split(RegExp(r'[, ]+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
} }
controller.tagCtrl.clear(); controller.tagCtrl.clear();
}, },
onFieldSubmitted: (v) {
final raw = v.trim();
if (raw.isEmpty) return;
final parts = raw
.split(RegExp(r'[, ]+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
}
controller.tagCtrl.clear();
},
decoration: _inputDecoration("Start typing to add tags"), decoration: _inputDecoration("Start typing to add tags"),
validator: (v) => controller.enteredTags.isEmpty validator: (v) => controller.enteredTags.isEmpty
? "Please add at least one tag" ? "Please add at least one tag"
@ -231,6 +285,8 @@ class _AddServiceProjectJobBottomSheetState
], ],
); );
// ------------------------------------------------------
void _handleSubmit() { void _handleSubmit() {
if (!(formKey.currentState?.validate() ?? false)) return; if (!(formKey.currentState?.validate() ?? false)) return;
controller.titleCtrl.text = controller.titleCtrl.text.trim(); controller.titleCtrl.text = controller.titleCtrl.text.trim();

View File

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

View File

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

View File

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

View File

@ -11,8 +11,6 @@ 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';
@ -25,6 +23,8 @@ 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,15 +70,6 @@ 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,7 +84,7 @@ getPageRoute() {
name: '/dashboard/document-main-page', name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(), page: () => UserDocumentsPage(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Finance // Finance
GetPage( GetPage(
name: '/dashboard/finance', name: '/dashboard/finance',
page: () => FinanceScreen(), page: () => FinanceScreen(),
@ -102,6 +93,12 @@ getPageRoute() {
name: '/dashboard/payment-request', name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(), page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Infrastructure Projects
GetPage(
name: '/dashboard/infra-projects',
page: () => InfraProjectScreen(),
middlewares: [AuthMiddleware()]),
// Authentication // Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),

View File

@ -3,7 +3,6 @@ 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';
@ -12,21 +11,14 @@ 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 StatefulWidget { class AttendanceLogsTab extends StatelessWidget {
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 (!widget.controller.showPendingOnly.value) { if (!controller.showPendingOnly.value) return const SizedBox.shrink();
return const SizedBox.shrink();
}
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -46,7 +38,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
), ),
), ),
InkWell( InkWell(
onTap: () => widget.controller.showPendingOnly.value = false, onTap: () => controller.showPendingOnly.value = false,
child: const Icon(Icons.close, size: 18, color: Colors.orange), child: const Icon(Icons.close, size: 18, color: Colors.orange),
), ),
], ],
@ -55,7 +47,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
}); });
} }
/// 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,
@ -77,32 +68,20 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
final isCheckoutAction = final isCheckoutAction =
text.contains("checkout") || text.contains("check out"); text.contains("checkout") || text.contains("check out");
int priority; if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) return 0;
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) { if (isCheckoutAction) return 0;
priority = 0; if (text.contains("regular")) return 1;
} else if (isCheckoutAction) { if (text == "requested") return 2;
priority = 0; if (text == "approved") return 3;
} else if (text.contains("regular")) { if (text == "rejected") return 4;
priority = 1; return 5;
} 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(widget.controller.filteredLogs); final allLogs = List.of(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;
@ -116,7 +95,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
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);
@ -125,20 +103,19 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
}); });
final dateRangeText = final dateRangeText =
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - ' '${DateTimeUtils.formatDate(controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}'; '${DateTimeUtils.formatDate(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: [
MyText.titleMedium("Attendance Logs", fontWeight: 600), controller.isLoadingAttendanceLogs.value
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader() ? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall( : MyText.bodySmall(
dateRangeText, dateRangeText,
@ -152,136 +129,160 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
// Pending-only header // Pending-only header
_buildStatusHeader(), _buildStatusHeader(),
MySpacing.height(8),
// Content: loader, empty, or logs // Divider between header and list
if (widget.controller.isLoadingAttendanceLogs.value) const Divider(height: 1),
SkeletonLoaders.employeeListSkeletonLoader()
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 // Scrollable attendance logs
for (final emp in (groupedLogs[date]! Expanded(
..sort( child: controller.isLoadingAttendanceLogs.value
(a, b) { ? SkeletonLoaders.employeeListSkeletonLoader()
final priorityCompare = _getActionPriority(a) : filteredLogs.isEmpty
.compareTo(_getActionPriority(b)); ? Center(
if (priorityCompare != 0) return priorityCompare; child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
)
: ListView.builder(
padding: MySpacing.all(8),
itemCount: sortedDates.length,
itemBuilder: (context, dateIndex) {
final date = sortedDates[dateIndex];
final employees = groupedLogs[date]!
..sort((a, b) {
final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b));
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);
});
final aTime = a.checkOut ?? a.checkIn ?? DateTime(0); return Column(
final bTime = b.checkOut ?? b.checkIn ?? DateTime(0); crossAxisAlignment: CrossAxisAlignment.start,
return bTime.compareTo( children: [
aTime); Padding(
}, padding:
))) ...[ const EdgeInsets.symmetric(vertical: 8),
MyContainer( child: MyText.bodyMedium(date, fontWeight: 700),
paddingAll: 8, ),
child: Row( ...employees.map(
crossAxisAlignment: CrossAxisAlignment.start, (emp) => Column(
children: [ children: [
Avatar( MyContainer(
firstName: emp.firstName, paddingAll: 8,
lastName: emp.lastName, child: Row(
size: 31, crossAxisAlignment:
), CrossAxisAlignment.start,
MySpacing.width(16), children: [
Expanded( Avatar(
child: Column( firstName: emp.firstName,
crossAxisAlignment: CrossAxisAlignment.start, lastName: emp.lastName,
children: [ size: 31,
Row(
children: [
Flexible(
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkIn!, 'hh:mm a'),
fontWeight: 600,
), ),
MySpacing.width(16), MySpacing.width(16),
], Expanded(
if (emp.checkOut != null) ...[ child: Column(
const Icon(Icons.arrow_circle_left, crossAxisAlignment:
size: 16, color: Colors.red), CrossAxisAlignment.start,
MySpacing.width(4), children: [
MyText.bodySmall( Row(
DateTimeUtils.formatDate( children: [
emp.checkOut!, 'hh:mm a'), Flexible(
fontWeight: 600, child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow
.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow
.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn !=
null) ...[
const Icon(
Icons
.arrow_circle_right,
size: 16,
color:
Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils
.formatDate(
emp.checkIn!,
'hh:mm a'),
fontWeight: 600,
),
MySpacing.width(16),
],
if (emp.checkOut !=
null) ...[
const Icon(
Icons
.arrow_circle_left,
size: 16,
color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils
.formatDate(
emp.checkOut!,
'hh:mm a'),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController:
controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController:
controller,
),
],
),
],
),
), ),
], ],
], ),
), ),
MySpacing.height(12), ],
Row( ),
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController: widget.controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController: widget.controller,
),
],
),
],
), ),
), ],
], );
), },
), ),
Divider(color: Colors.grey.withOpacity(0.3)), ),
],
],
],
),
),
], ],
); );
}); });

View File

@ -1,6 +1,5 @@
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';
@ -8,12 +7,15 @@ 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});
@ -22,45 +24,84 @@ class AttendanceScreen extends StatefulWidget {
State<AttendanceScreen> createState() => _AttendanceScreenState(); State<AttendanceScreen> createState() => _AttendanceScreenState();
} }
class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin { class _AttendanceScreenState extends State<AttendanceScreen>
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.find<ProjectController>(); final projectController = Get.put(ProjectController());
String selectedTab = 'todaysAttendance'; late TabController _tabController;
late List<Map<String, String>> _tabs;
bool _tabsInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { ever(permissionController.permissionsLoaded, (loaded) {
// 🔁 Listen for project changes if (loaded == true && !_tabsInitialized) {
ever<String>(projectController.selectedProjectId, (projectId) async { WidgetsBinding.instance.addPostFrameCallback((_) {
if (projectId.isNotEmpty) await _loadData(projectId); _initializeTabs();
}); setState(() {});
});
// 🚀 Load initial data only once the screen is shown }
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId);
}); });
}
Future<void> _loadData(String projectId) async { // Watch project changes to reload data
try { ever<String>(projectController.selectedProjectId, (projectId) async {
attendanceController.selectedTab = 'todaysAttendance'; if (projectId.isNotEmpty && _tabsInitialized) {
await attendanceController.loadAttendanceData(projectId); await _fetchTabData(attendanceController.selectedTab);
attendanceController.update(['attendance_dashboard_controller']); }
} catch (e) { });
debugPrint("Error loading data: $e");
// If permissions are already loaded at init
if (permissionController.permissionsLoaded.value) {
_initializeTabs();
} }
} }
Future<void> _refreshData() async { 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;
if (projectId.isNotEmpty) {
final initialTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = initialTab;
await _fetchTabData(initialTab);
}
}
Future<void> _fetchTabData(String tab) async {
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
// Call only the relevant API for current tab switch (tab) {
switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId); await attendanceController.fetchTodaysAttendance(projectId);
break; break;
@ -77,59 +118,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
} }
} }
Widget _buildAppBar() { Future<void> _refreshData() async {
return AppBar( await _fetchTabData(attendanceController.selectedTab);
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() {
@ -165,11 +155,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
@ -177,17 +167,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}), }),
), ),
), ),
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(10), borderRadius: BorderRadius.circular(5),
), ),
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -200,19 +187,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: borderRadius:
BorderRadius.vertical(top: Radius.circular(12)), BorderRadius.vertical(top: Radius.circular(5)),
), ),
builder: (context) => AttendanceFilterBottomSheet( builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController, controller: attendanceController,
permissionController: permissionController, permissionController: permissionController,
selectedTab: selectedTab, selectedTab: _tabs[_tabController.index]['value']!,
), ),
); );
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?;
@ -223,111 +209,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
} }
if (selectedProjectId.isNotEmpty) { if (selectedProjectId.isNotEmpty) {
try { await _fetchTabData(attendanceController.selectedTab);
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,
),
),
),
],
);
}),
], ],
), ),
); );
@ -346,66 +233,121 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
); );
} }
Widget _buildSelectedTabContent() { Widget _buildTabBarView() {
switch (selectedTab) { return TabBarView(
case 'attendanceLogs': controller: _tabController,
return AttendanceLogsTab(controller: attendanceController); children: _tabs.map((tab) {
case 'regularizationRequests': switch (tab['value']) {
return RegularizationRequestsTab(controller: attendanceController); case 'attendanceLogs':
case 'todaysAttendance': return AttendanceLogsTab(controller: attendanceController);
default: case 'regularizationRequests':
return TodaysAttendanceTab(controller: attendanceController); return RegularizationRequestsTab(controller: attendanceController);
} case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}).toList(),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId = projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator( if (!_tabsInitialized) {
onRefresh: _refreshData, return Scaffold(
child: SingleChildScrollView( appBar: CustomAppBar(
physics: const AlwaysScrollableScrollPhysics(), title: "Attendance",
padding: MySpacing.zero, backgroundColor: appBarColor,
child: Column( onBackPressed: () => Get.toNamed('/dashboard'),
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ body: const Center(child: CircularProgressIndicator()),
MySpacing.height(flexSpacing), );
_buildFilterSearchRow(), }
MyFlex(
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>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId =
projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator(
onRefresh: _refreshData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero,
child: Column(
children: [ children: [
MyFlexItem( Padding(
sizes: 'lg-12 md-12 sm-12', padding: const EdgeInsets.symmetric(horizontal: 8),
child: noProjectSelected child: PillTabBar(
? _buildNoProjectWidget() controller: _tabController,
: _buildSelectedTabContent(), 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(),
MyFlex(
children: [
MyFlexItem(
sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected
? _buildNoProjectWidget()
: SizedBox(
height:
MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
),
],
), ),
], ],
), ),
], ),
), );
), },
); ),
}, ),
), ],
), ),
); );
} }
@override @override
void dispose() { void dispose() {
// 🧹 Clean up the controller when user leaves this screen _tabController.dispose();
if (Get.isRegistered<AttendanceController>()) { if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>(); Get.delete<AttendanceController>();
} }

View File

@ -1,4 +1,3 @@
// 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';
@ -19,140 +18,136 @@ class RegularizationRequestsTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Obx(() {
crossAxisAlignment: CrossAxisAlignment.start, final isLoading = controller.isLoadingRegularizationLogs.value;
children: [ final employees = controller.filteredRegularizationLogs;
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
final employees = controller.filteredRegularizationLogs;
if (controller.isLoadingRegularizationLogs.value) { if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader(); return SkeletonLoaders.employeeListSkeletonLoader();
} }
if (employees.isEmpty) { if (employees.isEmpty) {
return const SizedBox( return const SizedBox(
height: 120, height: 120,
child: Center( child: Center(
child: child: Text("No Regularization Requests Found for this Project"),
Text("No Regularization Requests Found for this Project"), ),
), );
); }
}
return MyCard.bordered( return ListView.builder(
paddingAll: 8, itemCount: employees.length,
child: Column( padding: MySpacing.only(bottom: 80),
children: List.generate(employees.length, (index) { itemBuilder: (context, index) {
final employee = employees[index]; final employee = employees[index]; // Corrected index
return Column(
children: [ return Padding(
MyContainer( padding: const EdgeInsets.only(bottom: 12),
paddingAll: 8, child: MyCard.bordered(
child: Row( paddingAll: 8,
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ children: [
Avatar( MyContainer(
firstName: employee.firstName, paddingAll: 8,
lastName: employee.lastName, child: Row(
size: 31, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
MySpacing.width(16), Avatar(
Expanded( firstName: employee.firstName,
child: Column( lastName: employee.lastName,
crossAxisAlignment: CrossAxisAlignment.start, size: 35,
children: [ ),
Row( MySpacing.width(16),
children: [ Expanded(
Flexible( child: Column(
child: MyText.bodyMedium( crossAxisAlignment: CrossAxisAlignment.start,
employee.name, children: [
fontWeight: 600, Row(
overflow: TextOverflow.ellipsis, children: [
), Flexible(
child: MyText.bodyMedium(
employee.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
), ),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.role})',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkIn!),
fontWeight: 600,
),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
fontWeight: 600,
),
],
],
), ),
MySpacing.height(12), MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.role})',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
RegularizeActionButton( if (employee.checkIn != null) ...[
attendanceController: controller, const Icon(Icons.arrow_circle_right,
log: employee, size: 16, color: Colors.green),
uniqueLogKey: employee.employeeId, MySpacing.width(4),
action: ButtonActions.approve, MyText.bodySmall(
), DateFormat('hh:mm a')
const SizedBox(width: 8), .format(employee.checkIn!),
RegularizeActionButton( fontWeight: 600,
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.reject,
),
const SizedBox(width: 8),
if (employee.checkIn != null)
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
), ),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
fontWeight: 600,
),
],
], ],
), ),
], MySpacing.height(12),
), Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.approve,
),
const SizedBox(width: 8),
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.reject,
),
const SizedBox(width: 8),
if (employee.checkIn != null)
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
),
],
), ),
], ),
), ],
), ),
if (index != employees.length - 1) ),
Divider(color: Colors.grey.withOpacity(0.3)),
], ],
); ),
}),
), ),
); );
}), },
], );
); });
} }
} }

View File

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

View File

@ -8,6 +8,7 @@ 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 }
@ -31,6 +32,8 @@ 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();
@ -43,6 +46,15 @@ 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
@ -142,11 +154,14 @@ class _WelcomeScreenState extends State<WelcomeScreen>
option: null, option: null,
), ),
const SizedBox(height: 36), const SizedBox(height: 36),
MyText(
'App version 1.0.0', // Dynamic App Version
color: Colors.grey, if (_appVersion.isNotEmpty)
fontSize: 12, MyText(
), 'App version $_appVersion',
color: Colors.grey,
fontSize: 12,
),
], ],
), ),
), ),

View File

@ -1,22 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart'; import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/avatar.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/dashbaord/dashboard_overview_widgets.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'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/model/attendance/attendence_action_button.dart';
import 'package:on_field_work/model/attendance/log_details_view.dart';
import 'package:on_field_work/view/layouts/layout.dart';
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@ -28,6 +31,8 @@ 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>();
@ -41,83 +46,212 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
Future<void> _checkMpinStatus() async { Future<void> _checkMpinStatus() async {
hasMpin = await LocalStorage.getIsMpin(); hasMpin = await LocalStorage.getIsMpin();
if (mounted) setState(() {}); if (mounted) {
setState(() {});
}
} }
@override // ---------------------------------------------------------------------------
Widget build(BuildContext context) { // Helpers
return Layout( // ---------------------------------------------------------------------------
child: SingleChildScrollView(
padding: const EdgeInsets.all(16), Widget _cardWrapper({required Widget child}) {
child: Column( return Container(
crossAxisAlignment: CrossAxisAlignment.start, margin: const EdgeInsets.only(bottom: 16),
children: [ decoration: BoxDecoration(
_buildDashboardCards(), color: Colors.white,
MySpacing.height(24), borderRadius: BorderRadius.circular(5),
_buildAttendanceChartSection(), border: Border.all(color: Colors.black12.withOpacity(.04)),
MySpacing.height(24), boxShadow: [
_buildProjectProgressChartSection(), BoxShadow(
MySpacing.height(24), color: Colors.black12.withOpacity(.05),
SizedBox( blurRadius: 12,
width: double.infinity, offset: const Offset(0, 4),
child: DashboardOverviewWidgets.teamsOverview(), ),
), ],
MySpacing.height(24), ),
SizedBox( child: child,
width: double.infinity, );
child: DashboardOverviewWidgets.tasksOverview(), }
),
MySpacing.height(24), Widget _sectionTitle(String title) {
ExpenseByStatusWidget(controller: dashboardController), return Padding(
MySpacing.height(24), padding: const EdgeInsets.only(left: 4, bottom: 8),
ExpenseTypeReportChart(), child: Text(
MySpacing.height(24), title,
MonthlyExpenseDashboardChart(), style: const TextStyle(
], fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
), ),
), ),
); );
} }
/// ---------------- Dynamic Dashboard Cards ---------------- // ---------------------------------------------------------------------------
Widget _buildDashboardCards() { // 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 30,
),
MySpacing.width(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
employee.name,
fontWeight: 600,
color: Colors.white,
),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.white70,
),
],
),
),
MyText.bodySmall(
statusText,
fontWeight: 600,
color: Colors.white,
),
],
),
const SizedBox(height: 12),
Text(
!isCheckedIn
? 'You are not checked-in yet. Please check-in to start your work.'
: !isCheckedOut
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
: 'You have checked-out for today.',
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: attendanceController,
),
if (isCheckedIn) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: attendanceController,
),
],
],
),
],
),
);
}),
],
);
}
// ---------------------------------------------------------------------------
// Dashboard Modules
// ---------------------------------------------------------------------------
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 projectSelected = projectController.selectedProject != null; final bool projectSelected = projectController.selectedProject != null;
// Define dashboard card meta with order // these are String constants from permission_constants.dart
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> cardMeta = { final Map<String, _DashboardCardMeta> meta = {
MenuItems.attendance: MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success), _DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees: MenuItems.employees:
_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:
@ -126,180 +260,328 @@ 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),
}; };
// Filter only available menus that exist in cardMeta final Map<String, dynamic> allowed = {
final allowedMenusMap = { for (final m in menuController.menuItems)
for (var menu in menuController.menuItems) if (m.available && meta.containsKey(m.id)) m.id: m,
if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu
}; };
if (allowedMenusMap.isEmpty) { final List<String> filtered =
return const Center( cardOrder.where((id) => allowed.containsKey(id)).toList();
child: Text(
"No accessible modules found.", return Column(
style: TextStyle(color: Colors.grey), crossAxisAlignment: CrossAxisAlignment.start,
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]!;
// Create list of cards in fixed order final bool isEnabled =
final stats = item.name == 'Attendance' ? true : projectSelected;
cardOrder.where((id) => allowedMenusMap.containsKey(id)).map((id) {
final menu = allowedMenusMap[id]!;
final meta = cardMeta[id]!;
return _DashboardStatItem(
meta.icon, menu.name, meta.color, menu.mobileLink);
}).toList();
return LayoutBuilder(builder: (context, constraints) { return GestureDetector(
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); onTap: () {
double cardWidth = if (!isEnabled) {
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; Get.snackbar(
'Required',
return Wrap( 'Please select a project first',
spacing: 6, snackPosition: SnackPosition.BOTTOM,
runSpacing: 6, margin: const EdgeInsets.all(16),
alignment: WrapAlignment.start, backgroundColor: Colors.black87,
children: stats colorText: Colors.white,
.map((stat) => duration: const Duration(seconds: 2),
_buildDashboardCard(stat, projectSelected, cardWidth)) );
.toList(), } else {
); Get.toNamed(item.mobileLink);
}); }
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isEnabled
? Colors.black12.withOpacity(0.06)
: Colors.transparent,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
cardMeta.icon,
size: 20,
color:
isEnabled ? cardMeta.color : Colors.grey.shade300,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
item.name,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
fontWeight:
isEnabled ? FontWeight.w600 : FontWeight.w400,
color: isEnabled
? Colors.black87
: Colors.grey.shade400,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
],
);
}); });
} }
Widget _buildDashboardCard( // ---------------------------------------------------------------------------
_DashboardStatItem stat, bool isProjectSelected, double width) { // Project Selector
final isEnabled = stat.title == "Attendance" ? true : isProjectSelected; // ---------------------------------------------------------------------------
return Opacity( Widget _projectSelector() {
opacity: isEnabled ? 1.0 : 0.4, return Obx(() {
child: IgnorePointer( final bool isLoading = projectController.isLoading.value;
ignoring: !isEnabled, final bool expanded = projectController.isProjectSelectionExpanded.value;
child: InkWell( final projects = projectController.projects;
onTap: () => _onDashboardCardTap(stat, isEnabled), final String? selectedId = projectController.selectedProjectId.value;
borderRadius: BorderRadius.circular(5),
child: MyCard.bordered( if (isLoading) {
width: width, return SkeletonLoaders.dashboardCardsSkeleton(
height: 60, maxWidth: MediaQuery.of(context).size.width,
paddingAll: 4, );
borderRadiusAll: 5, }
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( _sectionTitle('Project'),
padding: const EdgeInsets.all(4), GestureDetector(
decoration: BoxDecoration( onTap: () => projectController.isProjectSelectionExpanded.toggle(),
color: stat.color.withOpacity(0.1), child: Container(
borderRadius: BorderRadius.circular(4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.15)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
), ),
child: Icon( ],
stat.icon, ),
size: 16, child: Row(
color: stat.color, children: [
const Icon(
Icons.work_outline,
color: Colors.blue,
size: 20,
), ),
), const SizedBox(width: 12),
MySpacing.height(4), Expanded(
Flexible( child: Text(
child: Text( projects
stat.title, .firstWhereOrNull(
textAlign: TextAlign.center, (p) => p.id == selectedId,
style: const TextStyle( )
fontSize: 10, ?.name ??
overflow: TextOverflow.ellipsis, 'Select Project',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
), ),
maxLines: 2,
), ),
), Icon(
], expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 26,
color: Colors.black54,
),
],
),
), ),
), ),
if (expanded) _projectDropdownList(projects, selectedId),
],
);
});
}
Widget _projectDropdownList(List projects, String? selectedId) {
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.2)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.07),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.33,
),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: 'Search project...',
isDense: true,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
),
),
const SizedBox(height: 10),
Expanded(
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, index) {
final project = projects[index];
return RadioListTile<String>(
dense: true,
value: project.id,
groupValue: selectedId,
onChanged: (value) {
if (value != null) {
projectController.updateSelectedProject(value);
projectController.isProjectSelectionExpanded.value =
false;
}
},
title: Text(project.name),
);
},
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff5f6fa),
body: Layout(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column(
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),
],
),
), ),
), ),
); );
} }
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 ----------------
Widget _buildAttendanceChartSection() {
return Obx(() {
final attendanceMenu = menuController.menuItems
.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(),
),
),
),
);
});
}
}
/// ---------------- Dashboard Card Models ----------------
class _DashboardStatItem {
final IconData icon;
final String title;
final Color color;
final String route;
_DashboardStatItem(this.icon, this.title, this.color, this.route);
} }
class _DashboardCardMeta { class _DashboardCardMeta {
final IconData icon; final IconData icon;
final Color color; final Color color;
_DashboardCardMeta(this.icon, this.color);
const _DashboardCardMeta(this.icon, this.color);
} }

View File

@ -13,6 +13,7 @@ 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;
@ -23,18 +24,21 @@ class ContactDetailScreen extends StatefulWidget {
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> class _ContactDetailScreenState extends State<ContactDetailScreen>
with UIMixin { with SingleTickerProviderStateMixin, 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.find<ProjectController>(); projectController = Get.put(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);
@ -50,60 +54,53 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
} }
@override @override
Widget build(BuildContext context) { void dispose() {
return DefaultTabController( _tabController.dispose();
length: 2, super.dispose();
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildMainAppBar(),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(children: [
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
]),
),
],
),
),
),
);
} }
PreferredSizeWidget _buildMainAppBar() { @override
return AppBar( Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.2,
automaticallyImplyLeading: false, // AppBar is outside SafeArea (correct)
titleSpacing: 0, appBar: CustomAppBar(
title: Padding( title: 'Contact Profile',
padding: MySpacing.xy(16, 0), backgroundColor: appBarColor,
child: Row( onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
crossAxisAlignment: CrossAxisAlignment.center, ),
// Only the content is wrapped inside SafeArea
body: SafeArea(
child: Column(
children: [ children: [
IconButton( // ************ GRADIENT + SUBHEADER + TABBAR ************
icon: const Icon(Icons.arrow_back_ios_new, Container(
color: Colors.black, size: 20), width: double.infinity,
onPressed: () => padding: const EdgeInsets.only(bottom: 8),
Get.offAllNamed('/dashboard/directory-main-page'), decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
contentTheme.primary,
contentTheme.primary.withOpacity(0),
],
),
),
child: Obx(() => _buildSubHeader(contactRx.value)),
), ),
MySpacing.width(8),
// ************ TAB CONTENT ************
Expanded( Expanded(
child: Column( child: TabBarView(
crossAxisAlignment: CrossAxisAlignment.start, controller: _tabController,
mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge('Contact Profile', Obx(() => _buildDetailsTab(contactRx.value)),
fontWeight: 700, color: Colors.black), _buildCommentsTab(),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (p) {
return ProjectLabel(p.selectedProject?.name);
}),
], ],
), ),
), ),
@ -118,7 +115,10 @@ 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 : "";
return Padding( final Color primaryColor = contentTheme.primary;
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,20 +137,53 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
], ],
), ),
]), ]),
TabBar( MySpacing.height(12),
labelColor: Colors.black, // === MODERN PILL-SHAPED TABBAR ===
unselectedLabelColor: Colors.grey, Container(
indicatorColor: contentTheme.primary, height: 48,
tabs: const [ decoration: BoxDecoration(
Tab(text: "Details"), color: Colors.white,
Tab(text: "Notes"), 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 [
Tab(text: "Details"),
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
@ -228,7 +261,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow(Icons.business, "Organization", contact.organization), _iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
_infoCard("Meta Info", [ _infoCard("Meta Info", [
@ -281,6 +315,7 @@ 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;
@ -622,25 +657,3 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
); );
} }
} }
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -3,12 +3,13 @@ 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/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.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});
@ -18,7 +19,7 @@ class DirectoryMainScreen extends StatefulWidget {
} }
class _DirectoryMainScreenState extends State<DirectoryMainScreen> class _DirectoryMainScreenState extends State<DirectoryMainScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, UIMixin {
late TabController _tabController; late TabController _tabController;
final DirectoryController controller = Get.put(DirectoryController()); final DirectoryController controller = Get.put(DirectoryController());
@ -38,91 +39,55 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
@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(0xFFF1F1F1),
appBar: PreferredSize( appBar: CustomAppBar(
preferredSize: const Size.fromHeight(72), title: "Directory",
child: AppBar( onBackPressed: () => Get.offNamed('/dashboard'),
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
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,
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],
),
),
],
);
},
),
],
),
),
],
),
),
),
), ),
body: Column( body: Stack(
children: [ children: [
// ---------------- TabBar ---------------- // === TOP GRADIENT ===
Container( Container(
color: Colors.white, height: 50,
child: TabBar( decoration: BoxDecoration(
controller: _tabController, gradient: LinearGradient(
labelColor: Colors.black, begin: Alignment.topCenter,
unselectedLabelColor: Colors.grey, end: Alignment.bottomCenter,
indicatorColor: Colors.red, colors: [
tabs: const [ appBarColor,
Tab(text: "Directory"), appBarColor.withOpacity(0.0),
Tab(text: "Notes"), ],
], ),
), ),
), ),
// ---------------- TabBarView ---------------- SafeArea(
Expanded( top: false,
child: TabBarView( bottom: true,
controller: _tabController, child: Column(
children: [ children: [
DirectoryView(), PillTabBar(
NotesView(), controller: _tabController,
tabs: const ["Directory", "Notes"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
// === TABBAR VIEW ===
Expanded(
child: TabBarView(
controller: _tabController,
children: [
DirectoryView(),
NotesView(),
],
),
),
], ],
), ),
), ),

View File

@ -13,6 +13,7 @@ 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;
@ -23,7 +24,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState(); State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
} }
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
@ -49,50 +50,78 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
@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: Obx(() { body: Stack(
if (controller.isLoading.value) { children: [
return SkeletonLoaders.documentDetailsSkeletonLoader(); // Gradient behind content
} Container(
height: 80,
final docResponse = controller.documentDetails.value; decoration: BoxDecoration(
if (docResponse == null || docResponse.data == null) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium( end: Alignment.bottomCenter,
"Failed to load document details.", colors: [
color: Colors.grey, appBarColor,
), appBarColor.withOpacity(0.0),
); ],
} ),
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
const SizedBox(height: 10),
_buildVersionsSection(),
],
), ),
), ),
);
}), // Main content
SafeArea(
child: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium(
"Versions",
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
);
}),
),
],
),
); );
} }

View File

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

View File

@ -3,6 +3,7 @@ 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;
@ -14,12 +15,15 @@ class EmployeeProfilePage extends StatefulWidget {
} }
class _EmployeeProfilePageState extends State<EmployeeProfilePage> class _EmployeeProfilePageState extends State<EmployeeProfilePage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin, UIMixin {
// 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);
} }
@ -29,46 +33,109 @@ 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: Column( body: Stack(
children: [ children: [
// ---------------- TabBar outside AppBar ---------------- // === Gradient at the top behind AppBar + Toggle ===
// This container ensures the background color transitions nicely
Container( Container(
color: Colors.white, height: 50,
child: TabBar( decoration: BoxDecoration(
controller: _tabController, gradient: LinearGradient(
labelColor: Colors.black, begin: Alignment.topCenter,
unselectedLabelColor: Colors.grey, end: Alignment.bottomCenter,
indicatorColor: Colors.red, colors: [
tabs: const [ appBarColor,
Tab(text: "Details"), appBarColor.withOpacity(0.0),
Tab(text: "Documents"), ],
], ),
), ),
), ),
// === Main Content Area ===
// ---------------- TabBarView ---------------- SafeArea(
Expanded( top: false,
child: TabBarView( bottom: true,
controller: _tabController, child: Column(
children: [ children: [
// Details Tab // 🛑 NEW: The Modern TabBar Implementation 🛑
EmployeeDetailPage( Padding(
employeeId: widget.employeeId, padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
fromProfile: true, child: Container(
), height: 48, // Define a specific height for the TabBar container
decoration: BoxDecoration(
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(
controller: _tabController,
// Style the indicator as a subtle pill/chip
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1), // Light background color for the selection
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,
),
// Documents Tab // Tabs (No custom widget needed, just use the built-in Tab)
UserDocumentsPage( tabs: const [
entityId: widget.employeeId, Tab(text: "Details"),
isEmployee: true, Tab(text: "Documents"),
],
// Setting this to zero removes the default underline
dividerColor: Colors.transparent,
),
),
),
// 🛑 TabBarView (The Content) 🛑
Expanded(
child: TabBarView(
controller: _tabController,
children: [
EmployeeDetailPage(
employeeId: widget.employeeId,
fromProfile: true,
),
UserDocumentsPage(
entityId: widget.employeeId,
isEmployee: true,
),
],
),
), ),
], ],
), ),
@ -77,4 +144,4 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
), ),
); );
} }
} }

View File

@ -17,6 +17,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';
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});
@ -104,7 +105,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => AssignProjectBottomSheet( builder: (_) => AssignProjectBottomSheet(
employeeId: employeeId, employeeId: employeeId,
jobRoleId: employeeData['jobRoleId'] as String, jobRoleId: employeeData['jobRoleId'] as String,
), ),
); );
@ -113,98 +114,69 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
backgroundColor: Colors.white,
appBar: _buildAppBar(),
floatingActionButton: _buildFloatingActionButton(),
body: SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
);
}
PreferredSizeWidget _buildAppBar() { return Scaffold(
return PreferredSize( backgroundColor: const Color(0xFFF5F5F5),
preferredSize: const Size.fromHeight(72), appBar: CustomAppBar(
child: AppBar( title: "Employees",
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5, projectName: Get.find<ProjectController>().selectedProject?.name ??
automaticallyImplyLeading: false, 'Select Project',
titleSpacing: 0, onBackPressed: () => Get.offNamed('/dashboard'),
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],
),
),
],
);
},
),
],
),
),
],
),
),
), ),
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>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
],
),
floatingActionButton: _buildFloatingActionButton(),
); );
} }

View File

@ -330,14 +330,11 @@ class _ManageReportingBottomSheetState
final EmployeesScreenController controller = Get.find(); final EmployeesScreenController controller = Get.find();
await controller.fetchReportingManagers(empId); await controller.fetchReportingManagers(empId);
await controller.fetchEmployeeDetails(empId); await controller.fetchEmployeeDetails(empId);
} catch (_) { } catch (_) {}
}
// Optional: re-fetch the organization hierarchy list (if needed elsewhere) // Optional: re-fetch the organization hierarchy list (if needed elsewhere)
await ApiService.getOrganizationHierarchyList(employeeId); await ApiService.getOrganizationHierarchyList(employeeId);
_resetForm(); _resetForm();
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -389,6 +386,17 @@ class _ManageReportingBottomSheetState
], ],
); );
// 🔥 WRAP EVERYTHING IN SAFEAREA + SCROLL + BOTTOM PADDING
final safeWrappedContent = SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewPadding.bottom + 20,
left: 16, right: 16, top: 8,
),
child: content,
),
);
if (widget.renderAsCard) { if (widget.renderAsCard) {
// Inline card for profile screen // Inline card for profile screen
return Card( return Card(
@ -397,7 +405,7 @@ class _ManageReportingBottomSheetState
elevation: 2, elevation: 2,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: content, child: safeWrappedContent,
), ),
); );
} }
@ -409,7 +417,7 @@ class _ManageReportingBottomSheetState
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
onCancel: _handleCancel, onCancel: _handleCancel,
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: content, child: safeWrappedContent,
); );
} }

View File

@ -14,7 +14,7 @@ import 'package:on_field_work/controller/expense/add_expense_controller.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
@ -81,34 +81,62 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
canSubmit.value = result; canSubmit.value = result;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final Color appBarColor = contentTheme.primary;
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) { return Scaffold(
_checkPermissionToSubmit(expense); backgroundColor: const Color(0xFFF7F7F7),
}); appBar: CustomAppBar(
title: "Expense Details",
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),
],
),
),
),
final statusColor = getExpenseStatusColor(expense.status.name, // Main content
colorCode: expense.status.color); SafeArea(
final formattedAmount = formatExpenseAmount(expense.amount); child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
return MyRefreshIndicator( final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();
}, },
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),
@ -122,21 +150,21 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
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', MyText.bodyMedium('Amount', fontWeight: 600),
fontWeight: 600),
const SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodyLarge( MyText.bodyLarge(
formattedAmount, formattedAmount,
@ -146,7 +174,6 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
], ],
), ),
const Spacer(), const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved) if (expense.preApproved)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -165,19 +192,19 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
), ),
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,
@ -189,122 +216,109 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
), ),
), ),
), ),
)); ),
}), );
), }),
floatingActionButton: Obx(() { ),
if (controller.isLoading.value) return buildLoadingSkeleton(); ],
),
floatingActionButton: Obx(() {
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 const SizedBox.shrink();
} }
if (!_checkedPermission) { if (!_checkedPermission) {
_checkedPermission = true; _checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense); _checkPermissionToSubmit(expense);
}); });
} }
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return FloatingActionButton.extended( return FloatingActionButton.extended(
onPressed: () async { onPressed: () async {
final editData = { final editData = {
'id': expense.id, 'id': expense.id,
'projectName': expense.project.name, 'projectName': expense.project.name,
'amount': expense.amount, 'amount': expense.amount,
'supplerName': expense.supplerName, 'supplerName': expense.supplerName,
'description': expense.description, 'description': expense.description,
'transactionId': expense.transactionId, 'transactionId': expense.transactionId,
'location': expense.location, 'location': expense.location,
'transactionDate': expense.transactionDate, 'transactionDate': expense.transactionDate,
'noOfPersons': expense.noOfPersons, 'noOfPersons': expense.noOfPersons,
'expensesTypeId': expense.expensesType.id, 'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id, 'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id, 'paidById': expense.paidBy.id,
'paidByFirstName': expense.paidBy.firstName, 'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName, 'paidByLastName': expense.paidBy.lastName,
'attachments': expense.documents 'attachments': expense.documents
.map((doc) => { .map((doc) => {
'url': doc.preSignedUrl, 'url': doc.preSignedUrl,
'fileName': doc.fileName, 'fileName': doc.fileName,
'documentId': doc.documentId, 'documentId': doc.documentId,
'contentType': doc.contentType, 'contentType': doc.contentType,
}) })
.toList(), .toList(),
}; };
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController()); final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
await addCtrl.loadMasterData(); await showAddExpenseBottomSheet(isEdit: true);
addCtrl.populateFieldsForEdit(editData); await controller.fetchExpenseDetails();
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.edit),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
await showAddExpenseBottomSheet(isEdit: true); return SafeArea(
await controller.fetchExpenseDetails(); child: Container(
}, decoration: const BoxDecoration(
backgroundColor: contentTheme.primary, color: Colors.white,
icon: const Icon(Icons.edit), border: Border(top: BorderSide(color: Color(0x11000000))),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
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);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
), ),
); padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
}), child: Wrap(
); alignment: WrapAlignment.center,
} spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
);
}),
);
}
Widget _statusButton(BuildContext context, ExpenseDetailController controller, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {
@ -449,64 +463,6 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
} }
} }
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const _AppBar({required this.projectController});
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
backgroundColor: Colors.white,
title: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expense Details',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _InvoiceHeader extends StatelessWidget { class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense; final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense}); const _InvoiceHeader({required this.expense});

View File

@ -1,5 +1,3 @@
// ignore_for_file: must_be_immutable
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/controller/expense/expense_screen_controller.dart'; import 'package:on_field_work/controller/expense/expense_screen_controller.dart';
@ -8,9 +6,10 @@ 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_text_style.dart'; import 'package:on_field_work/helpers/widgets/my_text_style.dart';
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/model/expense/employee_selector_for_filter_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/date_range_picker.dart'; import 'package:on_field_work/helpers/widgets/date_range_picker.dart';
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
class ExpenseFilterBottomSheet extends StatefulWidget { class ExpenseFilterBottomSheet extends StatefulWidget {
final ExpenseController expenseController; final ExpenseController expenseController;
@ -303,12 +302,13 @@ class _ExpenseFilterBottomSheetState extends State<ExpenseFilterBottomSheet>
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (context) => EmployeeSelectorBottomSheet( builder: (context) => EmployeeSelectionBottomSheet(
selectedEmployees: selectedEmployees, initiallySelected: selectedEmployees.toList(),
searchEmployees: searchEmployees, multipleSelection: true,
title: title, title: title,
), ),
); );
if (result != null) selectedEmployees.assignAll(result); if (result != null) selectedEmployees.assignAll(result);
}, },
child: Container( child: Container(

View File

@ -12,6 +12,8 @@ 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});
@ -87,65 +89,94 @@ 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: ExpenseAppBar(projectController: projectController), appBar: CustomAppBar(
body: Column( title: "Expense & Reimbursement",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'),
),
body: Stack(
children: [ children: [
// ---------------- TabBar ---------------- // === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Container( Positioned.fill(
color: Colors.white, child: Column(
child: TabBar( children: [
controller: _tabController, Container(
labelColor: Colors.black, height: 80,
unselectedLabelColor: Colors.grey, decoration: BoxDecoration(
indicatorColor: Colors.red, gradient: LinearGradient(
tabs: const [ begin: Alignment.topCenter,
Tab(text: "Current Month"), end: Alignment.bottomCenter,
Tab(text: "History"), colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
], ],
), ),
), ),
// ---------------- Gray background for rest ---------------- // === MAIN CONTENT ===
Expanded( SafeArea(
child: Container( top: false,
color: Colors.grey[100], bottom: true,
child: Column( child: Column(
children: [ children: [
// ---------------- Search ---------------- PillTabBar(
Padding( controller: _tabController,
padding: tabs: const ["Current Month", "History"],
const EdgeInsets.symmetric(horizontal: 0, vertical: 0), selectedColor: contentTheme.primary,
child: SearchAndFilter( unselectedColor: Colors.grey.shade600,
controller: searchController, indicatorColor: contentTheme.primary,
onChanged: (_) => setState(() {}), ),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// ---------------- TabBarView ---------------- // CONTENT AREA
Expanded( Expanded(
child: TabBarView( child: Container(
controller: _tabController, color: Colors.transparent,
child: Column(
children: [ children: [
_buildExpenseList(isHistory: false), // SEARCH & FILTER
_buildExpenseList(isHistory: true), Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// TABBAR VIEW
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
], ],
), ),
), ),
], ),
), ],
), ),
), ),
], ],
), ),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
// Show loader or hide FAB while permissions are loading if (permissionController.permissions.isEmpty)
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
final canUpload = final canUpload =
permissionController.hasPermission(Permissions.expenseUpload); permissionController.hasPermission(Permissions.expenseUpload);

View File

@ -3,10 +3,9 @@ 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});
@ -49,142 +48,106 @@ 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( backgroundColor: const Color(0xFFF5F5F5),
0xFFF5F5F5), appBar: CustomAppBar(
appBar: _buildAppBar(), title: "Advance Payments",
body: GestureDetector( onBackPressed: () => Get.offNamed('/dashboard/finance'),
onTap: () => FocusScope.of(context).unfocus(), backgroundColor: appBarColor,
child: RefreshIndicator( ),
onRefresh: () async { body: Stack(
final emp = controller.selectedEmployee.value; children: [
if (emp != null) { // ===== TOP GRADIENT =====
await controller.fetchAdvancePayments(emp.id.toString()); Container(
} height: 100,
}, decoration: BoxDecoration(
color: Colors.white, gradient: LinearGradient(
backgroundColor: contentTheme.primary, begin: Alignment.topCenter,
strokeWidth: 2.5, end: Alignment.bottomCenter,
displacement: 60, colors: [
child: SingleChildScrollView( appBarColor,
physics: const AlwaysScrollableScrollPhysics(), appBarColor.withOpacity(0.0),
child: Container(
color:
const Color(0xFFF5F5F5),
child: Column(
children: [
_buildSearchBar(),
_buildEmployeeDropdown(context),
_buildTopBalance(),
_buildPaymentList(),
], ],
), ),
), ),
), ),
),
),
);
}
// ---------------- AppBar ---------------- // ===== MAIN CONTENT =====
PreferredSizeWidget _buildAppBar() { SafeArea(
return PreferredSize( top: false,
preferredSize: const Size.fromHeight(72), bottom: true,
child: AppBar( child: GestureDetector(
backgroundColor: const Color(0xFFF5F5F5), onTap: () => FocusScope.of(context).unfocus(),
elevation: 0.5, child: RefreshIndicator(
automaticallyImplyLeading: false, onRefresh: () async {
titleSpacing: 0, final emp = controller.selectedEmployee.value;
title: Padding( if (emp != null) {
padding: MySpacing.xy(16, 0), await controller.fetchAdvancePayments(emp.id.toString());
child: Row( }
crossAxisAlignment: CrossAxisAlignment.center, },
children: [ color: Colors.white,
IconButton( backgroundColor: appBarColor,
icon: const Icon(Icons.arrow_back_ios_new, strokeWidth: 2.5,
color: Colors.black, size: 20), displacement: 60,
onPressed: () => Get.offNamed('/dashboard/finance'), child: SingleChildScrollView(
), physics: const AlwaysScrollableScrollPhysics(),
MySpacing.width(8), child: Padding(
Expanded( padding: EdgeInsets.only(
child: Column( bottom: MediaQuery.of(context).padding.bottom + 20,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
), ),
MySpacing.height(2), child: Column(
GetBuilder<ProjectController>( children: [
builder: (_) { // ===== SEARCH BAR FLOATING OVER GRADIENT =====
final name = projectController.selectedProject?.name ?? Padding(
'Select Project'; padding: const EdgeInsets.symmetric(
return Row( horizontal: 12, vertical: 8),
children: [ child: SizedBox(
const Icon(Icons.work_outline, height: 38,
size: 14, color: Colors.grey), child: TextField(
MySpacing.width(4), controller: _searchCtrl,
Expanded( focusNode: _searchFocus,
child: MyText.bodySmall( onChanged: (v) =>
name, controller.searchQuery.value = v.trim(),
fontWeight: 600, decoration: InputDecoration(
overflow: TextOverflow.ellipsis, contentPadding: const EdgeInsets.symmetric(
color: Colors.grey[700], horizontal: 12, vertical: 0),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: appBarColor, width: 1.5),
),
), ),
), ),
], ),
); ),
},
),
],
),
),
],
),
),
),
);
}
// ---------------- Search ---------------- // ===== EMPLOYEE DROPDOWN =====
Widget _buildSearchBar() { _buildEmployeeDropdown(context),
return Container(
color: Colors.grey[100], // ===== TOP BALANCE =====
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), _buildTopBalance(),
child: Row(
children: [ // ===== PAYMENTS LIST =====
Expanded( _buildPaymentList(),
child: SizedBox( ],
height: 38, ),
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) => controller.searchQuery.value = v.trim(),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
), ),
), ),
), ),
@ -322,7 +285,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
); );
} }
// No employee selected yet
if (controller.selectedEmployee.value == null) { if (controller.selectedEmployee.value == null) {
return const Padding( return const Padding(
padding: EdgeInsets.only(top: 100), padding: EdgeInsets.only(top: 100),
@ -330,7 +292,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
); );
} }
// Employee selected but no payments found
if (controller.payments.isEmpty) { if (controller.payments.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.only(top: 100), padding: EdgeInsets.only(top: 100),
@ -340,7 +301,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
); );
} }
// Payments available
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -364,9 +324,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? DateFormat('dd MMM yyyy').format(parsedDate) ? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : ''); : (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? ''; final project = item.name ?? '';
final desc = item.title ?? ''; final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble(); final amount = (item.amount ?? 0).toDouble();
@ -378,7 +335,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: Colors.grey[100],
border: Border( border: Border(
bottom: BorderSide(color: Color(0xFFE0E0E0), width: 0.9), bottom: BorderSide(color: const Color(0xFFE0E0E0), width: 0.9),
), ),
), ),
child: Row( child: Row(
@ -396,16 +353,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
style: style:
TextStyle(fontSize: 12, color: Colors.grey.shade600), TextStyle(fontSize: 12, color: Colors.grey.shade600),
), ),
if (formattedTime.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
formattedTime,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic),
),
]
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@ -6,13 +6,14 @@ 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});
@ -52,232 +53,235 @@ 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: PreferredSize( appBar: CustomAppBar(
preferredSize: const Size.fromHeight(72), title: "Finance",
child: AppBar( onBackPressed: () => Get.offAllNamed( '/dashboard' ),
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: appBarColor,
elevation: 0.5, ),
automaticallyImplyLeading: false, body: Stack(
titleSpacing: 0, children: [
title: Padding( // Top fade under AppBar
padding: MySpacing.xy(16, 0), Container(
child: Row( height: 40,
crossAxisAlignment: CrossAxisAlignment.center, decoration: BoxDecoration(
children: [ gradient: LinearGradient(
IconButton( begin: Alignment.topCenter,
icon: const Icon(Icons.arrow_back_ios_new, end: Alignment.bottomCenter,
color: Colors.black, size: 20), colors: [
onPressed: () => Get.offNamed('/dashboard'), appBarColor,
), appBarColor.withOpacity(0.0),
MySpacing.width(8), ],
Expanded( ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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],
),
),
],
);
},
),
],
),
),
],
), ),
), ),
),
),
body: FadeTransition(
opacity: _fadeAnimation,
child: Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (menuController.hasError.value || menuController.menuItems.isEmpty) { // Bottom fade (above system buttons or FAB)
return const Center( Align(
child: Text( alignment: Alignment.bottomCenter,
"Failed to load menus. Please try again later.", child: Container(
style: TextStyle(color: Colors.red), height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
), ),
);
}
// Filter allowed Finance menus dynamically
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
), ),
); ),
}),
// Main scrollable content
SafeArea(
top: false,
bottom: true,
child: FadeTransition(
opacity: _fadeAnimation,
child: Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
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 financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset + 24,
),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
),
);
}),
),
),
],
), ),
); );
} }
// --- Finance Modules (Compact Dashboard-style) --- // --- Finance Modules (Compact Dashboard-style) ---
Widget _buildFinanceModulesCompact(List<MenuItem> financeMenus) { Widget _buildFinanceModulesCompact(List<MenuItem> financeMenus) {
// Map menu IDs to icon + color // Map menu IDs to icon + color
final Map<String, _FinanceCardMeta> financeCardMeta = { final Map<String, _FinanceCardMeta> financeCardMeta = {
MenuItems.expenseReimbursement: _FinanceCardMeta(LucideIcons.badge_dollar_sign, contentTheme.info), MenuItems.expenseReimbursement:
MenuItems.paymentRequests: _FinanceCardMeta(LucideIcons.receipt_text, contentTheme.primary), _FinanceCardMeta(LucideIcons.badge_dollar_sign, contentTheme.info),
MenuItems.advancePaymentStatements: _FinanceCardMeta(LucideIcons.wallet, contentTheme.warning), MenuItems.paymentRequests:
}; _FinanceCardMeta(LucideIcons.receipt_text, contentTheme.primary),
MenuItems.advancePaymentStatements:
_FinanceCardMeta(LucideIcons.wallet, contentTheme.warning),
};
// Build the stat items using API-provided mobileLink // Build the stat items using API-provided mobileLink
final stats = financeMenus.map((menu) { final stats = financeMenus.map((menu) {
final meta = financeCardMeta[menu.id]!; final meta = financeCardMeta[menu.id]!;
// --- Log the routing info --- // --- Log the routing info ---
debugPrint( debugPrint(
"[Finance Card] ID: ${menu.id}, Title: ${menu.name}, Route: ${menu.mobileLink}"); "[Finance Card] ID: ${menu.id}, Title: ${menu.name}, Route: ${menu.mobileLink}");
return _FinanceStatItem( return _FinanceStatItem(
meta.icon, meta.icon,
menu.name, menu.name,
meta.color, meta.color,
menu.mobileLink, // Each card navigates to its own route menu.mobileLink, // Each card navigates to its own route
); );
}).toList(); }).toList();
final projectSelected = projectController.selectedProject != null; final projectSelected = projectController.selectedProject != null;
return LayoutBuilder(builder: (context, constraints) { return LayoutBuilder(builder: (context, constraints) {
// Determine number of columns dynamically // Determine number of columns dynamically
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
double cardWidth = (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
return Wrap( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
alignment: WrapAlignment.end, alignment: WrapAlignment.end,
children: stats children: stats
.map((stat) => _buildFinanceModuleCard(stat, projectSelected, cardWidth)) .map((stat) =>
.toList(), _buildFinanceModuleCard(stat, projectSelected, cardWidth))
); .toList(),
}); );
} });
}
Widget _buildFinanceModuleCard( Widget _buildFinanceModuleCard(
_FinanceStatItem stat, bool isProjectSelected, double width) { _FinanceStatItem stat, bool isProjectSelected, double width) {
return Opacity( return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4, // Dim if no project selected opacity: isProjectSelected ? 1.0 : 0.4, // Dim if no project selected
child: IgnorePointer( child: IgnorePointer(
ignoring: !isProjectSelected, ignoring: !isProjectSelected,
child: InkWell( child: InkWell(
onTap: () => _onCardTap(stat, isProjectSelected), onTap: () => _onCardTap(stat, isProjectSelected),
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: MyCard.bordered( child: MyCard.bordered(
width: width, width: width,
height: 60, height: 60,
paddingAll: 4, paddingAll: 4,
borderRadiusAll: 5, borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: stat.color.withOpacity(0.1), color: stat.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Icon( child: Icon(
stat.icon, stat.icon,
size: 16, size: 16,
color: stat.color, color: stat.color,
),
),
MySpacing.height(4),
Flexible(
child: Text(
stat.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.ellipsis,
), ),
maxLines: 2,
softWrap: true,
), ),
), MySpacing.height(4),
], Flexible(
child: Text(
stat.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
softWrap: true,
),
),
],
),
), ),
), ),
), ),
),
);
}
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this section.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
); );
} else { }
// Navigate to the card's specific route
Get.toNamed(statItem.route); void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this section.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
// Navigate to the card's specific route
Get.toNamed(statItem.route);
}
} }
} }
}
class _FinanceStatItem { class _FinanceStatItem {
final IconData icon; final IconData icon;

View File

@ -21,6 +21,7 @@ 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;
@ -107,76 +108,101 @@ 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: _buildAppBar(), appBar: CustomAppBar(
body: SafeArea( title: "Payment Request Details",
child: Obx(() { backgroundColor: appBarColor,
if (controller.isLoading.value && ),
controller.paymentRequest.value == null) { body: Stack(
return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); children: [
} // ===== TOP GRADIENT =====
Container(
final request = controller.paymentRequest.value; height: 80,
decoration: BoxDecoration(
if ((controller.errorMessage.value).isNotEmpty) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium(controller.errorMessage.value)); end: Alignment.bottomCenter,
} colors: [
appBarColor,
if (request == null) { appBarColor.withOpacity(0.0),
return Center(child: MyText.bodyMedium("No data to display.")); ],
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
), ),
child: Center( ),
child: Container( ),
constraints: const BoxConstraints(maxWidth: 520),
child: Card( // ===== MAIN CONTENT =====
shape: RoundedRectangleBorder( SafeArea(
borderRadius: BorderRadius.circular(5)), child: Obx(() {
child: Padding( if (controller.isLoading.value &&
padding: const EdgeInsets.symmetric( controller.paymentRequest.value == null) {
vertical: 12, horizontal: 14), return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
child: Column( }
crossAxisAlignment: CrossAxisAlignment.start,
children: [ final request = controller.paymentRequest.value;
_Header(
request: request, if ((controller.errorMessage.value).isNotEmpty) {
colorParser: _parseColor, return Center(
employeeInfo: employeeInfo, child: MyText.bodyMedium(controller.errorMessage.value));
onEdit: () => }
_openEditPaymentRequestBottomSheet(request),
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () =>
_openEditPaymentRequestBottomSheet(request),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
), ),
const Divider(height: 30, thickness: 1.2), ),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
), ),
), ),
), ),
), ),
), );
), }),
); ),
}), ],
), ),
bottomNavigationBar: _buildBottomActionBar(), bottomNavigationBar: _buildBottomActionBar(),
); );
@ -191,11 +217,9 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (!_checkedPermission) { if (!_checkedPermission && request != null && employeeInfo != null) {
_checkedPermission = true; _checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) { _checkPermissionToSubmit(request);
_checkPermissionToSubmit(request);
});
} }
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95'; const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
@ -269,15 +293,20 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
comment: comment.trim(), comment: comment.trim(),
); );
showAppSnackbar( if (!success) {
title: success ? 'Success' : 'Error', showAppSnackbar(
message: success title: 'Error',
? 'Status updated successfully' message: 'Failed to update status',
: 'Failed to update status', type: SnackbarType.error,
type: success ? SnackbarType.success : SnackbarType.error, );
); return;
}
if (success) await controller.fetchPaymentRequestDetail(); showAppSnackbar(
title: 'Success',
message: 'Status updated successfully',
type: SnackbarType.success,
);
} }
}, },
child: MyText.bodySmall( child: MyText.bodySmall(
@ -292,65 +321,6 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
}); });
} }
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Request Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
);
}
} }
class PaymentRequestPermissionHelper { class PaymentRequestPermissionHelper {

View File

@ -13,6 +13,8 @@ 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});
@ -96,41 +98,76 @@ 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: _buildAppBar(), appBar: CustomAppBar(
body: Column( title: "Payment Requests",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [ children: [
Container( // === FULL GRADIENT BEHIND APPBAR & TABBAR ===
color: Colors.white, Positioned.fill(
child: TabBar( child: Column(
controller: _tabController, children: [
labelColor: Colors.black, Container(
unselectedLabelColor: Colors.grey, height: 80,
indicatorColor: Colors.red, decoration: BoxDecoration(
tabs: const [ gradient: LinearGradient(
Tab(text: "Current Month"), begin: Alignment.topCenter,
Tab(text: "History"), end: Alignment.bottomCenter,
], colors: [
), appBarColor,
), appBarColor.withOpacity(0.0),
Expanded(
child: Container(
color: Colors.grey[100],
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
], ],
), ),
), ),
], ),
), Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Current Month", "History"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
// CONTENT AREA
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
],
),
),
],
),
),
),
],
), ),
), ),
], ],
@ -158,67 +195,6 @@ 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),
@ -294,7 +270,6 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
final list = filteredList(isHistory: isHistory); final list = filteredList(isHistory: isHistory);
// ScrollController for infinite scroll
final scrollController = ScrollController(); final scrollController = ScrollController();
scrollController.addListener(() { scrollController.addListener(() {
if (scrollController.position.pixels >= if (scrollController.position.pixels >=
@ -309,6 +284,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
child: list.isEmpty child: list.isEmpty
? ListView( ? ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: [ children: [
SizedBox( SizedBox(
height: MediaQuery.of(context).size.height * 0.5, height: MediaQuery.of(context).size.height * 0.5,
@ -325,7 +301,12 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
) )
: ListView.separated( : ListView.separated(
controller: scrollController, controller: scrollController,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
// ------------------------
// FIX: ensure bottom list items stay visible above nav bar
// ------------------------
padding: const EdgeInsets.fromLTRB(12, 12, 12, 120),
itemCount: list.length + 1, itemCount: list.length + 1,
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20), Divider(color: Colors.grey.shade300, height: 20),
@ -365,10 +346,6 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
Row( Row(
children: [ children: [
MyText.bodyMedium(item.expenseCategory.name, fontWeight: 600), MyText.bodyMedium(item.expenseCategory.name, fontWeight: 600),
// -------------------------------
// ADV CHIP (only if advance)
// -------------------------------
if (item.isAdvancePayment == true) ...[ if (item.isAdvancePayment == true) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(

View File

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

View File

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

View File

@ -7,8 +7,9 @@ import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart'; 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/controller/project_controller.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/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget { class Layout extends StatefulWidget {
final Widget? child; final Widget? child;
@ -20,11 +21,10 @@ class Layout extends StatefulWidget {
State<Layout> createState() => _LayoutState(); State<Layout> createState() => _LayoutState();
} }
class _LayoutState extends State<Layout> { class _LayoutState extends State<Layout> with UIMixin {
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");
final projectController = Get.find<ProjectController>();
bool hasMpin = true; bool hasMpin = true;
@ -58,374 +58,166 @@ class _LayoutState extends State<Layout> {
} }
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: SafeArea( body: Column(
child: GestureDetector( children: [
behavior: HitTestBehavior.translucent, // Solid primary background area
onTap: () { Container(
if (projectController.isProjectSelectionExpanded.value) { width: double.infinity,
projectController.isProjectSelectionExpanded.value = false; color: primaryColor,
} child: _buildHeaderContent(isMobile),
}, ),
child: Stack( Expanded(
children: [ child: Container(
Column( width: double.infinity,
children: [ decoration: BoxDecoration(
_buildHeader(context, isMobile), gradient: LinearGradient(
Expanded( 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(
behavior: HitTestBehavior.translucent,
onTap: () {},
child: SingleChildScrollView( child: SingleChildScrollView(
key: controller.scrollKey, key: controller.scrollKey,
padding: EdgeInsets.symmetric( padding: EdgeInsets.zero,
horizontal: 0, vertical: isMobile ? 16 : 32),
child: widget.child, child: widget.child,
), ),
), ),
],
),
_buildProjectDropdown(context, isMobile),
],
),
),
),
);
}
/// Header Section
Widget _buildHeader(BuildContext context, bool isMobile) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Obx(() {
final isLoading = projectController.isLoading.value;
if (isLoading) {
return _buildLoadingSkeleton();
}
final isExpanded = projectController.isProjectSelectionExpanded.value;
final selectedProjectId = projectController.selectedProjectId.value;
final selectedProject = projectController.projects.firstWhereOrNull(
(p) => p.id == selectedProjectId,
);
final hasProjects = projectController.projects.isNotEmpty;
if (!hasProjects) {
projectController.selectedProjectId.value = '';
} else if (selectedProject == null) {
projectController
.updateSelectedProject(projectController.projects.first.id);
}
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
if (isBetaEnvironment)
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius:
BorderRadius.circular(6), // capsule shape
border: Border.all(
color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 2,
offset: Offset(0, 1),
)
],
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: hasProjects
? (projectController.projects.length > 1
? GestureDetector(
onTap: () => projectController
.isProjectSelectionExpanded
.toggle(),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: MyText.bodyLarge(
selectedProject?.name ??
"Select Project",
fontWeight: 700,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
),
),
Icon(
isExpanded
? Icons
.arrow_drop_up_outlined
: Icons
.arrow_drop_down_outlined,
color: Colors.black,
),
],
),
),
],
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
selectedProject?.name ?? "No Project",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
))
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"No Project Assigned",
fontWeight: 700,
color: Colors.redAccent,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
),
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu),
onPressed: () => controller.scaffoldKey.currentState
?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border:
Border.all(color: Colors.white, width: 2),
),
),
),
],
)
],
), ),
), ),
if (isExpanded && hasProjects)
Positioned(
top: 70,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(5),
color: Colors.white,
child: _buildProjectList(context, isMobile),
),
),
],
),
);
}),
);
}
/// Loading Skeleton for Header
Widget _buildLoadingSkeleton() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
), ),
],
));
}
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container(
margin: const EdgeInsets.only(bottom: 18),
width: double.infinity,
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),
),
],
),
child: Row(
children: [
// Logo section
Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white, width: 1.2),
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(width: 12), const SizedBox(width: 12),
// Titles
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( MyText.bodyLarge(
height: 18, "Dashboard",
width: 140, fontWeight: 700,
color: Colors.grey.shade300, maxLines: 1,
), overflow: TextOverflow.ellipsis,
const SizedBox(height: 6), color: Colors.black87,
Container(
height: 14,
width: 100,
color: Colors.grey.shade200,
), ),
if (selectedTenant != null)
MyText.bodySmall(
"Organization: ${selectedTenant.name}",
color: Colors.black54,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
], ],
), ),
), ),
const SizedBox(width: 10),
Container( // Menu button with red dot if MPIN missing
height: 30, Stack(
width: 30, clipBehavior: Clip.none,
color: Colors.grey.shade300, alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu, color: Colors.black87),
onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
), ),
], ],
), ),
), ),
); );
} }
/// Project List Popup
Widget _buildProjectDropdown(BuildContext context, bool isMobile) {
return Obx(() {
if (!projectController.isProjectSelectionExpanded.value) {
return const SizedBox.shrink();
}
return Positioned(
top: 95,
left: 16,
right: 16,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(5),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.all(10),
child: _buildProjectList(context, isMobile),
),
),
);
});
}
Widget _buildProjectList(BuildContext context, bool isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Switch Project", fontWeight: 600),
const SizedBox(height: 4),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
isMobile ? MediaQuery.of(context).size.height * 0.4 : 400,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: projectController.projects.length,
itemBuilder: (context, index) {
final project = projectController.projects[index];
final selectedId = projectController.selectedProjectId.value;
final isSelected = project.id == selectedId;
return RadioListTile<String>(
value: project.id,
groupValue: selectedId,
onChanged: (value) {
projectController.updateSelectedProject(value!);
projectController.isProjectSelectionExpanded.value = false;
},
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.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
visualDensity: const VisualDensity(vertical: -4),
);
},
),
),
],
);
}
} }

View File

@ -247,6 +247,43 @@ 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 (tenants.length == 1) {
final selectedTenant = tenants.first;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: selectedTenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
selectedTenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: contentTheme.primary),
),
),
const Icon(Icons.check_circle, color: Colors.green, size: 18),
],
),
);
}
final selectedTenant = TenantService.currentTenant; final selectedTenant = TenantService.currentTenant;

View File

@ -14,7 +14,9 @@ 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 {
const MyApp({super.key}); final bool isOffline;
const MyApp({super.key, required this.isOffline});
Future<String> _getInitialRoute() async { Future<String> _getInitialRoute() async {
try { try {
@ -40,6 +42,62 @@ 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>(
@ -71,9 +129,18 @@ class MyApp extends StatelessWidget {
getPages: getPageRoute(), getPages: getPageRoute(),
builder: (context, child) { builder: (context, child) {
NavigationService.registerContext(context); NavigationService.registerContext(context);
return Directionality(
textDirection: AppTheme.textDirection, // 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
child: child ?? const SizedBox(), // This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
); );
}, },
localizationsDelegates: [ localizationsDelegates: [

View File

@ -13,6 +13,7 @@ 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;
@ -429,6 +430,8 @@ 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(
@ -436,55 +439,68 @@ class _ServiceProjectDetailsScreenState
projectName: widget.projectName, projectName: widget.projectName,
onBackPressed: () => Get.toNamed('/dashboard/service-projects'), onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
), ),
body: SafeArea( body: Stack(
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")),
], ],
), ),
), ),
),
// TabBarView SafeArea(
Expanded( top: false,
child: Obx(() { bottom: true,
if (controller.isLoading.value && child: Column(
controller.projectDetail.value == null) { children: [
return const Center(child: CircularProgressIndicator()); PillTabBar(
}
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController, controller: _tabController,
children: [ tabs: const ["Profile", "Jobs", "Teams"],
_buildProfileTab(), selectedColor: contentTheme.primary,
JobsTab( unselectedColor: Colors.grey.shade600,
scrollController: _jobScrollController, indicatorColor: contentTheme.primary.withOpacity(0.1),
projectName: widget.projectName ?? '', height: 48,
), ),
_buildTeamsTab(),
], // === TABBAR VIEW ===
); Expanded(
}), child: Obx(() {
if (controller.isLoading.value &&
controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child:
MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
children: [
_buildProfileTab(),
JobsTab(
scrollController: _jobScrollController,
projectName: widget.projectName ?? '',
),
_buildTeamsTab(),
],
);
}),
),
],
), ),
], ),
), ],
), ),
floatingActionButton: _tabController.index == 1 floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended( ? FloatingActionButton.extended(

View File

@ -18,6 +18,8 @@ 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;
@ -47,10 +49,15 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(ServiceProjectDetailsController()); controller = Get.find<ServiceProjectDetailsController>();
controller.fetchJobDetail(widget.jobId).then((_) {
// Fetch job detail first
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 =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_titleController.text = job.title ?? ''; _titleController.text = job.title ?? '';
_descriptionController.text = job.description ?? ''; _descriptionController.text = job.description ?? '';
_startDateController.text = DateTimeUtils.convertUtcToLocal( _startDateController.text = DateTimeUtils.convertUtcToLocal(
@ -60,7 +67,21 @@ 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 ?? [];
_selectedTags.value = job.tags ?? [];
// 🔹 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,
);
}
}
} }
}); });
} }
@ -75,18 +96,31 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
super.dispose(); super.dispose();
} }
bool _tagsAreDifferent(List<Tag> original, List<Tag> current) {
// Compare by id / name sets (simple equality)
final origIds = original.map((t) => t.id ?? '').toSet();
final currIds = current.map((t) => t.id ?? '').toSet();
final origNames = original.map((t) => t.name?.trim() ?? '').toSet();
final currNames = current.map((t) => t.name?.trim() ?? '').toSet();
return !(origIds == currIds && origNames == currNames);
}
Future<void> _editJob() async { Future<void> _editJob() async {
_processTagsInput(); // process any new tag input
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({
@ -96,6 +130,7 @@ 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);
@ -115,41 +150,57 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}); });
} }
final originalAssignees = job.assignees; // 4 Assignees
final assigneesPayload = originalAssignees?.map((a) { final originalAssignees = job.assignees ?? [];
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();
for (var s in _selectedAssignees) { for (var s in _selectedAssignees) {
if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) { 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});
final originalTags = job.tags; // 5 Tags
final replaceTagsPayload = originalTags?.map((t) { final originalTags = job.tags ?? [];
final isSelected = _selectedTags.any((s) => s.id == t.id); final currentTags = _selectedTags.toList();
return {"id": t.id, "name": t.name, "isActive": isSelected}; if (_tagsAreDifferent(originalTags, currentTags)) {
}).toList(); final List<Map<String, dynamic>> finalTagsPayload = [];
final addTagsPayload = _selectedTags for (var ot in originalTags) {
.where((t) => t.id == "0") final isSelected = currentTags.any((ct) =>
.map((t) => {"name": t.name, "isActive": true}) (ct.id != null && ct.id == ot.id) ||
.toList(); (ct.name?.trim() == ot.name?.trim()));
finalTagsPayload.add({
"id": ot.id,
"name": ot.name,
"isActive": isSelected,
});
}
for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) {
finalTagsPayload.add({"name": ct.name, "isActive": true});
}
if ((replaceTagsPayload?.isNotEmpty ?? false)) {
operations operations
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload}); .add({"op": "replace", "path": "/tags", "value": finalTagsPayload});
} }
if (addTagsPayload.isNotEmpty) { // 6 Job Status
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload}); final selectedStatus = controller.selectedJobStatus.value;
if (selectedStatus != null && selectedStatus.id != job.status?.id) {
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",
@ -158,6 +209,7 @@ 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,
@ -168,7 +220,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
title: "Success", title: "Success",
message: "Job updated successfully", message: "Job updated successfully",
type: SnackbarType.success); type: SnackbarType.success);
// Re-fetch job detail & update tags locally
await controller.fetchJobDetail(widget.jobId); await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) {
_selectedTags.value = (updatedJob.tags ?? [])
.map((t) => Tag(id: t.id, name: t.name))
.toList();
setState(() {});
}
isEditing.value = false; isEditing.value = false;
} else { } else {
showAppSnackbar( showAppSnackbar(
@ -178,6 +240,29 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
} }
} }
void _processTagsInput() {
final input = _tagTextController.text;
// Remove comma behaviour treat whole input as one tag
String tag = input.trim();
if (tag.isEmpty) {
_tagTextController.clear();
return;
}
// Convert underscore to space
tag = tag.replaceAll("_", " ");
// Avoid duplicate tags (case-insensitive)
if (!_selectedTags
.any((t) => (t.name ?? "").toLowerCase() == tag.toLowerCase())) {
_selectedTags.add(Tag(id: "0", name: tag));
}
// Clear text field
_tagTextController.clear();
}
Future<void> _handleTagAction() async { Future<void> _handleTagAction() async {
final job = controller.jobDetail.value?.data; final job = controller.jobDetail.value?.data;
if (job == null) return; if (job == null) return;
@ -402,10 +487,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
border: Border.all(color: Colors.grey.shade400), border: Border.all(color: Colors.grey.shade400),
), ),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text("Tap to select assignees",
"Tap to select assignees", style: TextStyle(fontSize: 14, color: Colors.grey[700])),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
), ),
), ),
], ],
@ -416,19 +499,24 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Widget _tagEditor() { Widget _tagEditor() {
return Obx(() { return Obx(() {
final editing = isEditing.value; final editing = isEditing.value;
final tags = _selectedTags; final job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
spacing: 6, spacing: 6,
children: tags children: displayTags
.map( .map(
(t) => Chip( (t) => Chip(
label: Text(t.name ?? ''), label: Text(t.name ?? ''),
onDeleted: editing onDeleted: editing
? () { ? () {
_selectedTags.remove(t); _selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
} }
: null, : null,
), ),
@ -439,17 +527,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (editing) if (editing)
TextField( TextField(
controller: _tagTextController, controller: _tagTextController,
onSubmitted: (v) { onChanged: (value) {
final value = v.trim(); // If space or comma typed process tags immediately
if (value.isNotEmpty && !tags.any((t) => t.name == value)) { if (value.endsWith(" ") || value.contains(",")) {
_selectedTags.add(Tag(id: "0", name: value)); _processTagsInput();
} }
_tagTextController.clear(); },
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Type and press enter to add tags", hintText: "Type tags (space or comma to add multiple tags)",
border: border: OutlineInputBorder(
OutlineInputBorder(borderRadius: BorderRadius.circular(5)), borderRadius: BorderRadius.circular(5),
),
isDense: true, isDense: true,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12), const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
@ -489,11 +581,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
const Spacer(), const Spacer(),
Obx(() => IconButton( Obx(() => IconButton(
icon: Icon( icon: Icon(
isAttendanceExpanded.value isAttendanceExpanded.value
? Icons.expand_less ? Icons.expand_less
: Icons.expand_more, : Icons.expand_more,
color: Colors.grey[600], color: Colors.grey[600]),
),
onPressed: () async { onPressed: () async {
isAttendanceExpanded.value = isAttendanceExpanded.value =
!isAttendanceExpanded.value; !isAttendanceExpanded.value;
@ -520,22 +611,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
height: 16, height: 16,
width: 16, width: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white, strokeWidth: 2))
strokeWidth: 2,
),
)
: Icon(action == 0 ? Icons.login : Icons.logout), : Icon(action == 0 ? Icons.login : Icons.logout),
label: MyText.bodyMedium( label: MyText.bodyMedium(
action == 0 ? "Tag In" : "Tag Out", action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600, fontWeight: 600,
color: Colors.white, color: Colors.white),
),
onPressed: isLoading ? null : _handleTagAction, onPressed: isLoading ? null : _handleTagAction,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5)),
),
backgroundColor: backgroundColor:
action == 0 ? Colors.green : Colors.red, action == 0 ? Colors.green : Colors.red,
), ),
@ -557,10 +643,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (logs.isEmpty) { if (logs.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium( child: MyText.bodyMedium("No attendance logs available",
"No attendance logs available", color: Colors.grey[600]),
color: Colors.grey[600],
),
); );
} }
@ -595,25 +679,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Row( Row(
children: [ children: [
Icon( Icon(
log.action == 0 ? Icons.login : Icons.logout, log.action == 0
color: log.action == 0 ? Icons.login
? Colors.green : Icons.logout,
: Colors.red, color: log.action == 0
size: 18, ? Colors.green
), : Colors.red,
size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(employeeName,
employeeName, style: const TextStyle(
style: const TextStyle( fontWeight: FontWeight.w600))),
fontWeight: FontWeight.w600), Text("$date | $time",
), style: TextStyle(
), fontSize: 12, color: Colors.grey[700])),
Text(
"$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -621,12 +701,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
// Comment // Comment
if (log.comment?.isNotEmpty == true) if (log.comment?.isNotEmpty == true)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(log.comment!,
log.comment!, style: const TextStyle(fontSize: 13))),
style: const TextStyle(fontSize: 13),
),
),
// Location // Location
if (log.latitude != null && log.longitude != null) if (log.latitude != null && log.longitude != null)
@ -651,14 +728,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Icon(Icons.location_on, Icon(Icons.location_on,
size: 14, color: Colors.blue), size: 14, color: Colors.blue),
SizedBox(width: 4), SizedBox(width: 4),
Text( Text("View Location",
"View Location", style: TextStyle(
style: TextStyle( fontSize: 12,
fontSize: 12, color: Colors.blue,
color: Colors.blue, decoration:
decoration: TextDecoration.underline)),
TextDecoration.underline),
),
], ],
), ),
), ),
@ -673,16 +748,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Image.network( child: Image.network(
log.document!.preSignedUrl, log.document!.preSignedUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
height: 250, height: 250,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon( const Icon(Icons.broken_image,
Icons.broken_image, size: 50,
size: 50, color: Colors.grey)),
color: Colors.grey,
),
),
), ),
), ),
child: ClipRRect( child: ClipRRect(
@ -695,10 +767,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
width: 50, width: 50,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image, Icons.broken_image,
size: 40, size: 40,
color: Colors.grey, color: Colors.grey),
),
), ),
), ),
), ),
@ -722,14 +793,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
child: MyText.bodySmall(label, child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700), fontWeight: 600, color: Colors.grey.shade700)),
), Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
Expanded(
flex: 5,
child: MyText.bodyMedium(value, fontWeight: 500),
),
], ],
); );
} }
@ -751,20 +818,144 @@ 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: contentTheme.primary, backgroundColor: appBarColor,
label: MyText.bodyMedium( label: MyText.bodyMedium(
isEditing.value ? "Save" : "Edit", isEditing.value ? "Save" : "Edit",
color: Colors.white, color: Colors.white,
@ -772,63 +963,115 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
), ),
icon: Icon(isEditing.value ? Icons.save : Icons.edit), icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)), )),
body: Obx(() { body: Stack(
if (controller.isJobDetailLoading.value) { children: [
return const Center(child: CircularProgressIndicator()); Container(
} height: 80,
decoration: BoxDecoration(
if (controller.jobDetailErrorMessage.value.isNotEmpty) { gradient: LinearGradient(
return Center( begin: Alignment.topCenter,
child: MyText.bodyMedium(controller.jobDetailErrorMessage.value)); end: Alignment.bottomCenter,
} colors: [
appBarColor,
final job = controller.jobDetail.value?.data; appBarColor.withOpacity(0.0),
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
], ],
), ),
MySpacing.height(12), ),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]),
MySpacing.height(80),
],
), ),
);
}), // 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) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
controller.jobDetailErrorMessage.value));
}
final job = controller.jobDetail.value?.data;
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildJobStatusCard(),
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
],
),
MySpacing.height(12),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
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),
],
),
);
}),
],
),
); );
} }
} }
@ -865,12 +1108,11 @@ class JobTimeline extends StatelessWidget {
isFirst: index == 0, isFirst: index == 0,
isLast: index == reversedLogs.length - 1, isLast: index == reversedLogs.length - 1,
indicatorStyle: const IndicatorStyle( indicatorStyle: const IndicatorStyle(
width: 16, width: 16,
height: 16, height: 16,
indicator: DecoratedBox( indicator: DecoratedBox(
decoration: decoration: BoxDecoration(
BoxDecoration(color: Colors.blue, shape: BoxShape.circle)), color: Colors.blue, shape: BoxShape.circle))),
),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding( endChild: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -885,13 +1127,12 @@ class JobTimeline extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
Row(children: [ Row(children: [
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2), const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)), borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600), child: MyText.bodySmall(initials, fontWeight: 600)),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded(child: MyText.bodySmall(updatedBy)), Expanded(child: MyText.bodySmall(updatedBy)),
]), ]),

View File

@ -22,11 +22,11 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final ServiceProjectController controller = final ServiceProjectController controller =
Get.put(ServiceProjectController()); Get.put(ServiceProjectController());
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Fetch projects safely after first frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchProjects(); controller.fetchProjects();
}); });
@ -49,10 +49,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
onTap: () { onTap: () {
// Navigate to ServiceProjectDetailsScreen
Get.to(() => ServiceProjectDetailsScreen( Get.to(() => ServiceProjectDetailsScreen(
projectId: project.id, projectId: project.id,
projectName: project.name, projectName: project.name,
)); ));
}, },
child: Padding( child: Padding(
@ -60,7 +59,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
/// Project Header
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -92,20 +90,14 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
), ),
], ],
), ),
MySpacing.height(10), MySpacing.height(10),
/// Assigned Date
_buildDetailRow( _buildDetailRow(
Icons.date_range_outlined, Icons.date_range_outlined,
Colors.teal, Colors.teal,
"Assigned: ${DateTimeUtils.convertUtcToLocal(project.assignedDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}", "Assigned: ${DateTimeUtils.convertUtcToLocal(project.assignedDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}",
fontSize: 13, fontSize: 13,
), ),
MySpacing.height(8), MySpacing.height(8),
/// Client Info
if (project.client != null) if (project.client != null)
_buildDetailRow( _buildDetailRow(
Icons.account_circle_outlined, Icons.account_circle_outlined,
@ -113,20 +105,14 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
"Client: ${project.client!.name} (${project.client!.contactPerson})", "Client: ${project.client!.name} (${project.client!.contactPerson})",
fontSize: 13, fontSize: 13,
), ),
MySpacing.height(8), MySpacing.height(8),
/// Contact Info
_buildDetailRow( _buildDetailRow(
Icons.phone, Icons.phone,
Colors.green, Colors.green,
"Contact: ${project.contactName} (${project.contactPhone})", "Contact: ${project.contactName} (${project.contactPhone})",
fontSize: 13, fontSize: 13,
), ),
MySpacing.height(12), MySpacing.height(12),
/// Services List
if (project.services.isNotEmpty) if (project.services.isNotEmpty)
Wrap( Wrap(
spacing: 6, spacing: 6,
@ -195,90 +181,113 @@ 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: Column( body: Stack(
children: [ children: [
/// Search bar and actions Container(
Padding( height: 80,
padding: MySpacing.xy(8, 8), decoration: BoxDecoration(
child: Row( gradient: LinearGradient(
children: [ begin: Alignment.topCenter,
Expanded( end: Alignment.bottomCenter,
child: SizedBox( colors: [
height: 35, appBarColor,
child: TextField( appBarColor.withOpacity(0.0),
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),
),
),
),
),
),
],
), ),
), ),
/// Project List // Main content
Expanded( SafeArea(
child: Obx(() { bottom: true,
if (controller.isLoading.value) { child: Column(
return const Center(child: CircularProgressIndicator()); children: [
} Padding(
padding: MySpacing.xy(8, 8),
final projects = controller.filteredProjects; child: Row(
return MyRefreshIndicator( children: [
onRefresh: _refreshProjects, Expanded(
backgroundColor: Colors.indigo, child: SizedBox(
color: Colors.white, height: 35,
child: projects.isEmpty child: TextField(
? _buildEmptyState() controller: searchController,
: ListView.separated( decoration: InputDecoration(
physics: const AlwaysScrollableScrollPhysics(), contentPadding:
padding: MySpacing.only( const EdgeInsets.symmetric(horizontal: 12),
left: 8, right: 8, top: 4, bottom: 80), prefixIcon: const Icon(Icons.search,
itemCount: projects.length, size: 20, color: Colors.grey),
separatorBuilder: (_, __) => MySpacing.height(12), suffixIcon:
itemBuilder: (_, index) => ValueListenableBuilder<TextEditingValue>(
_buildProjectCard(projects[index]), 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),
),
),
),
),
), ),
); ],
}), ),
),
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: 120),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
),
), ),
], ],
), ),

View File

@ -1,4 +1,5 @@
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 {
@ -8,8 +9,9 @@ class SplashScreen extends StatefulWidget {
const SplashScreen({ const SplashScreen({
super.key, super.key,
this.message, this.message =
this.logoSize = 120, 'GET WORK DONE, ANYWHERE.', // Default message for a modern look
this.logoSize = 150, // Slightly larger logo
this.backgroundColor = Colors.white, this.backgroundColor = Colors.white,
}); });
@ -20,20 +22,59 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _animation; // Animation for the logo's vertical float effect
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: const Duration(seconds: 1), duration:
const Duration(seconds: 3), // Longer duration for complex sequence
vsync: this, vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.0, end: 8.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
); );
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
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
@ -42,33 +83,14 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose(); super.dispose();
} }
Widget _buildAnimatedDots() { // A simple, modern custom progress indicator
return Row( Widget _buildProgressIndicator() {
mainAxisAlignment: MainAxisAlignment.center, return SizedBox(
children: List.generate(3, (index) { width: 60,
return AnimatedBuilder( child: LinearProgressIndicator(
animation: _animation, backgroundColor: Colors.blueAccent.withOpacity(0.2),
builder: (context, child) { valueColor: const AlwaysStoppedAnimation<Color>(Colors.blueAccent),
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,
),
);
},
);
}),
); );
} }
@ -76,43 +98,56 @@ class _SplashScreenState extends State<SplashScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
body: SafeArea( // Full screen display, no SafeArea needed for a full bleed splash
child: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Logo with slight bounce animation // Animated Logo (Scale, Opacity, and Float)
ScaleTransition( FadeTransition(
scale: Tween(begin: 0.8, end: 1.0).animate( opacity: _opacityAnimation,
CurvedAnimation( child: AnimatedBuilder(
parent: _controller, animation: _floatAnimation,
curve: Curves.easeInOut, builder: (context, child) {
return Transform.translate(
offset: Offset(0, _floatAnimation.value),
child: ScaleTransition(
scale: _scaleAnimation,
child: SizedBox(
width: widget.logoSize,
height: widget.logoSize,
// Replace with your actual logo image widget
child: Image.asset(Images.logoDark),
),
),
);
},
),
),
const SizedBox(height: 30),
// Text Message (Fades in slightly after logo)
if (widget.message != null)
FadeTransition(
opacity: _opacityAnimation,
child: Text(
widget.message!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
letterSpacing: 1.2,
), ),
), ),
child: SizedBox(
width: widget.logoSize,
height: widget.logoSize,
child: Image.asset(Images.logoDark),
),
), ),
const SizedBox(height: 20), const SizedBox(height: 40),
// Text message
if (widget.message != null) // Modern Loading Indicator
Text( _buildProgressIndicator(),
widget.message!, ],
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 30),
// Animated loading dots
_buildAnimatedDots(),
],
),
), ),
), ),
); );

View File

@ -19,7 +19,8 @@ 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 {
const DailyProgressReportScreen({super.key}); final String projectId;
const DailyProgressReportScreen({super.key, required this.projectId});
@override @override
State<DailyProgressReportScreen> createState() => State<DailyProgressReportScreen> createState() =>
@ -62,21 +63,15 @@ 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);
} }
// Update when project changes // Removed the ever<ProjectController> block to keep it independent
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
@ -88,129 +83,72 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: PreferredSize( body: Stack(
preferredSize: const Size.fromHeight(72), children: [
child: AppBar( SafeArea(
backgroundColor: const Color(0xFFF5F5F5), child: MyRefreshIndicator(
elevation: 0.5, onRefresh: _refreshData,
automaticallyImplyLeading: false, child: CustomScrollView(
titleSpacing: 0, controller: _scrollController,
title: Padding( physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.xy(16, 0), slivers: [
child: Row( SliverToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.center, child: GetBuilder<DailyTaskController>(
children: [ init: dailyTaskController,
IconButton( tag: 'daily_progress_report_controller',
icon: const Icon(Icons.arrow_back_ios_new, builder: (controller) {
color: Colors.black, size: 20), return Column(
onPressed: () => Get.offNamed('/dashboard'), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
MySpacing.width(8), MySpacing.height(flexSpacing),
Expanded( Padding(
child: Column( padding: MySpacing.x(15),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
MyText.titleLarge( InkWell(
'Daily Progress Report', borderRadius: BorderRadius.circular(22),
fontWeight: 700, onTap: _openFilterSheet,
color: Colors.black, child: Padding(
), padding: const EdgeInsets.symmetric(
MySpacing.height(2), horizontal: 8, vertical: 4),
GetBuilder<ProjectController>( child: Row(
builder: (projectController) { children: [
final projectName = MyText.bodySmall(
projectController.selectedProject?.name ?? "Filter",
'Select Project'; fontWeight: 600,
return Row( color: Colors.black,
children: [ ),
const Icon(Icons.work_outline, const SizedBox(width: 4),
size: 14, color: Colors.grey), const Icon(Icons.tune,
MySpacing.width(4), size: 20, color: Colors.black),
Expanded( ],
child: MyText.bodySmall( ),
projectName, ),
fontWeight: 600, ),
overflow: TextOverflow.ellipsis, ],
color: Colors.grey[700],
),
), ),
], ),
); MySpacing.height(8),
}, Padding(
), padding: MySpacing.x(8),
], child: _buildDailyProgressReportTab(),
),
],
);
},
),
), ),
), ],
], ),
), ),
), ),
), ],
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
children: [
MyText.bodySmall(
"Filter",
fontWeight: 600,
color: Colors.black,
),
const SizedBox(width: 4),
Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
),
],
),
),
MySpacing.height(8),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
),
),
), ),
); );
} }
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 ?? '');
@ -307,32 +245,27 @@ 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", "No Progress Report Found for selected filters.",
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: [
@ -351,7 +284,6 @@ 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(
@ -376,8 +308,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
), ),
), ),
// 🔽 Task List (expandable)
Obx(() { Obx(() {
if (!dailyTaskController.expandedDates if (!dailyTaskController.expandedDates
.contains(dateKey)) { .contains(dateKey)) {
@ -415,15 +345,12 @@ 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),
@ -441,8 +368,6 @@ 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,
@ -487,8 +412,6 @@ 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(),
@ -547,8 +470,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
); );
}), }),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value Obx(() => dailyTaskController.isLoadingMore.value
? const Padding( ? const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),

View File

@ -7,7 +7,6 @@ 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';
@ -17,7 +16,9 @@ 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 {
DailyTaskPlanningScreen({super.key}); final String projectId; // Optional projectId from parent
DailyTaskPlanningScreen({super.key, required this.projectId});
@override @override
State<DailyTaskPlanningScreen> createState() => State<DailyTaskPlanningScreen> createState() =>
@ -30,156 +31,88 @@ 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();
final projectId = projectController.selectedProjectId.value; // Use widget.projectId if passed; otherwise fallback to selectedProjectId
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(
appBar: PreferredSize( body: Stack(
preferredSize: const Size.fromHeight(72), children: [
child: AppBar( SafeArea(
backgroundColor: const Color(0xFFF5F5F5), child: MyRefreshIndicator(
elevation: 0.5, onRefresh: () async {
automaticallyImplyLeading: false, final projectId = widget.projectId;
titleSpacing: 0, if (projectId.isNotEmpty) {
title: Padding( try {
padding: MySpacing.xy(16, 0), await dailyTaskPlanningController.fetchTaskData(
child: Row( projectId,
crossAxisAlignment: CrossAxisAlignment.center, serviceId: serviceController.selectedService?.id,
children: [ );
IconButton( } catch (e) {
icon: const Icon(Icons.arrow_back_ios_new, debugPrint('Error refreshing task data: ${e.toString()}');
color: Colors.black, size: 20), }
onPressed: () => Get.offNamed('/dashboard'), }
), },
MySpacing.width(8), child: SingleChildScrollView(
Expanded( physics: const AlwaysScrollableScrollPhysics(),
child: Column( padding: MySpacing.x(0),
crossAxisAlignment: CrossAxisAlignment.start, child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
children: [ minHeight: MediaQuery.of(context).size.height -
MyText.titleLarge( kToolbarHeight -
'Daily Task Planning', MediaQuery.of(context).padding.top,
fontWeight: 700, ),
color: Colors.black, child: GetBuilder<DailyTaskPlanningController>(
), init: dailyTaskPlanningController,
MySpacing.height(2), tag: 'daily_task_Planning_controller',
GetBuilder<ProjectController>( builder: (controller) {
builder: (projectController) { return Column(
final projectName = crossAxisAlignment: CrossAxisAlignment.start,
projectController.selectedProject?.name ?? children: [
'Select Project'; MySpacing.height(flexSpacing),
return Row( Padding(
children: [ padding: MySpacing.x(10),
const Icon(Icons.work_outline, child: ServiceSelector(
size: 14, color: Colors.grey), controller: serviceController,
MySpacing.width(4), height: 40,
Expanded( onSelectionChanged: (service) async {
child: MyText.bodySmall( final projectId = widget.projectId;
projectName, if (projectId.isNotEmpty) {
fontWeight: 600, await dailyTaskPlanningController
overflow: TextOverflow.ellipsis, .fetchTaskData(
color: Colors.grey[700], projectId,
), serviceId: service?.id,
);
}
},
), ),
], ),
); MySpacing.height(flexSpacing),
}), Padding(
], padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
), ),
), ),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: () async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}');
}
}
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.x(0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
MediaQuery.of(context).padding.top,
),
child: GetBuilder<DailyTaskPlanningController>(
init: dailyTaskPlanningController,
tag: 'daily_task_Planning_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId:
service?.id, // <-- pass selected service
);
}
},
),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
), ),
), ),
), ),
), ],
), ),
); );
} }
@ -227,8 +160,7 @@ 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.plannedWork) > 0 || (building.completedWork) > 0)
(building.completedWork ) > 0)
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {
@ -267,16 +199,14 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
}); });
if (expanded && !buildingLoaded && !buildingLoading) { if (expanded && !buildingLoaded && !buildingLoading) {
// fetch infra details for this building lazily final projectId = widget.projectId;
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(() {}); // rebuild to reflect loaded data setMainState(() {});
} }
} }
}, },
@ -320,7 +250,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", "No Progress Report Found for this Project",
fontWeight: 600, fontWeight: 600,
), ),
) )

View File

@ -12,11 +12,11 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In Android, build-name is used as versionName while build-number used as versionCode. # In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning # Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at # Document Reference for iOS Versioning at:
# 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+16 version: 1.0.0+18
environment: environment:
sdk: ^3.5.3 sdk: ^3.5.3
@ -32,7 +32,6 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
@ -56,7 +55,7 @@ dependencies:
appflowy_board: ^0.1.2 appflowy_board: ^0.1.2
syncfusion_flutter_calendar: ^29.1.40 syncfusion_flutter_calendar: ^29.1.40
syncfusion_flutter_maps: ^29.1.40 syncfusion_flutter_maps: ^29.1.40
http: ^1.2.2 http: ^1.6.0
geolocator: ^14.0.2 geolocator: ^14.0.2
permission_handler: ^12.0.1 permission_handler: ^12.0.1
image: ^4.0.17 image: ^4.0.17
@ -84,7 +83,10 @@ dependencies:
timeago: ^3.7.1 timeago: ^3.7.1
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
gallery_saver_plus: ^3.2.9
share_plus: ^12.0.1
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@ -147,3 +149,6 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # see https://flutter.dev/to/font-from-package
dependency_overrides:
http: ^1.6.0