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 {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
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
}

View File

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

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/monthly_expence_model.dart';
import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
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 {
// =========================
// 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
// Dependencies
final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// =========================
// Expense Category Report
// =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
Rx<ExpenseTypeReportData?>(null);
final Rx<DateTime> expenseReportStartDate =
// 1. STATE VARIABLES
// =========================
// Attendance
final roleWiseData = <Map<String, dynamic>>[].obs;
final attendanceSelectedRange = '15D'.obs;
final attendanceIsChartView = true.obs;
final isAttendanceLoading = false.obs;
// Project Progress
final projectChartData = <ChartTaskData>[].obs;
final projectSelectedRange = '15D'.obs;
final projectIsChartView = true.obs;
final isProjectLoading = false.obs;
// Overview Counts
final totalProjects = 0.obs;
final ongoingProjects = 0.obs;
final isProjectsLoading = false.obs;
final totalTasks = 0.obs;
final completedTasks = 0.obs;
final isTasksLoading = false.obs;
final totalEmployees = 0.obs;
final inToday = 0.obs;
final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
// =========================
// Monthly Expense Report
// =========================
final RxBool isMonthlyExpenseLoading = false.obs;
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
final expenseReportEndDate = DateTime.now().obs;
final isMonthlyExpenseLoading = false.obs;
final monthlyExpenseList = <MonthlyExpenseData>[].obs;
final selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final selectedMonthsCount = 12.obs;
final RxInt selectedMonthsCount = 12.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
final expenseTypes = <ExpenseTypeModel>[].obs;
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
// Teams/Employees
final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs;
// Debug print to verify
print('Selected: ${type?.name ?? "All Types"}');
// Collection
final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
if (type == null) {
fetchMonthlyExpenses();
} else {
fetchMonthlyExpenses(categoryId: type.id);
}
// =========================
// 2. COMPUTED PROPERTIES
// =========================
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
void onInit() {
super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info);
logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info,
);
fetchAllDashboardData();
// React to project change
// Project Selection Listener
ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) {
fetchAllDashboardData();
fetchTodaysAttendance(id);
}
});
// Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
}
});
// React to range changes
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress());
}
// =========================
// Helper Methods
// 4. USER ACTIONS
// =========================
int _getDaysFromRange(String range) {
switch (range) {
case '7D':
return 7;
case '15D':
return 15;
case '30D':
return 30;
case '3M':
return 90;
case '6M':
return 180;
default:
return 7;
}
}
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
void updateAttendanceRange(String range) {
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) {
void updateProjectRange(String range) => projectSelectedRange.value = range;
void toggleAttendanceChartView(bool isChart) =>
attendanceIsChartView.value = isChart;
logSafe('Attendance chart view toggled to: $isChart',
level: LogLevel.debug);
}
void toggleProjectChartView(bool isChart) {
void toggleProjectChartView(bool isChart) =>
projectIsChartView.value = isChart;
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
fetchMonthlyExpenses(categoryId: type?.id);
}
// =========================
// Manual Refresh Methods
// =========================
Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData();
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> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshDashboard() => fetchAllDashboardData();
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
final id = projectController.selectedProjectId.value;
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// 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;
}
}
// =========================
// Fetch All Dashboard Data
// =========================
Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
return;
}
if (projectId.isEmpty) return;
await Future.wait([
fetchRoleWiseAttendance(),
@ -204,248 +214,150 @@ class DashboardController extends GetxController {
endDate: expenseReportEndDate.value,
),
fetchMonthlyExpenses(),
fetchMasterData()
fetchMasterData(),
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]);
}
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
Future<void> fetchCollectionOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
// Set months count based on selection
switch (duration) {
case MonthlyExpenseDuration.oneMonth:
selectedMonthsCount.value = 1;
break;
case MonthlyExpenseDuration.threeMonths:
selectedMonthsCount.value = 3;
break;
case MonthlyExpenseDuration.sixMonths:
selectedMonthsCount.value = 6;
break;
case MonthlyExpenseDuration.twelveMonths:
selectedMonthsCount.value = 12;
break;
case MonthlyExpenseDuration.all:
selectedMonthsCount.value = 0; // 0 = All months in your API
break;
await _executeApiCall(isCollectionOverviewLoading, () async {
final response =
await ApiService.getCollectionOverview(projectId: projectId);
collectionOverviewData.value =
(response?.success == true) ? response!.data : null;
});
}
// Re-fetch updated data
fetchMonthlyExpenses();
Future<void> fetchTodaysAttendance(String projectId) async {
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 {
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
final data = await ApiService.getMasterExpenseTypes();
if (data is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (e) {
logSafe('Error fetching master data', level: LogLevel.error, error: e);
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (_) {}
}
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
try {
isMonthlyExpenseLoading.value = true;
int months = selectedMonthsCount.value;
logSafe(
'Fetching Monthly Expense Report for last $months months'
'${categoryId != null ? ' (categoryId: $categoryId)' : ''}',
level: LogLevel.info,
);
await _executeApiCall(isMonthlyExpenseLoading, () async {
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId,
months: months,
months: selectedMonthsCount.value,
);
monthlyExpenseList.value =
(response?.success == true) ? response!.data : [];
});
}
if (response != null && response.success) {
monthlyExpenseList.value = response.data;
logSafe('Monthly Expense Report fetched successfully.',
level: LogLevel.info);
} else {
monthlyExpenseList.clear();
logSafe('Failed to fetch Monthly Expense Report.',
level: LogLevel.error);
}
} catch (e, st) {
monthlyExpenseList.clear();
logSafe('Error fetching Monthly Expense Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isMonthlyExpenseLoading.value = false;
}
Future<void> fetchPurchaseInvoiceOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isPurchaseInvoiceLoading, () async {
final response = await ApiService.getPurchaseInvoiceOverview(
projectId: projectId,
);
purchaseInvoiceOverviewData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchPendingExpenses() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
try {
isPendingExpensesLoading.value = true;
final response =
await ApiService.getPendingExpensesApi(projectId: projectId);
if (response != null && response.success) {
pendingExpensesData.value = response.data;
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
} else {
pendingExpensesData.value = null;
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
}
} catch (e, st) {
pendingExpensesData.value = null;
logSafe('Error fetching pending expenses',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isPendingExpensesLoading.value = false;
}
await _executeApiCall(isPendingExpensesLoading, () async {
final response = await ApiService.getPendingExpensesApi(projectId: id);
pendingExpensesData.value =
(response?.success == true) ? response!.data : null;
});
}
// =========================
// API Calls
// =========================
Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
try {
isAttendanceLoading.value = true;
final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
if (response != null) {
await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays());
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;
}
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
});
}
Future<void> fetchExpenseTypeReport({
required DateTime startDate,
required DateTime endDate,
}) async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isExpenseTypeReportLoading.value = true;
Future<void> fetchExpenseTypeReport(
{required DateTime startDate, required DateTime endDate}) async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi(
projectId: projectId,
projectId: id,
startDate: startDate,
endDate: endDate,
);
if (response != null && response.success) {
expenseTypeReportData.value = response.data;
logSafe('Expense Category Report fetched successfully.',
level: LogLevel.info);
} else {
expenseTypeReportData.value = null;
logSafe('Failed to fetch Expense Category Report.', level: LogLevel.error);
}
} catch (e, st) {
expenseTypeReportData.value = null;
logSafe('Error fetching Expense Category Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isExpenseTypeReportLoading.value = false;
}
expenseTypeReportData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
try {
isProjectLoading.value = true;
await _executeApiCall(isProjectLoading, () async {
final response = await ApiService.getProjectProgress(
projectId: projectId, days: getProjectDays());
if (response != null && response.success) {
projectChartData.value =
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
logSafe('Project progress data mapped for chart', level: LogLevel.info);
projectId: id, days: getProjectDays());
if (response?.success == true) {
projectChartData.value = response!.data
.map((d) => ChartTaskData.fromProjectData(d))
.toList();
} else {
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 {
if (projectId.isEmpty) return;
try {
isTasksLoading.value = true;
await _executeApiCall(isTasksLoading, () async {
final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response != null && response.success) {
totalTasks.value = response.data?.totalTasks ?? 0;
if (response?.success == true) {
totalTasks.value = response!.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0;
logSafe('Dashboard tasks fetched', level: LogLevel.info);
} else {
totalTasks.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 {
if (projectId.isEmpty) return;
try {
isTeamsLoading.value = true;
await _executeApiCall(isTeamsLoading, () async {
final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response != null && response.success) {
totalEmployees.value = response.data?.totalEmployees ?? 0;
if (response?.success == true) {
totalEmployees.value = response!.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else {
totalEmployees.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.data.data.isNotEmpty) {
documents.addAll(response.data.data);
if (response.data?.data.isNotEmpty ?? false) {
documents.addAll(response.data!.data);
pageNumber.value++;
} else {
hasMore.value = false;

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
@ -50,10 +51,22 @@ class AddExpenseController extends GetxController {
final isEditMode = 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 ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>();
// final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
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/model/finance/expense_category_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 {
// Loading States
@ -32,7 +33,7 @@ class AddPaymentRequestController extends GetxController {
// Selected Values
final selectedProject = Rx<Map<String, dynamic>?>(null);
final selectedCategory = Rx<ExpenseCategory?>(null);
final selectedPayee = ''.obs;
final selectedPayee = Rx<EmployeeModel?>(null);
final selectedCurrency = Rx<Currency?>(null);
final isAdvancePayment = false.obs;
final selectedDueDate = Rx<DateTime?>(null);
@ -184,7 +185,7 @@ class AddPaymentRequestController extends GetxController {
selectedProject.value = project;
void selectCategory(ExpenseCategory 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 addAttachment(File file) => attachments.add(file);
@ -268,7 +269,7 @@ class AddPaymentRequestController extends GetxController {
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(),
"payee": selectedPayee.value,
"payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) {
@ -337,7 +338,7 @@ class AddPaymentRequestController extends GetxController {
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(),
"payee": selectedPayee.value,
"payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) {
@ -388,7 +389,7 @@ class AddPaymentRequestController extends GetxController {
return _errorSnackbar("Please select a project");
if (selectedCategory.value == null)
return _errorSnackbar("Please select a category");
if (selectedPayee.value.isEmpty)
if (selectedPayee.value == null)
return _errorSnackbar("Please select a payee");
if (selectedCurrency.value == null)
return _errorSnackbar("Please select currency");
@ -408,7 +409,7 @@ class AddPaymentRequestController extends GetxController {
descriptionController.clear();
selectedProject.value = null;
selectedCategory.value = null;
selectedPayee.value = '';
selectedPayee.value = null;
selectedCurrency.value = null;
isAdvancePayment.value = false;
attachments.clear();

View File

@ -281,6 +281,7 @@ class PaymentRequestDetailController extends GetxController {
String? tdsPercentage,
}) async {
isLoading.value = true;
try {
final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId,
@ -295,25 +296,14 @@ class PaymentRequestDetailController extends GetxController {
);
if (success) {
showAppSnackbar(
title: 'Success',
message: 'Payment submitted successfully',
type: SnackbarType.success);
// Controller refreshes the data but does not show snackbars.
await fetchPaymentRequestDetail();
paymentRequestController.fetchPaymentRequests();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status. Please try again.',
type: SnackbarType.error);
}
return success;
} catch (e) {
showAppSnackbar(
title: 'Error',
message: 'Something went wrong: $e',
type: SnackbarType.error);
// Controller returns false on error; UI will show the snackbar.
return false;
} finally {
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;
var isLoading = true.obs;
/// NEW: reactive flag to signal permissions are loaded
var permissionsLoaded = false.obs;
@override
void onInit() {
super.onInit();
@ -52,6 +55,10 @@ class PermissionController extends GetxController {
_updateState(userData);
await _storeData();
logSafe("Data loaded and state updated successfully.");
// NEW: mark permissions as loaded
permissionsLoaded.value = true;
} catch (e, stacktrace) {
logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace);
@ -103,7 +110,7 @@ class PermissionController extends GetxController {
}
void _startAutoRefresh() {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
_refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered.");
final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) {
@ -117,8 +124,6 @@ class PermissionController extends GetxController {
bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId);
// logSafe("Checking permission $permissionId: $hasPerm",
// level: LogLevel.debug);
return hasPerm;
}

View File

@ -63,26 +63,34 @@ class AddServiceProjectJobController extends GetxController {
return;
}
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
isLoading.value = true;
final success = await ApiService.createServiceProjectJobApi(
final jobId = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(),
description: descCtrl.text.trim(),
projectId: projectId,
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!,
dueDate: dueDate.value!,
tags: enteredTags.map((tag) => {"name": tag}).toList(),
tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true})
.toList(),
);
isLoading.value = false;
if (success) {
if (jobId != null) {
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();

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: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_status_response.dart';
import 'package:on_field_work/model/service_project/job_comments.dart';
import 'dart:convert';
import 'dart:io';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
class ServiceProjectDetailsController extends GetxController {
// -------------------- Observables --------------------
@ -28,6 +30,8 @@ class ServiceProjectDetailsController extends GetxController {
var errorMessage = ''.obs;
var jobErrorMessage = ''.obs;
var jobDetailErrorMessage = ''.obs;
final ImagePicker picker = ImagePicker();
var isProcessingAttachment = false.obs;
// Pagination
var pageNumber = 1;
@ -41,7 +45,16 @@ class ServiceProjectDetailsController extends GetxController {
var isTeamLoading = false.obs;
var teamErrorMessage = ''.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 --------------------
@override
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 {
if (projectId.value.isEmpty) {
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
Future<void> updateJobAttendance({
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://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list";
@ -36,6 +36,10 @@ class ApiEndpoints {
"/Dashboard/expense/monthly";
static const String getExpenseTypeReport = "/Dashboard/expense/type";
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
static const String createProject = "/project";
@ -44,6 +48,7 @@ class ApiEndpoints {
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
@ -152,4 +157,14 @@ class ApiEndpoints {
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/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_allocation_model.dart';
import 'package:on_field_work/model/service_project/service_project_branches_model.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/model/service_project/job_comments.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
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 {
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
static Future<ServiceProjectBranchesResponse?> getServiceProjectBranchesFull({
required String projectId,
@ -595,8 +871,7 @@ class ApiService {
return null;
}
/// Create a new Service Project Job
static Future<bool> createServiceProjectJobApi({
static Future<String?> createServiceProjectJobApi({
required String title,
required String description,
required String projectId,
@ -623,32 +898,22 @@ class ApiService {
try {
final response = await _postRequest(endpoint, body);
if (response == 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}");
if (response == null) return null;
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
final jobId = json['data']?['id'];
logSafe("Service Project Job created successfully: $jobId");
return jobId;
}
return null;
} catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
return null;
}
}
@ -671,8 +936,7 @@ class ApiService {
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
'isActive': isActive.toString(),
if (isArchive)
'isArchive': 'true',
if (isArchive) 'isArchive': 'true',
};
final response = await _getRequest(endpoint, queryParams: queryParams);
@ -3209,6 +3473,30 @@ class ApiService {
res != null ? _parseResponse(res, label: 'Employees') : null);
}
static Future<List<EmployeeModel>?> getAttendanceForDashboard(
String projectId) async {
String endpoint = ApiEndpoints.getAttendanceForDashboard.replaceFirst(
':projectId',
projectId,
);
final res = await _getRequest(endpoint);
if (res == null) return null;
final data = _parseResponse(res, label: 'Dashboard Attendance');
if (data == null) return null;
// Wrap single object in a list if needed
if (data is Map<String, dynamic>) {
return [EmployeeModel.fromJson(data)];
} else if (data is List) {
return data.map((e) => EmployeeModel.fromJson(e)).toList();
}
return null;
}
static Future<List<dynamic>?> getRegularizationLogs(
String projectId, {
String? organizationId,

View File

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

View File

@ -163,6 +163,9 @@ class MenuItems {
/// Service Projects
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.

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/helpers/widgets/my_spacing.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? projectName;
final String? projectName; // If passed, show static text
final VoidCallback? onBackPressed;
final Color? backgroundColor;
const CustomAppBar({
CustomAppBar({
super.key,
required this.title,
this.projectName,
this.onBackPressed,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
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,
Size get preferredSize => const Size.fromHeight(72);
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
final ProjectController projectController = Get.find();
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
void _toggleDropdown() {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
OverlayEntry _createOverlayEntry() {
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => GestureDetector(
onTap: () {
_toggleDropdown();
},
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 20,
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),
),
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,
TextField(
decoration: InputDecoration(
hintText: "Search project...",
isDense: true,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5)),
),
MySpacing.height(2),
// PROJECT NAME ROW
GetBuilder<ProjectController>(
builder: (projectController) {
// NEW LOGIC simple and safe
final displayProjectName =
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(
displayProjectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
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
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
Widget build(BuildContext context) {
return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0),
padding: MySpacing.fromLTRB(12, 10, 12, 8),
child: Row(
children: [
Expanded(
@ -179,13 +179,6 @@ class ToggleButtonsRow extends StatelessWidget {
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
@ -286,8 +279,10 @@ class ExpenseList extends StatelessWidget {
return Center(child: MyText.bodyMedium('No expenses found.'));
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
return SafeArea(
bottom: true,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 100),
itemCount: expenseList.length,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
@ -303,9 +298,7 @@ class ExpenseList extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () async {
await Get.to(
() => ExpenseDetailScreen(expenseId: expense.id),
);
await Get.to(() => ExpenseDetailScreen(expenseId: expense.id));
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
@ -321,7 +314,8 @@ class ExpenseList extends StatelessWidget {
children: [
MyText.bodyMedium('${expense.formattedAmount}',
fontWeight: 600),
if (expense.status.name.toLowerCase() == 'draft') ...[
if (expense.status.name.toLowerCase() ==
'draft') ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () =>
@ -362,6 +356,7 @@ class ExpenseList extends StatelessWidget {
),
);
},
),
);
}
}

View File

@ -1,14 +1,27 @@
import 'dart:io';
import 'dart:ui';
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 {
final List<dynamic> imageSources;
final int initialIndex;
final List<String>? captions;
final String? title;
const ImageViewerDialog({
Key? key,
required this.imageSources,
required this.initialIndex,
this.captions,
this.title,
}) : super(key: key);
@override
@ -28,87 +41,291 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
_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
Widget build(BuildContext context) {
final double dialogHeight = MediaQuery.of(context).size.height * 0.55;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF101018), Color(0xFF050509)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 100),
// Add vertical padding to avoid overlap with header/footer
Padding(
padding:
const EdgeInsets.only(top: 72, bottom: 110), // Adjust as needed
child: PageView.builder(
controller: _controller,
itemCount: widget.imageSources.length,
onPageChanged: (index) => setState(() => currentIndex = index),
itemBuilder: (context, index) {
final item = widget.imageSources[index];
final ImageProvider provider = isFile(item)
? FileImage(item)
: CachedNetworkImageProvider(item);
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(20),
child: PhotoView(
imageProvider: provider,
backgroundDecoration:
const BoxDecoration(color: Colors.transparent),
loadingBuilder: (context, event) =>
const Center(child: CircularProgressIndicator()),
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image,
size: 64, color: Colors.white54),
),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
),
),
);
},
),
),
// 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: dialogHeight,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
color: Colors.black.withOpacity(0.4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
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(
widget.title ?? 'Preview',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
maxLines: 1,
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: [
// 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
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: widget.imageSources.length,
onPageChanged: (index) {
setState(() => currentIndex = index);
},
itemBuilder: (context, index) {
final item = widget.imageSources[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: isFile(item)
? Image.file(item, fit: BoxFit.contain)
: Image.network(
item,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
(loadingProgress.expectedTotalBytes ?? 1)
: null,
),
);
},
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image, size: 48, color: Colors.grey),
),
),
);
},
),
),
// Index Indicator
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: Text(
'${currentIndex + 1} / ${widget.imageSources.length}',
Row(
children: [
Text(
'${currentIndex + 1}/${widget.imageSources.length}',
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.w500,
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,
),
),
),
@ -116,6 +333,12 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
),
),
),
),
),
),
),
],
),
);
}
}

View File

@ -68,7 +68,7 @@ class ConfirmDialog extends StatelessWidget {
maxWidth: 480,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: _ContentView(
title: title,
message: message,
@ -161,7 +161,7 @@ class _ContentView extends StatelessWidget {
Align(
alignment: Alignment.center,
child: SizedBox(
width: double.infinity, // allow full available width
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -174,7 +174,9 @@ class _ContentView extends StatelessWidget {
textColor: colorScheme.onSurface,
isFilled: false,
isLoading: false,
onPressed: loading.value ? null : () => Navigator.pop(context, false),
onPressed: loading.value
? null
: () => Navigator.pop(context, false),
),
),
),
@ -209,8 +211,7 @@ class _ContentView extends StatelessWidget {
],
),
),
),
),
],
);
}

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/helpers/theme/app_notifier.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';
Future<void> main() async {
@ -55,38 +54,37 @@ Widget _buildErrorApp() => const MaterialApp(
),
);
class MainWrapper extends StatefulWidget {
class MainWrapper extends StatelessWidget {
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
Widget build(BuildContext context) {
final bool isOffline =
_connectivityStatus.contains(ConnectivityResult.none);
return isOffline
? const MaterialApp(
debugShowCheckedModeBanner: false, home: OfflineScreen())
: const MyApp();
// 1. Use FutureBuilder to check the current connectivity status ONCE.
return FutureBuilder<List<ConnectivityResult>>(
future: Connectivity().checkConnectivity(),
builder: (context, initialSnapshot) {
// If the initial check is still running, display a standard loading screen.
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> {
final attendanceController = Get.find<AttendanceController>();
late final String uniqueLogKey;
@override
@ -189,12 +190,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false;
if (success) {
await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
controller.update();
if (selectedProjectId.isNotEmpty) {
await attendanceController.fetchProjectData(selectedProjectId);
attendanceController.update(['attendance_dashboard_controller']);
}
}
@ -273,13 +271,9 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
? const Text(
'Loading...',
style: TextStyle(fontSize: 12, color: Colors.white),
)
: Row(
mainAxisSize: MainAxisSize.min,

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/attendance/attendance_screen_controller.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: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';
class AttendanceFilterBottomSheet extends StatefulWidget {
@ -27,21 +25,6 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
class _AttendanceFilterBottomSheetState
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({
required String currentValue,
@ -51,12 +34,8 @@ class _AttendanceFilterBottomSheetState
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
itemBuilder: (context) =>
items.map((e) => PopupMenuItem<String>(value: e, child: MyText(e))).toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@ -107,48 +86,11 @@ class _AttendanceFilterBottomSheetState
);
}
List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
List<Widget> _buildFilters() {
final List<Widget> widgets = [];
final viewOptions = [
{'label': 'Today\'s Attendance', 'value': 'todaysAttendance'},
{'label': 'Attendance Logs', 'value': 'attendanceLogs'},
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
];
final filteredOptions = viewOptions.where((item) {
return item['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
final List<Widget> widgets = [
// 🔹 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
// Organization selector
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align(
@ -165,24 +107,6 @@ class _AttendanceFilterBottomSheetState
color: Colors.grey.shade300,
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) {
return Center(
@ -200,8 +124,8 @@ class _AttendanceFilterBottomSheetState
}),
]);
// 🔹 Date Range (only for Attendance Logs)
if (tempSelectedTab == 'attendanceLogs') {
// Date range (only for Attendance Logs)
if (widget.selectedTab == 'attendanceLogs') {
widgets.addAll([
const Divider(),
Padding(
@ -211,14 +135,12 @@ class _AttendanceFilterBottomSheetState
child: MyText.titleSmall("Date Range", fontWeight: 600),
),
),
// Reusable DateRangePickerWidget
DateRangePickerWidget(
startDate: widget.controller.startDateAttendance,
endDate: widget.controller.endDateAttendance,
startLabel: "Start Date",
endLabel: "End Date",
onDateRangeSelected: (start, end) {
// Optional: trigger UI updates if needed
setState(() {});
},
),
@ -230,19 +152,23 @@ class _AttendanceFilterBottomSheetState
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
return SafeArea(
child: BaseBottomSheet(
title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: buildMainFilters(),
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/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/employees/employee_model.dart';
import 'package:on_field_work/model/employees/multiple_select_role_bottomsheet.dart';
class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation;
@ -43,14 +45,15 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final DailyTaskPlanningController controller = 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 TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId;
String? selectedRoleId;
Organization? selectedOrganization;
Service? selectedService;
@ -79,12 +82,14 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
serviceId: selectedService?.id,
organizationId: selectedOrganization?.id,
);
await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id);
await controller.fetchTaskData(
selectedProjectId,
serviceId: selectedService?.id,
);
}
@override
void dispose() {
_employeeListScrollController.dispose();
targetController.dispose();
descriptionController.dispose();
super.dispose();
@ -92,20 +97,21 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
@override
Widget build(BuildContext context) {
return Obx(() => BaseBottomSheet(
return Obx(
() => BaseBottomSheet(
title: "Assign Task",
child: _buildAssignTaskForm(),
onCancel: () => Get.back(),
onSubmit: _onAssignTaskPressed,
isSubmitting: controller.isAssigningTask.value,
));
),
);
}
Widget _buildAssignTaskForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Organization Selector
SizedBox(
height: 50,
child: OrganizationSelector(
@ -117,9 +123,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
},
),
),
MySpacing.height(12),
// Service Selector
SizedBox(
height: 50,
child: ServiceSelector(
@ -131,49 +137,75 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
},
),
),
MySpacing.height(16),
// Work Location Info
_infoRow(Icons.location_on, "Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
const Divider(),
_infoRow(
Icons.location_on,
"Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}",
),
Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Pending Task Info
_infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Role Selector
GestureDetector(
onTap: _onRoleMenuPressed,
child: Row(
children: [
child: Row(children: [
MyText.titleMedium("Select Team :", fontWeight: 600),
const SizedBox(width: 4),
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
],
),
]),
),
MySpacing.height(8),
// Employee List
Container(
constraints: const BoxConstraints(maxHeight: 180),
/// TEAM SELECT BOX
GestureDetector(
onTap: _openEmployeeSelectionSheet,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: _buildEmployeeList(),
),
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(),
MySpacing.height(8),
// Target Input
_buildTextField(
icon: Icons.track_changes,
label: "Target for Today :",
@ -182,9 +214,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validatorType: "target",
),
MySpacing.height(16),
// Description Input
_buildTextField(
icon: Icons.description,
label: "Description :",
@ -198,7 +230,8 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}
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;
showMenu(
@ -211,69 +244,18 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
),
items: [
const PopupMenuItem(value: 'all', child: Text("All Roles")),
...controller.roles.map((role) {
return PopupMenuItem(
...controller.roles.map(
(role) => PopupMenuItem(
value: role['id'].toString(),
child: Text(role['name'] ?? 'Unknown Role'),
);
}),
),
),
],
).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,
runSpacing: 4,
children: controller.selectedEmployees.map((e) {
return Obx(() {
final isSelected = controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container();
return Chip(
label: Text(e.name, style: const TextStyle(color: Colors.white)),
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();
controller.selectedEmployees.remove(e);
},
);
});
}).toList(),
);
});
@ -328,10 +304,15 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
maxLines: maxLines,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
),
validator: (value) => this.controller.formFieldValidator(value, fieldType: validatorType),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
validator: (value) => this
.controller
.formFieldValidator(value, fieldType: validatorType),
),
],
);
@ -350,32 +331,70 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
text: TextSpan(
children: [
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() {
final selectedTeam = controller.uploadingStates.entries
.where((e) => e.value.value)
.map((e) => e.key)
.toList();
final selectedTeam = controller.selectedEmployees;
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;
}
final target = double.tryParse(targetController.text.trim());
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;
}
@ -390,7 +409,11 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final description = descriptionController.text.trim();
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;
}
@ -398,7 +421,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
workItemId: widget.workItemId,
plannedTask: target.toInt(),
description: description,
taskTeam: selectedTeam,
taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs
assignmentDate: widget.assignmentDate,
organizationId: selectedOrganization?.id,
serviceId: selectedService?.id,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.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/widgets/my_spacing.dart';
@ -23,21 +24,22 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
filterData.services,
].any((list) => list.isNotEmpty);
return BaseBottomSheet(
return SafeArea(
// PREVENTS GOING UNDER NAV BUTTONS
bottom: true,
child: BaseBottomSheet(
title: "Filter Tasks",
submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () {
if (controller.selectedProjectId != null) {
controller.fetchTaskData(
controller.selectedProjectId!,
);
controller.fetchTaskData(controller.selectedProjectId!);
}
Get.back();
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 40), // EXTRA SAFETY PADDING
child: hasFilters
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -96,9 +98,11 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
),
),
),
),
);
}
// MULTI SELECT FIELD
Widget _multiSelectField({
required String label,
required List<dynamic> items,
@ -117,6 +121,7 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
.where((item) => selectedValues.contains(item.id))
.map((item) => item.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
@ -146,27 +151,23 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(item.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo;
}
return Colors.white;
},
(states) =>
states.contains(MaterialState.selected)
? Colors.indigo
: Colors.white,
),
onChanged: (val) {
if (val == true) {
selectedValues.add(item.id);
@ -212,6 +213,7 @@ class DailyTaskFilterBottomSheet extends StatelessWidget {
);
}
// DATE RANGE PICKER
Widget _dateRangeSelector(BuildContext context) {
return Column(
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,
child: TextField(
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) {
controller.addEnteredTag(v);
if (v.trim().isNotEmpty) {
controller.addEnteredTag(v.trim());
}
tagCtrl.clear();
controller.clearSuggestions();
},
decoration: _inputDecoration("Start typing to add tags"),
),
),
Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox.shrink()
: Container(
@ -353,7 +369,10 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
},
),
)),
MySpacing.height(8),
// TAG CHIPS
Obx(() => Wrap(
spacing: 8,
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/model/employees/employee_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 {
static void show(
@ -21,10 +22,8 @@ class EditBucketBottomSheet {
final nameController = TextEditingController(text: bucket.name);
final descController = TextEditingController(text: bucket.description);
final searchController = TextEditingController();
final selectedIds = RxSet<String>({...bucket.employeeIds});
final searchText = ''.obs;
InputDecoration _inputDecoration(String label) {
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() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -101,117 +109,72 @@ class EditBucketBottomSheet {
MySpacing.height(20),
MyText.labelLarge('Shared With', fontWeight: 600),
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(() {
final filtered = allEmployees.where((emp) {
final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase();
return fullName.contains(searchText.value);
}).toList();
if (selectedIds.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 180,
child: ListView.separated(
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final emp = filtered[index];
final selectedEmployees =
allEmployees.where((e) => selectedIds.contains(e.id)).toList();
return Wrap(
spacing: 8,
children: selectedEmployees.map((emp) {
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
return Obx(() => Theme(
data: Theme.of(context).copyWith(
unselectedWidgetColor: Colors.grey.shade500,
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4)),
side: const BorderSide(color: Colors.grey),
fillColor:
MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
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
return Chip(
label: Text(fullName),
onDeleted: 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,
),
));
},
),
: () => selectedIds.remove(emp.id),
);
}).toList(),
);
}),
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(
title: "Edit Bucket",
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
onSubmit: () => _handleSubmitBottomSheet(context),
child: _formContent(),
);
},

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
@ -24,33 +25,59 @@ class EmployeeSelectionBottomSheet extends StatefulWidget {
class _EmployeeSelectionBottomSheetState
extends State<EmployeeSelectionBottomSheet> {
final TextEditingController _searchController = TextEditingController();
final RxBool _isSearching = false.obs;
final RxList<EmployeeModel> _searchResults = <EmployeeModel>[].obs;
final RxList<EmployeeModel> _allResults = <EmployeeModel>[].obs;
late RxList<EmployeeModel> _selectedEmployees;
Timer? _debounce;
@override
void initState() {
super.initState();
_selectedEmployees = RxList<EmployeeModel>.from(widget.initiallySelected);
_searchEmployees('');
_performSearch('');
}
@override
void dispose() {
_debounce?.cancel();
_searchController.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;
final data = await ApiService.searchEmployeesBasic(searchString: query);
final results = (data as List)
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.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;
}
// HANDLE TAP & CHECKBOX
void _toggleEmployee(EmployeeModel emp) {
if (widget.multipleSelection) {
if (_selectedEmployees.contains(emp)) {
@ -61,9 +88,12 @@ class _EmployeeSelectionBottomSheetState
} else {
_selectedEmployees.assignAll([emp]);
}
_selectedEmployees.refresh(); // important for Obx rebuild
// Refresh list but do NOT reorder selected
_performSearch(_searchController.text.trim());
}
// SUBMIT SELECTION
void _handleSubmit() {
if (widget.multipleSelection) {
Navigator.of(context).pop(_selectedEmployees.toList());
@ -73,11 +103,12 @@ class _EmployeeSelectionBottomSheetState
}
}
// SEARCH BAR
Widget _searchBar() => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: _searchController,
onChanged: _searchEmployees,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Search employees...',
filled: true,
@ -88,7 +119,7 @@ class _EmployeeSelectionBottomSheetState
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () {
_searchController.clear();
_searchEmployees('');
_performSearch('');
},
)
: null,
@ -102,24 +133,36 @@ class _EmployeeSelectionBottomSheetState
),
);
// EMPLOYEE LIST
Widget _employeeList() => Expanded(
child: Obx(() {
if (_isSearching.value) {
return const Center(child: CircularProgressIndicator());
}
if (_searchResults.isEmpty) {
return const Center(child: Text("No employees found"));
}
final results = _allResults;
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: _searchResults.length,
itemCount: results.length,
itemBuilder: (context, index) {
final emp = _searchResults[index];
return Obx(() {
final emp = results[index];
final isSelected = _selectedEmployees.contains(emp);
Widget trailingWidget;
if (widget.multipleSelection) {
trailingWidget = Checkbox(
value: isSelected,
onChanged: (_) => _toggleEmployee(emp),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white,
),
);
} 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,
@ -131,31 +174,17 @@ class _EmployeeSelectionBottomSheetState
),
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);
},
trailing: trailingWidget,
onTap: () => _toggleEmployee(emp),
contentPadding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
);
});
},
);
}),
);
// BUILD BOTTOM SHEET
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
@ -164,10 +193,12 @@ class _EmployeeSelectionBottomSheetState
onSubmit: _handleSubmit,
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(children: [
child: Column(
children: [
_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: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/model/expense/expense_type_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/validators.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_confirmation_dialog.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>({
bool isEdit = false,
Map<String, dynamic>? existingExpense,
@ -27,7 +30,6 @@ Future<T?> showAddExpenseBottomSheet<T>({
);
}
/// Bottom sheet widget
class _AddExpenseBottomSheet extends StatefulWidget {
final bool isEdit;
final Map<String, dynamic>? existingExpense;
@ -50,47 +52,55 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey();
/// Show employee list
Future<void> _showEmployeeList() async {
await showModalBottomSheet(
final result = await showModalBottomSheet<dynamic>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
builder: (_) => EmployeeSelectionBottomSheet(
initiallySelected: controller.selectedPaidBy.value != null
? [controller.selectedPaidBy.value!]
: [],
multipleSelection: false,
title: "Select Paid By",
),
);
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
if (result == null) return;
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>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final RenderBox button =
final RenderBox btn =
triggerKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
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>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
pos.dx,
pos.dy + btn.size.height,
overlay.size.width - pos.dx - btn.size.width,
0,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
@ -105,7 +115,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
if (selected != null) onSelected(selected);
}
/// Validate required selections
bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) {
_showError("Please select a project");
@ -141,9 +150,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
return Obx(
() => Form(
key: _formKey,
child: SafeArea(
bottom: true,
child: BaseBottomSheet(
title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value,
@ -156,6 +169,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
}
},
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: bottomInset + 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -175,7 +189,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
dropdownKey: _projectDropdownKey,
),
_gap(),
_buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Category",
@ -190,9 +203,8 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
),
dropdownKey: _expenseTypeDropdownKey,
),
// Persons if required
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
if (controller
.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildTextFieldSection(
@ -205,7 +217,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
),
],
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
@ -213,7 +224,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
@ -229,10 +239,8 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
_buildPaidBySection(),
_gap(),
_buildTextFieldSection(
icon: Icons.currency_rupee,
title: "Amount",
@ -244,7 +252,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
: "Enter valid amount",
),
_gap(),
_buildTextFieldSection(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
@ -253,7 +260,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
validator: Validators.nameValidator,
),
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
@ -264,16 +270,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
: null,
),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
@ -287,6 +289,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
),
),
),
),
);
}
@ -349,11 +352,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
Expanded(
child: Text(
controller.selectedPaidBy.value?.name ?? "Select Paid By",
style: const TextStyle(fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
@ -399,7 +403,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
hintText: "Enter Location",
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
suffixIcon: controller.isFetchingLocation.value
@ -413,7 +419,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
)
: IconButton(
icon: const Icon(Icons.my_location),
tooltip: "Use Current Location",
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/expense/expense_form_widgets.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>({
bool isEdit = false,
@ -58,12 +60,10 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (widget.isEdit && widget.existingData != null) {
final data = widget.existingData!;
// Prefill text fields
controller.titleController.text = data["title"] ?? "";
controller.amountController.text = data["amount"]?.toString() ?? "";
controller.descriptionController.text = data["description"] ?? "";
// Prefill due date
if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) {
DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString());
if (dueDate != null) {
@ -73,15 +73,15 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
}
}
// Prefill dropdowns & toggles
controller.selectedProject.value = {
'id': data["projectId"],
'name': data["projectName"],
};
controller.selectedPayee.value = data["payee"] ?? "";
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
// Categories & currencies
// When categories and currencies load, set selected ones
everAll([controller.categories, controller.currencies], (_) {
controller.selectedCategory.value = controller.categories
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
@ -89,7 +89,6 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
.firstWhereOrNull((c) => c.id == data["currencyId"]);
});
// Attachments
final attachmentsData = data["attachments"];
if (attachmentsData != null &&
attachmentsData is List &&
@ -116,21 +115,21 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
@override
Widget build(BuildContext context) {
return Obx(() => Form(
return Obx(
() => SafeArea(
child: Form(
key: _formKey,
child: BaseBottomSheet(
title: widget.isEdit
? "Edit Payment Request"
: "Create Payment Request",
title: widget.isEdit ? "Edit Payment Request" : "Create Payment Request",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
submitText: "Save as Draft",
onSubmit: () async {
if (_formKey.currentState!.validate() && _validateSelections()) {
bool success = false;
if (widget.isEdit && widget.existingData != null) {
final requestId =
widget.existingData!['id']?.toString() ?? '';
final requestId = widget.existingData!['id']?.toString() ?? '';
if (requestId.isNotEmpty) {
success = await controller.submitEditedPaymentRequest(
requestId: requestId);
@ -144,7 +143,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (success) {
Get.back();
if (widget.onUpdated != null) widget.onUpdated!();
widget.onUpdated?.call();
showAppSnackbar(
title: "Success",
@ -157,31 +156,33 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDropdown(
"Select Project",
Icons.work_outline,
controller.selectedProject.value?['name'] ??
"Select Project",
controller.selectedProject.value?['name'] ?? "Select Project",
controller.globalProjects,
(p) => p['name'],
controller.selectProject,
key: _projectDropdownKey),
key: _projectDropdownKey,
),
_gap(),
_buildDropdown(
"Expense Category",
Icons.category_outlined,
controller.selectedCategory.value?.name ??
"Select Category",
controller.selectedCategory.value?.name ?? "Select Category",
controller.categories,
(c) => c.name,
controller.selectCategory,
key: _categoryDropdownKey),
key: _categoryDropdownKey,
),
_gap(),
_buildTextField(
"Title", Icons.title_outlined, controller.titleController,
_buildTextField("Title", Icons.title_outlined,
controller.titleController,
hint: "Enter title", validator: Validators.requiredField),
_gap(),
_buildRadio("Is Advance Payment", Icons.attach_money_outlined,
@ -199,17 +200,17 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
? null
: "Enter valid amount"),
_gap(),
_buildPayeeAutocompleteField(),
_buildPayeeField(),
_gap(),
_buildDropdown(
"Currency",
Icons.monetization_on_outlined,
controller.selectedCurrency.value?.currencyName ??
"Select Currency",
controller.selectedCurrency.value?.currencyName ?? "Select Currency",
controller.currencies,
(c) => c.currencyName,
controller.selectCurrency,
key: _currencyDropdownKey),
key: _currencyDropdownKey,
),
_gap(),
_buildTextField("Description", Icons.description_outlined,
controller.descriptionController,
@ -218,11 +219,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
validator: Validators.requiredField),
_gap(),
_buildAttachmentsSection(),
MySpacing.height(30),
],
),
),
),
));
),
),
);
}
Widget _buildDropdown<T>(String title, IconData icon, String value,
@ -236,7 +240,8 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
DropdownTile(
key: key,
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(
String title, IconData icon, RxBool controller, List<String> labels) {
String title, IconData icon, RxBool controllerBool, List<String> labels) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -284,15 +289,16 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
final i = entry.key;
final label = entry.value;
final value = i == 0;
return Expanded(
child: RadioListTile<bool>(
contentPadding: EdgeInsets.zero,
title: Text(label),
value: value,
groupValue: controller.value,
groupValue: controllerBool.value,
activeColor: contentTheme.primary,
onChanged: (val) =>
val != null ? controller.value = val : null,
val != null ? controllerBool.value = val : null,
),
);
}).toList(),
@ -306,9 +312,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.calendar_today,
title: "Due To Date",
requiredField: true),
icon: Icons.calendar_today, title: "Due To Date", requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: () => controller.pickDueDate(context),
@ -336,75 +340,35 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
);
}
Widget _buildPayeeAutocompleteField() {
Widget _buildPayeeField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: Icons.person_outline, title: "Payee", requiredField: true),
const SectionTitle(
icon: Icons.person_outline,
title: "Payee",
requiredField: true,
),
MySpacing.height(6),
GestureDetector(
onTap: _showPayeeSelector,
child: TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Obx(() => Text(
controller.selectedPayee.value?.name ?? "Select Payee",
style: const TextStyle(fontSize: 15),
overflow: TextOverflow.ellipsis,
)),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
const SizedBox(height: 6),
Autocomplete<String>(
optionsBuilder: (textEditingValue) {
final query = textEditingValue.text.toLowerCase();
return query.isEmpty
? const Iterable<String>.empty()
: controller.payees
.where((p) => p.toLowerCase().contains(query));
},
displayStringForOption: (option) => option,
fieldViewBuilder:
(context, fieldController, focusNode, onFieldSubmitted) {
// Avoid updating during build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (fieldController.text != controller.selectedPayee.value) {
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),
),
),
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)),
),
),
),
),
),
),
],
);
}
@ -492,8 +456,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
return;
}
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox button = key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
@ -507,8 +470,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.map((opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(),
);
@ -523,7 +485,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (controller.selectedCategory.value == null) {
return _showError("Please select a category");
}
if (controller.selectedPayee.value.isEmpty) {
if (controller.selectedPayee.value == null) {
return _showError("Please select a payee");
}
if (controller.selectedCurrency.value == null) {
@ -532,6 +494,25 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
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) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
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/widgets/date_range_picker.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 {
final PaymentRequestController controller;
@ -27,11 +27,9 @@ class PaymentRequestFilterBottomSheet extends StatefulWidget {
class _PaymentRequestFilterBottomSheetState
extends State<PaymentRequestFilterBottomSheet> with UIMixin {
// ---------------- Date Range ----------------
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
// ---------------- Selected Filters (store IDs internally) ----------------
final RxString selectedProjectId = ''.obs;
final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].obs;
@ -39,7 +37,6 @@ class _PaymentRequestFilterBottomSheetState
final RxString selectedCurrencyId = ''.obs;
final RxString selectedStatusId = ''.obs;
// Computed display names
String get selectedProjectName =>
widget.controller.projects
.firstWhereOrNull((e) => e.id == selectedProjectId.value)
@ -64,10 +61,8 @@ class _PaymentRequestFilterBottomSheetState
?.name ??
'Please select...';
// ---------------- Filter Data ----------------
final RxBool isFilterLoading = true.obs;
// Individual RxLists for safe Obx usage
final RxList<String> projectNames = <String>[].obs;
final RxList<String> submittedByNames = <String>[].obs;
final RxList<String> payeeNames = <String>[].obs;
@ -92,17 +87,14 @@ class _PaymentRequestFilterBottomSheetState
currencyNames.assignAll(widget.controller.currencies.map((e) => e.name));
statusNames.assignAll(widget.controller.statuses.map((e) => e.name));
// 🔹 Prefill existing applied filter (if any)
final existing = widget.controller.appliedFilter;
if (existing.isNotEmpty) {
// Project
if (existing['projectIds'] != null &&
(existing['projectIds'] as List).isNotEmpty) {
selectedProjectId.value = (existing['projectIds'] as List).first;
}
// Submitted By
if (existing['createdByIds'] != null &&
existing['createdByIds'] is List) {
selectedSubmittedBy.assignAll(
@ -114,7 +106,6 @@ class _PaymentRequestFilterBottomSheetState
);
}
// Payees
if (existing['payees'] != null && existing['payees'] is List) {
selectedPayees.assignAll(
(existing['payees'] as List)
@ -125,26 +116,22 @@ class _PaymentRequestFilterBottomSheetState
);
}
// Category
if (existing['expenseCategoryIds'] != null &&
(existing['expenseCategoryIds'] as List).isNotEmpty) {
selectedCategoryId.value =
(existing['expenseCategoryIds'] as List).first;
}
// Currency
if (existing['currencyIds'] != null &&
(existing['currencyIds'] as List).isNotEmpty) {
selectedCurrencyId.value = (existing['currencyIds'] as List).first;
}
// Status
if (existing['statusIds'] != null &&
(existing['statusIds'] as List).isNotEmpty) {
selectedStatusId.value = (existing['statusIds'] as List).first;
}
// Dates
if (existing['startDate'] != null && existing['endDate'] != null) {
startDate.value = DateTime.tryParse(existing['startDate']);
endDate.value = DateTime.tryParse(existing['endDate']);
@ -192,8 +179,14 @@ class _PaymentRequestFilterBottomSheetState
submitText: 'Apply',
submitColor: contentTheme.primary,
submitIcon: Icons.check_circle_outline,
/// IMPORTANT FIX
/// Prevents bottom part from hiding under 3-button nav bar in landscape
child: SafeArea(
minimum: const EdgeInsets.only(bottom: 20),
child: SingleChildScrollView(
controller: widget.scrollController,
padding: const EdgeInsets.only(bottom: 40), // extra bottom spacing
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -227,6 +220,7 @@ class _PaymentRequestFilterBottomSheetState
],
),
),
),
);
}
@ -441,9 +435,9 @@ class _PaymentRequestFilterBottomSheetState
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: (query) => searchEmployees(query, items),
builder: (context) => EmployeeSelectionBottomSheet(
initiallySelected: selectedEmployees.toList(),
multipleSelection: true,
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(() {
if (controller.isBranchLoading.value) {
return const Center(child: CircularProgressIndicator());
@ -197,6 +198,8 @@ class _AddServiceProjectJobBottomSheetState
],
);
// ----------------- UPDATED TAG INPUT -----------------
Widget _tagInput() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -204,14 +207,65 @@ class _AddServiceProjectJobBottomSheetState
height: 48,
child: TextFormField(
controller: controller.tagCtrl,
onFieldSubmitted: (v) {
final value = v.trim();
if (value.isNotEmpty &&
!controller.enteredTags.contains(value)) {
controller.enteredTags.add(value);
textInputAction: TextInputAction.done,
// 🚀 Auto-create tag when space pressed
onChanged: (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();
},
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"),
validator: (v) => controller.enteredTags.isEmpty
? "Please add at least one tag"
@ -231,6 +285,8 @@ class _AddServiceProjectJobBottomSheetState
],
);
// ------------------------------------------------------
void _handleSubmit() {
if (!(formKey.currentState?.validate() ?? false)) return;
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? nextStatus;
final String? comment;
final String? updatedAt;
final User? updatedBy;
UpdateLog({
@ -284,6 +285,7 @@ class UpdateLog {
this.status,
this.nextStatus,
this.comment,
this.updatedAt,
this.updatedBy,
});
@ -297,6 +299,7 @@ class UpdateLog {
? Status.fromJson(json['nextStatus'])
: null,
comment: json['comment'] as String?,
updatedAt: json['updatedAt'] as String?,
updatedBy:
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/dashboard/dashboard_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/auth/login_option_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/payment_request_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 {
@override
RouteSettings? redirect(String? route) {
@ -70,15 +70,6 @@ getPageRoute() {
name: '/dashboard/employees',
page: () => EmployeesScreen(),
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(
name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(),
@ -102,6 +93,12 @@ getPageRoute() {
name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]),
// Infrastructure Projects
GetPage(
name: '/dashboard/infra-projects',
page: () => InfraProjectScreen(),
middlewares: [AuthMiddleware()]),
// Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()),
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/helpers/utils/date_time_utils.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_spacing.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/helpers/utils/attendance_actions.dart';
class AttendanceLogsTab extends StatefulWidget {
class AttendanceLogsTab extends StatelessWidget {
final AttendanceController controller;
const AttendanceLogsTab({super.key, required this.controller});
@override
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
}
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
Widget _buildStatusHeader() {
return Obx(() {
if (!widget.controller.showPendingOnly.value) {
return const SizedBox.shrink();
}
if (!controller.showPendingOnly.value) return const SizedBox.shrink();
return Container(
width: double.infinity,
@ -46,7 +38,7 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
),
),
InkWell(
onTap: () => widget.controller.showPendingOnly.value = false,
onTap: () => controller.showPendingOnly.value = false,
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) {
final text = AttendanceButtonHelper.getButtonText(
activity: employee.activity,
@ -77,32 +68,20 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
final isCheckoutAction =
text.contains("checkout") || text.contains("check out");
int priority;
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) {
priority = 0;
} else if (isCheckoutAction) {
priority = 0;
} else if (text.contains("regular")) {
priority = 1;
} else if (text == "requested") {
priority = 2;
} else if (text == "approved") {
priority = 3;
} else if (text == "rejected") {
priority = 4;
} else {
priority = 5;
}
return priority;
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) return 0;
if (isCheckoutAction) return 0;
if (text.contains("regular")) return 1;
if (text == "requested") return 2;
if (text == "approved") return 3;
if (text == "rejected") return 4;
return 5;
}
@override
Widget build(BuildContext context) {
return Obx(() {
final allLogs = List.of(widget.controller.filteredLogs);
// Filter logs if "pending only"
final showPendingOnly = widget.controller.showPendingOnly.value;
final allLogs = List.of(controller.filteredLogs);
final showPendingOnly = controller.showPendingOnly.value;
final filteredLogs = showPendingOnly
? allLogs.where((emp) => emp.activity == 1).toList()
: allLogs;
@ -116,7 +95,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
groupedLogs.putIfAbsent(dateKey, () => []).add(log);
}
// Sort dates (latest first)
final sortedDates = groupedLogs.keys.toList()
..sort((a, b) {
final da = DateTimeUtils.parseDate(a, 'dd MMM yyyy') ?? DateTime(0);
@ -125,20 +103,19 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
});
final dateRangeText =
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
'${DateTimeUtils.formatDate(controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(controller.endDateAttendance.value, 'dd MMM yyyy')}';
// Sticky header + scrollable list
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
// Header Row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium("Attendance Logs", fontWeight: 600),
widget.controller.isLoading.value
controller.isLoadingAttendanceLogs.value
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
@ -152,50 +129,53 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
// Pending-only header
_buildStatusHeader(),
MySpacing.height(8),
// Content: loader, empty, or logs
if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
// Divider between header and list
const Divider(height: 1),
// Scrollable attendance logs
Expanded(
child: controller.isLoadingAttendanceLogs.value
? SkeletonLoaders.employeeListSkeletonLoader()
: filteredLogs.isEmpty
? 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
for (final emp in (groupedLogs[date]!
..sort(
(a, b) {
: 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);
final bTime = b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(
aTime);
},
))) ...[
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(date, fontWeight: 700),
),
...employees.map(
(emp) => Column(
children: [
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Avatar(
firstName: emp.firstName,
@ -205,7 +185,8 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
@ -213,7 +194,8 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
overflow: TextOverflow
.ellipsis,
),
),
MySpacing.width(6),
@ -222,7 +204,8 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
overflow: TextOverflow
.ellipsis,
),
),
],
@ -232,24 +215,37 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
emp.checkOut != null)
Row(
children: [
if (emp.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
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'),
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),
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'),
DateTimeUtils
.formatDate(
emp.checkOut!,
'hh:mm a'),
fontWeight: 600,
),
],
@ -257,16 +253,19 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisAlignment:
MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController: widget.controller,
attendanceController:
controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController: widget.controller,
attendanceController:
controller,
),
],
),
@ -276,10 +275,12 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
],
),
),
Divider(color: Colors.grey.withOpacity(0.3)),
],
],
),
),
],
);
},
),
),
],

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.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/widgets/my_flex.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/controller/attendance/attendance_screen_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/view/Attendence/regularization_requests_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/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 {
const AttendanceScreen({super.key});
@ -22,45 +24,84 @@ class AttendanceScreen extends StatefulWidget {
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 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
void initState() {
super.initState();
ever(permissionController.permissionsLoaded, (loaded) {
if (loaded == true && !_tabsInitialized) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// 🔁 Listen for project changes
_initializeTabs();
setState(() {});
});
}
});
// Watch project changes to reload data
ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId);
if (projectId.isNotEmpty && _tabsInitialized) {
await _fetchTabData(attendanceController.selectedTab);
}
});
// 🚀 Load initial data only once the screen is shown
// If permissions are already loaded at init
if (permissionController.permissionsLoaded.value) {
_initializeTabs();
}
}
void _initializeTabs() async {
final allTabs = [
{'label': "Today's", 'value': 'todaysAttendance'},
{'label': "Logs", 'value': 'attendanceLogs'},
{'label': "Regularization", 'value': 'regularizationRequests'},
];
final hasRegularizationPermission =
permissionController.hasPermission(Permissions.regularizeAttendance);
_tabs = allTabs.where((tab) {
return tab['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
_tabController = TabController(length: _tabs.length, vsync: this);
// Keep selectedTab in sync and fetch data on tab change
_tabController.addListener(() async {
if (!_tabController.indexIsChanging) {
final selectedTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
}
});
_tabsInitialized = true;
// Load initial data for default tab
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId);
});
}
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error loading data: $e");
if (projectId.isNotEmpty) {
final initialTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = initialTab;
await _fetchTabData(initialTab);
}
}
Future<void> _refreshData() async {
Future<void> _fetchTabData(String tab) async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
// Call only the relevant API for current tab
switch (selectedTab) {
switch (tab) {
case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId);
break;
@ -77,59 +118,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}
}
Widget _buildAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Attendance',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
);
Future<void> _refreshData() async {
await _fetchTabData(attendanceController.selectedTab);
}
Widget _buildFilterSearchRow() {
@ -165,11 +155,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
@ -177,17 +167,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}),
),
),
MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(5),
),
child: IconButton(
padding: EdgeInsets.zero,
@ -200,19 +187,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(12)),
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController,
permissionController: permissionController,
selectedTab: selectedTab,
selectedTab: _tabs[_tabController.index]['value']!,
),
);
if (result != null) {
final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
final selectedOrgId =
result['selectedOrganization'] as String?;
@ -223,111 +209,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
}
if (selectedProjectId.isNotEmpty) {
try {
await attendanceController.fetchTodaysAttendance(
selectedProjectId,
);
await attendanceController.fetchAttendanceLogs(
selectedProjectId,
);
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
attendanceController.selectedTab = selectedView;
if (selectedProjectId.isNotEmpty) {
await attendanceController
.fetchProjectData(selectedProjectId);
}
await _fetchTabData(attendanceController.selectedTab);
}
}
},
),
),
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,8 +233,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
);
}
Widget _buildSelectedTabContent() {
switch (selectedTab) {
Widget _buildTabBarView() {
return TabBarView(
controller: _tabController,
children: _tabs.map((tab) {
switch (tab['value']) {
case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests':
@ -356,21 +246,53 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}).toList(),
);
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
if (!_tabsInitialized) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
appBar: CustomAppBar(
title: "Attendance",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: SafeArea(
body: const Center(child: CircularProgressIndicator()),
);
}
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 selectedProjectId =
projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator(
@ -379,9 +301,22 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e['label']!).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
onTap: (index) async {
final selectedTab = _tabs[index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
},
),
),
_buildFilterSearchRow(),
MyFlex(
children: [
@ -389,7 +324,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected
? _buildNoProjectWidget()
: _buildSelectedTabContent(),
: SizedBox(
height:
MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
),
],
),
@ -400,12 +340,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
},
),
),
],
),
);
}
@override
void dispose() {
// 🧹 Clean up the controller when user leaves this screen
_tabController.dispose();
if (Get.isRegistered<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:get/get.dart';
import 'package:intl/intl.dart';
@ -19,17 +18,11 @@ class RegularizationRequestsTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
return Obx(() {
final isLoading = controller.isLoadingRegularizationLogs.value;
final employees = controller.filteredRegularizationLogs;
if (controller.isLoadingRegularizationLogs.value) {
if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
@ -37,18 +30,22 @@ class RegularizationRequestsTab extends StatelessWidget {
return const SizedBox(
height: 120,
child: Center(
child:
Text("No Regularization Requests Found for this Project"),
child: Text("No Regularization Requests Found for this Project"),
),
);
}
return MyCard.bordered(
return ListView.builder(
itemCount: employees.length,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
final employee = employees[index]; // Corrected index
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 8,
@ -58,7 +55,7 @@ class RegularizationRequestsTab extends StatelessWidget {
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31,
size: 35,
),
MySpacing.width(16),
Expanded(
@ -144,15 +141,13 @@ class RegularizationRequestsTab extends StatelessWidget {
],
),
),
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/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_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
@ -22,17 +21,28 @@ class TodaysAttendanceTab extends StatelessWidget {
final isLoading = controller.isLoadingEmployees.value;
final employees = controller.filteredEmployees;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (employees.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text("No Employees Assigned"),
),
);
}
return ListView.builder(
itemCount: employees.length + 1,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 12, top: 4),
child: Row(
children: [
Expanded(
child:
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
@ -40,75 +50,68 @@ class TodaysAttendanceTab extends StatelessWidget {
),
],
),
),
if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
else if (employees.isEmpty)
const SizedBox(
height: 120,
child: Center(child: Text("No Employees Assigned")))
else
MyCard.bordered(
paddingAll: 8,
);
}
final employee = employees[index - 1];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: MyCard.bordered(
paddingAll: 10,
child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 5,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- 1. Employee Info Row (Avatar, Name, Designation ONLY) ---
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Avatar
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31),
MySpacing.width(16),
size: 30,
),
MySpacing.width(10),
// Employee Details (Expanded to use remaining space)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Wrap(
spacing: 6,
children: [
MyText.bodyMedium(employee.name,
fontWeight: 600),
MyText.titleSmall(employee.name,
fontWeight: 600, overflow: TextOverflow.ellipsis),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.grey[600],
overflow: TextOverflow.ellipsis,
),
],
),
),
// Status Text (Added back for context)
if (employee.checkIn == null)
MyText.bodySmall(
'(${employee.designation})',
'Check In Pending',
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')),
],
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: [
@ -128,17 +131,8 @@ class TodaysAttendanceTab extends StatelessWidget {
],
),
),
],
),
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}),
),
),
],
},
);
});
}

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

View File

@ -1,22 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/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/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_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 {
const DashboardScreen({super.key});
@ -28,6 +31,8 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController =
Get.put(DashboardController(), permanent: true);
final AttendanceController attendanceController =
Get.put(AttendanceController());
final DynamicMenuController menuController = Get.put(DynamicMenuController());
final ProjectController projectController = Get.find<ProjectController>();
@ -41,83 +46,212 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
Future<void> _checkMpinStatus() async {
hasMpin = await LocalStorage.getIsMpin();
if (mounted) setState(() {});
if (mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Layout(
child: SingleChildScrollView(
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Widget _cardWrapper({required Widget child}) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: child,
);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
);
}
// ---------------------------------------------------------------------------
// Quick Actions
// ---------------------------------------------------------------------------
Widget _quickActions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Quick Action'),
Obx(() {
if (dashboardController.isLoadingEmployees.value) {
// Show loading skeleton
return SkeletonLoaders.attendanceQuickCardSkeleton();
}
final employees = dashboardController.employees;
final employee = employees.isNotEmpty ? employees.first : null;
if (employee == null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3),
contentTheme.primary.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Text(
'No attendance data available',
style: TextStyle(color: Colors.white),
),
);
}
// Actual employee quick action card
final bool isCheckedIn = employee.checkIn != null;
final bool isCheckedOut = employee.checkOut != null;
final String statusText = !isCheckedIn
? 'Check In Pending'
: isCheckedIn && !isCheckedOut
? 'Checked In'
: 'Checked Out';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3),
contentTheme.primary.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDashboardCards(),
MySpacing.height(24),
_buildAttendanceChartSection(),
MySpacing.height(24),
_buildProjectProgressChartSection(),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
Row(
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 30,
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.tasksOverview(),
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,
),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
),
),
);
}
/// ---------------- Dynamic Dashboard Cards ----------------
Widget _buildDashboardCards() {
return Obx(() {
if (menuController.isLoading.value) {
return SkeletonLoaders.dashboardCardsSkeleton();
}
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),
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,
),
],
],
),
],
),
);
}),
],
);
}
final projectSelected = projectController.selectedProject != null;
// ---------------------------------------------------------------------------
// Dashboard Modules
// ---------------------------------------------------------------------------
// Define dashboard card meta with order
Widget _dashboardModules() {
return Obx(() {
if (menuController.isLoading.value) {
return SkeletonLoaders.dashboardCardsSkeleton(
maxWidth: MediaQuery.of(context).size.width,
);
}
final bool projectSelected = projectController.selectedProject != null;
// these are String constants from permission_constants.dart
final List<String> cardOrder = [
MenuItems.attendance,
MenuItems.employees,
MenuItems.dailyTaskPlanning,
MenuItems.dailyProgressReport,
MenuItems.directory,
MenuItems.finance,
MenuItems.documents,
MenuItems.serviceProjects
MenuItems.serviceProjects,
MenuItems.infraProjects,
];
final Map<String, _DashboardCardMeta> cardMeta = {
final Map<String, _DashboardCardMeta> meta = {
MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees:
_DashboardCardMeta(LucideIcons.users, contentTheme.warning),
MenuItems.dailyTaskPlanning:
_DashboardCardMeta(LucideIcons.logs, contentTheme.info),
MenuItems.dailyProgressReport:
_DashboardCardMeta(LucideIcons.list_todo, contentTheme.info),
MenuItems.directory:
_DashboardCardMeta(LucideIcons.folder, contentTheme.info),
MenuItems.finance:
@ -126,180 +260,328 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
_DashboardCardMeta(LucideIcons.file_text, contentTheme.info),
MenuItems.serviceProjects:
_DashboardCardMeta(LucideIcons.package, contentTheme.info),
MenuItems.infraProjects:
_DashboardCardMeta(LucideIcons.building_2, contentTheme.primary),
};
// Filter only available menus that exist in cardMeta
final allowedMenusMap = {
for (var menu in menuController.menuItems)
if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu
final Map<String, dynamic> allowed = {
for (final m in menuController.menuItems)
if (m.available && meta.containsKey(m.id)) m.id: m,
};
if (allowedMenusMap.isEmpty) {
return const Center(
child: Text(
"No accessible modules found.",
style: TextStyle(color: Colors.grey),
),
);
}
final List<String> filtered =
cardOrder.where((id) => allowed.containsKey(id)).toList();
// Create list of cards in fixed order
final stats =
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) {
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
return Wrap(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.map((stat) =>
_buildDashboardCard(stat, projectSelected, cardWidth))
.toList(),
);
});
});
}
Widget _buildDashboardCard(
_DashboardStatItem stat, bool isProjectSelected, double width) {
final isEnabled = stat.title == "Attendance" ? true : isProjectSelected;
return Opacity(
opacity: isEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isEnabled,
child: InkWell(
onTap: () => _onDashboardCardTap(stat, isEnabled),
borderRadius: BorderRadius.circular(5),
child: MyCard.bordered(
width: width,
height: 60,
paddingAll: 4,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
return Column(
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.all(4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: stat.color.withOpacity(0.1),
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
stat.icon,
size: 16,
color: stat.color,
child: const Text(
'Select Project',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
MySpacing.height(4),
Flexible(
child: Text(
stat.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
),
),
],
),
),
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]!;
void _onDashboardCardTap(_DashboardStatItem statItem, bool isEnabled) {
final bool isEnabled =
item.name == 'Attendance' ? true : projectSelected;
return GestureDetector(
onTap: () {
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"),
),
Get.snackbar(
'Required',
'Please select a project first',
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
backgroundColor: Colors.black87,
colorText: Colors.white,
duration: const Duration(seconds: 2),
);
} else {
Get.toNamed(statItem.route);
Get.toNamed(item.mobileLink);
}
}
/// ---------------- 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."),
},
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,
),
),
],
),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
data: dashboardController.projectChartData,
),
},
),
],
);
});
}
/// ---------------- Attendance Chart ----------------
Widget _buildAttendanceChartSection() {
// ---------------------------------------------------------------------------
// Project Selector
// ---------------------------------------------------------------------------
Widget _projectSelector() {
return Obx(() {
final attendanceMenu = menuController.menuItems
.firstWhereOrNull((m) => m.id == MenuItems.attendance);
if (attendanceMenu == null || !attendanceMenu.available)
return const SizedBox.shrink();
final bool isLoading = projectController.isLoading.value;
final bool expanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects;
final String? selectedId = projectController.selectedProjectId.value;
final isProjectSelected = projectController.selectedProject != null;
if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton(
maxWidth: MediaQuery.of(context).size.width,
);
}
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Project'),
GestureDetector(
onTap: () => projectController.isProjectSelectionExpanded.toggle(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
border: Border.all(color: Colors.black12.withOpacity(.15)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
const Icon(
Icons.work_outline,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
Icon(
expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 26,
color: Colors.black54,
),
],
),
),
),
if (expanded) _projectDropdownList(projects, selectedId),
],
);
});
}
Widget _projectDropdownList(List projects, String? selectedId) {
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
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),
],
),
),
),
);
});
}
}
/// ---------------- 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 {
final IconData icon;
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_confirmation_dialog.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 {
final ContactModel contact;
@ -23,18 +24,21 @@ class ContactDetailScreen extends StatefulWidget {
}
class _ContactDetailScreenState extends State<ContactDetailScreen>
with UIMixin {
with SingleTickerProviderStateMixin, UIMixin {
late final DirectoryController directoryController;
late final ProjectController projectController;
late Rx<ContactModel> contactRx;
late TabController _tabController;
@override
void initState() {
super.initState();
directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>();
projectController = Get.put(ProjectController());
contactRx = widget.contact.obs;
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await directoryController.fetchCommentsForContact(contactRx.value.id,
active: true);
@ -49,61 +53,54 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildMainAppBar(),
// AppBar is outside SafeArea (correct)
appBar: CustomAppBar(
title: 'Contact Profile',
backgroundColor: appBarColor,
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
),
// Only the content is wrapped inside SafeArea
body: SafeArea(
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(),
]),
),
// ************ GRADIENT + SUBHEADER + TABBAR ************
Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
contentTheme.primary,
contentTheme.primary.withOpacity(0),
],
),
),
child: Obx(() => _buildSubHeader(contactRx.value)),
),
);
}
PreferredSizeWidget _buildMainAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.2,
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.offAllNamed('/dashboard/directory-main-page'),
),
MySpacing.width(8),
// ************ TAB CONTENT ************
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
child: TabBarView(
controller: _tabController,
children: [
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (p) {
return ProjectLabel(p.selectedProject?.name);
}),
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
],
),
),
@ -118,7 +115,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
final lastName =
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -137,20 +137,53 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
],
),
]),
TabBar(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: contentTheme.primary,
MySpacing.height(12),
// === MODERN PILL-SHAPED TABBAR ===
Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(24),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding:
const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
labelColor: primaryColor,
unselectedLabelColor: Colors.grey.shade600,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
],
dividerColor: Colors.transparent,
),
),
],
),
);
}
// --- DETAILS TAB ---
Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds
@ -228,7 +261,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
_iconInfoRow(Icons.location_on, "Address", contact.address),
]),
_infoCard("Organization", [
_iconInfoRow(Icons.business, "Organization", contact.organization),
_iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category),
]),
_infoCard("Meta Info", [
@ -281,6 +315,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
);
}
// --- COMMENTS TAB ---
Widget _buildCommentsTab() {
return Obx(() {
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/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/my_text.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.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/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class DirectoryMainScreen extends StatefulWidget {
const DirectoryMainScreen({super.key});
@ -18,7 +19,7 @@ class DirectoryMainScreen extends StatefulWidget {
}
class _DirectoryMainScreenState extends State<DirectoryMainScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, UIMixin {
late TabController _tabController;
final DirectoryController controller = Get.put(DirectoryController());
@ -38,85 +39,46 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Directory",
onBackPressed: () => Get.offNamed('/dashboard'),
backgroundColor: appBarColor,
),
body: Stack(
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(
children: [
// ---------------- TabBar ----------------
// === TOP GRADIENT ===
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Directory"),
Tab(text: "Notes"),
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// ---------------- TabBarView ----------------
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Directory", "Notes"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
// === TABBAR VIEW ===
Expanded(
child: TabBarView(
controller: _tabController,
@ -128,6 +90,9 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
),
],
),
),
],
),
);
}
}

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/controller/permission_controller.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 {
final String documentId;
@ -23,7 +24,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
final DocumentDetailsController controller =
Get.find<DocumentDetailsController>();
@ -49,15 +50,37 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: 'Document Details',
backgroundColor: appBarColor,
onBackPressed: () {
Get.back();
},
),
body: Obx(() {
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: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
@ -84,8 +107,11 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
MyText.titleMedium(
"Versions",
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 10),
_buildVersionsSection(),
],
@ -93,6 +119,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
),
);
}),
),
],
),
);
}

View File

@ -115,7 +115,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
void dispose() {
_scrollController.dispose();
_fabAnimationController.dispose();
docController.searchController.dispose();
docController.documents.clear();
super.dispose();
}
@ -137,7 +136,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
],
),
child: TextField(
controller: docController.searchController,
controller: docController.searchController, // keep GetX controller
onChanged: (value) {
docController.searchQuery.value = value;
docController.fetchDocuments(
@ -428,14 +427,21 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
}
Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) {
final uploadDate =
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty
? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
final uploadDate = doc.uploadedAt != null
? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal())
: '-';
final uploadTime = doc.uploadedAt != null
? DateFormat("hh:mm a").format(doc.uploadedAt!.toLocal())
: '';
final uploader =
(doc.uploadedBy != null && doc.uploadedBy!.firstName.isNotEmpty)
? "${doc.uploadedBy!.firstName} ${doc.uploadedBy!.lastName ?? ''}"
.trim()
: "You";
final iconColor = _getDocumentTypeColor(doc.documentType.name);
final iconColor =
_getDocumentTypeColor(doc.documentType?.name ?? 'unknown');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -479,11 +485,10 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getDocumentIcon(doc.documentType.name),
_getDocumentIcon(doc.documentType?.name ?? 'unknown'),
color: iconColor,
size: 24,
),
),
)),
const SizedBox(width: 14),
Expanded(
child: Column(
@ -497,7 +502,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(6),
),
child: MyText.labelSmall(
doc.documentType.name,
doc.documentType?.name ?? 'Unknown',
fontWeight: 600,
color: iconColor,
letterSpacing: 0.3,
@ -799,38 +804,42 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
}
Widget _buildBody() {
return Obx(() {
// Check permissions
if (permissionController.permissions.isEmpty) {
return _buildLoadingIndicator();
}
// Non-reactive widgets
final searchBar = _buildSearchBar();
final filterChips = _buildFilterChips();
final statusBanner = _buildStatusBanner();
return Column(
children: [
searchBar,
filterChips,
statusBanner,
// Only the list is reactive
Expanded(
child: Obx(() {
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return _buildPermissionDenied();
}
// Show skeleton loader
if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: SkeletonLoaders.documentSkeletonLoader(),
);
}
final docs = docController.documents;
return Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildStatusBanner(),
Expanded(
child: MyRefreshIndicator(
// Skeleton loader
if (docController.isLoading.value && docs.isEmpty) {
return SkeletonLoaders.documentSkeletonLoader();
}
// Empty state
if (!docController.isLoading.value && docs.isEmpty) {
return _buildEmptyState();
}
// List of documents
return MyRefreshIndicator(
onRefresh: () async {
final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds':
docController.selectedCategory.toList(),
'documentCategoryIds': docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
};
@ -842,17 +851,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
reset: true,
);
},
child: docs.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _buildEmptyState(),
),
],
)
: ListView.builder(
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100, top: 8),
@ -863,8 +862,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
if (docController.isLoading.value) {
return _buildLoadingIndicator();
}
if (!docController.hasMore.value &&
docs.isNotEmpty) {
if (!docController.hasMore.value && docs.isNotEmpty) {
return _buildNoMoreIndicator();
}
return const SizedBox.shrink();
@ -872,23 +870,26 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
}
final doc = docs[index];
final currentDate = DateFormat("dd MMM yyyy")
.format(doc.uploadedAt.toLocal());
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())
.format(docs[index - 1].uploadedAt!.toLocal())
: '')
: null;
final showDateHeader = currentDate != prevDate;
return _buildDocumentCard(doc, showDateHeader);
},
),
),
);
}),
),
],
);
});
}
}
Widget _buildFAB() {
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/document/user_document_screen.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 {
final String employeeId;
@ -14,12 +15,15 @@ class EmployeeProfilePage extends StatefulWidget {
}
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;
@override
void initState() {
super.initState();
// Initialize TabController with 2 tabs
_tabController = TabController(length: 2, vsync: this);
}
@ -29,43 +33,103 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
super.dispose();
}
// --- No need for _buildSegmentedButton function anymore ---
@override
Widget build(BuildContext context) {
// Accessing theme colors for consistency
final Color appBarColor = contentTheme.primary;
final Color primaryColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Employee Profile",
onBackPressed: () => Get.back(),
backgroundColor: appBarColor,
),
body: Column(
body: Stack(
children: [
// ---------------- TabBar outside AppBar ----------------
// === Gradient at the top behind AppBar + Toggle ===
// This container ensures the background color transitions nicely
Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// === Main Content Area ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
// 🛑 NEW: The Modern TabBar Implementation 🛑
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
height: 48, // Define a specific height for the TabBar container
decoration: BoxDecoration(
color: Colors.white,
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,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
// 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,
),
// Tabs (No custom widget needed, just use the built-in Tab)
tabs: const [
Tab(text: "Details"),
Tab(text: "Documents"),
],
// Setting this to zero removes the default underline
dividerColor: Colors.transparent,
),
),
),
// ---------------- TabBarView ----------------
// 🛑 TabBarView (The Content) 🛑
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Details Tab
EmployeeDetailPage(
employeeId: widget.employeeId,
fromProfile: true,
),
// Documents Tab
UserDocumentsPage(
entityId: widget.employeeId,
isEmployee: true,
@ -75,6 +139,9 @@ 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/view/employees/employee_profile_screen.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 {
const EmployeesScreen({super.key});
@ -113,11 +114,36 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
floatingActionButton: _buildFloatingActionButton(),
body: SafeArea(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Employees",
backgroundColor: appBarColor,
projectName: Get.find<ProjectController>().selectedProject?.name ??
'Select Project',
onBackPressed: () => Get.offNamed('/dashboard'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
@ -148,63 +174,9 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
},
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Employees',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
floatingActionButton: _buildFloatingActionButton(),
);
}

View File

@ -330,14 +330,11 @@ class _ManageReportingBottomSheetState
final EmployeesScreenController controller = Get.find();
await controller.fetchReportingManagers(empId);
await controller.fetchEmployeeDetails(empId);
} catch (_) {
}
} catch (_) {}
// Optional: re-fetch the organization hierarchy list (if needed elsewhere)
await ApiService.getOrganizationHierarchyList(employeeId);
_resetForm();
if (Navigator.of(context).canPop()) {
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) {
// Inline card for profile screen
return Card(
@ -397,7 +405,7 @@ class _ManageReportingBottomSheetState
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12),
child: content,
child: safeWrappedContent,
),
);
}
@ -409,7 +417,7 @@ class _ManageReportingBottomSheetState
isSubmitting: _isSubmitting,
onCancel: _handleCancel,
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:url_launcher/url_launcher.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/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
@ -82,13 +82,38 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
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),
],
),
),
),
// Main content
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."));
@ -98,8 +123,10 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
final statusColor = getExpenseStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator(
@ -108,7 +135,8 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
},
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
@ -122,21 +150,21 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------------- Header & Status ----------------
// Header & Status
_InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Activity Logs ----------------
// Activity Logs
InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2),
// ---------------- Amount & Summary ----------------
// Amount & Summary
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Amount',
fontWeight: 600),
MyText.bodyMedium('Amount', fontWeight: 600),
const SizedBox(height: 4),
MyText.bodyLarge(
formattedAmount,
@ -146,7 +174,6 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
],
),
const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved)
Container(
padding: const EdgeInsets.symmetric(
@ -165,19 +192,19 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
),
const Divider(height: 30, thickness: 1.2),
// ---------------- Parties ----------------
// Parties
_InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Expense Details ----------------
// Expense Details
_InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// ---------------- Documents ----------------
// Documents
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
// ---------------- Totals ----------------
// Totals
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
@ -189,15 +216,18 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
),
),
),
));
),
);
}),
),
],
),
floatingActionButton: 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."));
return const SizedBox.shrink();
}
if (!_checkedPermission) {
@ -237,10 +267,8 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
})
.toList(),
};
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
@ -279,22 +307,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
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
if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
@ -304,7 +317,8 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
);
}),
);
}
}
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
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 {
final ExpenseDetailModel 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:get/get.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_style.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/widgets/date_range_picker.dart';
import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart';
class ExpenseFilterBottomSheet extends StatefulWidget {
final ExpenseController expenseController;
@ -303,12 +302,13 @@ class _ExpenseFilterBottomSheetState extends State<ExpenseFilterBottomSheet>
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
builder: (context) => EmployeeSelectionBottomSheet(
initiallySelected: selectedEmployees.toList(),
multipleSelection: true,
title: title,
),
);
if (result != null) selectedEmployees.assignAll(result);
},
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/widgets/my_refresh_indicator.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 {
const ExpenseMainScreen({super.key});
@ -87,36 +89,64 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController),
body: Column(
appBar: CustomAppBar(
title: "Expense & Reimbursement",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'),
),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
// ---------------- TabBar ----------------
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
// ---------------- Gray background for rest ----------------
Expanded(
child: Container(
color: Colors.grey[100],
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
// ---------------- Search ----------------
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: [
// SEARCH & FILTER
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
padding: const EdgeInsets.symmetric(horizontal: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
@ -125,7 +155,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
),
),
// ---------------- TabBarView ----------------
// TABBAR VIEW
Expanded(
child: TabBarView(
controller: _tabController,
@ -141,11 +171,12 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
),
],
),
),
],
),
floatingActionButton: Obx(() {
// Show loader or hide FAB while permissions are loading
if (permissionController.permissions.isEmpty) {
if (permissionController.permissions.isEmpty)
return const SizedBox.shrink();
}
final canUpload =
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/project_controller.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:intl/intl.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class AdvancePaymentScreen extends StatefulWidget {
const AdvancePaymentScreen({super.key});
@ -49,12 +48,37 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(
0xFFF5F5F5),
appBar: _buildAppBar(),
body: GestureDetector(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Advance Payments",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// ===== MAIN CONTENT =====
SafeArea(
top: false,
bottom: true,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: RefreshIndicator(
onRefresh: () async {
@ -64,127 +88,66 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
}
},
color: Colors.white,
backgroundColor: contentTheme.primary,
backgroundColor: appBarColor,
strokeWidth: 2.5,
displacement: 60,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
color:
const Color(0xFFF5F5F5),
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20,
),
child: Column(
children: [
_buildSearchBar(),
_buildEmployeeDropdown(context),
_buildTopBalance(),
_buildPaymentList(),
],
),
),
),
),
),
);
}
// ---------------- AppBar ----------------
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
// ---------------- Search ----------------
Widget _buildSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
// ===== SEARCH BAR FLOATING OVER GRADIENT =====
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: SizedBox(
height: 38,
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) => controller.searchQuery.value = v.trim(),
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),
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),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
borderSide: BorderSide(
color: appBarColor, width: 1.5),
),
),
),
),
),
// ===== EMPLOYEE DROPDOWN =====
_buildEmployeeDropdown(context),
// ===== TOP BALANCE =====
_buildTopBalance(),
// ===== PAYMENTS LIST =====
_buildPaymentList(),
],
),
),
),
),
@ -322,7 +285,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
);
}
// No employee selected yet
if (controller.selectedEmployee.value == null) {
return const Padding(
padding: EdgeInsets.only(top: 100),
@ -330,7 +292,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
);
}
// Employee selected but no payments found
if (controller.payments.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 100),
@ -340,7 +301,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
);
}
// Payments available
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -364,9 +324,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? '';
final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble();
@ -378,7 +335,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(
bottom: BorderSide(color: Color(0xFFE0E0E0), width: 0.9),
bottom: BorderSide(color: const Color(0xFFE0E0E0), width: 0.9),
),
),
child: Row(
@ -396,16 +353,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
style:
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),

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/widgets/my_card.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_by_status_widget.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/helpers/utils/permission_constants.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 {
const FinanceScreen({super.key});
@ -52,75 +53,63 @@ class _FinanceScreenState extends State<FinanceScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
appBar: CustomAppBar(
title: "Finance",
onBackPressed: () => Get.offAllNamed( '/dashboard' ),
backgroundColor: appBarColor,
),
body: Stack(
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(
'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],
),
),
],
);
},
),
// Top fade under AppBar
Container(
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Bottom fade (above system buttons or FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
body: FadeTransition(
// 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) {
if (menuController.hasError.value ||
menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
@ -129,7 +118,6 @@ class _FinanceScreenState extends State<FinanceScreen>
);
}
// Filter allowed Finance menus dynamically
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
@ -149,8 +137,16 @@ class _FinanceScreenState extends State<FinanceScreen>
);
}
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset + 24,
),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
@ -165,16 +161,22 @@ class _FinanceScreenState extends State<FinanceScreen>
);
}),
),
),
],
),
);
}
// --- Finance Modules (Compact Dashboard-style) ---
Widget _buildFinanceModulesCompact(List<MenuItem> financeMenus) {
Widget _buildFinanceModulesCompact(List<MenuItem> financeMenus) {
// Map menu IDs to icon + color
final Map<String, _FinanceCardMeta> financeCardMeta = {
MenuItems.expenseReimbursement: _FinanceCardMeta(LucideIcons.badge_dollar_sign, contentTheme.info),
MenuItems.paymentRequests: _FinanceCardMeta(LucideIcons.receipt_text, contentTheme.primary),
MenuItems.advancePaymentStatements: _FinanceCardMeta(LucideIcons.wallet, contentTheme.warning),
MenuItems.expenseReimbursement:
_FinanceCardMeta(LucideIcons.badge_dollar_sign, contentTheme.info),
MenuItems.paymentRequests:
_FinanceCardMeta(LucideIcons.receipt_text, contentTheme.primary),
MenuItems.advancePaymentStatements:
_FinanceCardMeta(LucideIcons.wallet, contentTheme.warning),
};
// Build the stat items using API-provided mobileLink
@ -198,20 +200,22 @@ Widget _buildFinanceModulesCompact(List<MenuItem> financeMenus) {
return LayoutBuilder(builder: (context, constraints) {
// Determine number of columns dynamically
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(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.end,
children: stats
.map((stat) => _buildFinanceModuleCard(stat, projectSelected, cardWidth))
.map((stat) =>
_buildFinanceModuleCard(stat, projectSelected, cardWidth))
.toList(),
);
});
}
}
Widget _buildFinanceModuleCard(
Widget _buildFinanceModuleCard(
_FinanceStatItem stat, bool isProjectSelected, double width) {
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4, // Dim if no project selected
@ -260,9 +264,9 @@ Widget _buildFinanceModuleCard(
),
),
);
}
}
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
@ -276,8 +280,8 @@ void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
// Navigate to the card's specific route
Get.toNamed(statItem.route);
}
}
}
}
class _FinanceStatItem {
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/make_expense_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 {
final String paymentRequestId;
@ -107,10 +108,33 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: SafeArea(
appBar: CustomAppBar(
title: "Payment Request Details",
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// ===== MAIN CONTENT =====
SafeArea(
child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
@ -178,6 +202,8 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
);
}),
),
],
),
bottomNavigationBar: _buildBottomActionBar(),
);
}
@ -191,11 +217,9 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return const SizedBox.shrink();
}
if (!_checkedPermission) {
if (!_checkedPermission && request != null && employeeInfo != null) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(request);
});
}
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
@ -269,15 +293,20 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
comment: comment.trim(),
);
if (!success) {
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? 'Status updated successfully'
: 'Failed to update status',
type: success ? SnackbarType.success : SnackbarType.error,
title: 'Error',
message: 'Failed to update status',
type: SnackbarType.error,
);
return;
}
if (success) await controller.fetchPaymentRequestDetail();
showAppSnackbar(
title: 'Success',
message: 'Status updated successfully',
type: SnackbarType.success,
);
}
},
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 {

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/helpers/utils/permission_constants.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 {
const PaymentRequestMainScreen({super.key});
@ -96,27 +98,59 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: Column(
appBar: CustomAppBar(
title: "Payment Requests",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
// === MAIN CONTENT ===
SafeArea(
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.grey[100],
color: Colors.transparent,
child: Column(
children: [
_buildSearchBar(),
@ -135,6 +169,9 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
),
],
),
),
],
),
floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink();
@ -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() {
return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0),
@ -294,7 +270,6 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
final list = filteredList(isHistory: isHistory);
// ScrollController for infinite scroll
final scrollController = ScrollController();
scrollController.addListener(() {
if (scrollController.position.pixels >=
@ -309,6 +284,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
child: list.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
@ -325,7 +301,12 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
)
: ListView.separated(
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,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
@ -365,10 +346,6 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
Row(
children: [
MyText.bodyMedium(item.expenseCategory.name, fontWeight: 600),
// -------------------------------
// ADV CHIP (only if advance)
// -------------------------------
if (item.isAdvancePayment == true) ...[
const SizedBox(width: 8),
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/helpers/services/api_endpoints.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/helpers/services/tenant_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget {
final Widget? child;
@ -20,11 +21,10 @@ class Layout extends StatefulWidget {
State<Layout> createState() => _LayoutState();
}
class _LayoutState extends State<Layout> {
class _LayoutState extends State<Layout> with UIMixin {
final LayoutController controller = LayoutController();
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
final projectController = Get.find<ProjectController>();
bool hasMpin = true;
@ -58,83 +58,77 @@ class _LayoutState extends State<Layout> {
}
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
final primaryColor = contentTheme.primary;
return Scaffold(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: SafeArea(
body: Column(
children: [
// Solid primary background area
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (projectController.isProjectSelectionExpanded.value) {
projectController.isProjectSelectionExpanded.value = false;
}
},
child: Stack(
children: [
Column(
children: [
_buildHeader(context, isMobile),
Expanded(
onTap: () {},
child: SingleChildScrollView(
key: controller.scrollKey,
padding: EdgeInsets.symmetric(
horizontal: 0, vertical: isMobile ? 16 : 32),
padding: EdgeInsets.zero,
child: widget.child,
),
),
),
),
),
],
),
_buildProjectDropdown(context, isMobile),
],
),
),
),
);
));
}
/// Header Section
Widget _buildHeader(BuildContext context, bool isMobile) {
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
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),
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),
),
],
),
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(
// Logo section
Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
@ -143,7 +137,9 @@ class _LayoutState extends State<Layout> {
width: 50,
fit: BoxFit.contain,
),
if (isBetaEnvironment)
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned(
bottom: 0,
left: 0,
@ -152,17 +148,8 @@ class _LayoutState extends State<Layout> {
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),
)
],
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white, width: 1.2),
),
child: const Text(
'B',
@ -176,92 +163,41 @@ class _LayoutState extends State<Layout> {
),
],
),
),
const SizedBox(width: 12),
// Titles
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",
"Dashboard",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: Colors.black87,
),
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 ?? ''}",
if (selectedTenant != null)
MyText.bodySmall(
"Organization: ${selectedTenant.name}",
color: Colors.black54,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu button with red dot if MPIN missing
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu),
onPressed: () => controller.scaffoldKey.currentState
?.openEndDrawer(),
icon: const Icon(Icons.menu, color: Colors.black87),
onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
@ -273,159 +209,15 @@ class _LayoutState extends State<Layout> {
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border:
Border.all(color: Colors.white, width: 2),
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),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 18,
width: 140,
color: Colors.grey.shade300,
),
const SizedBox(height: 6),
Container(
height: 14,
width: 100,
color: Colors.grey.shade200,
),
],
),
),
const SizedBox(width: 10),
Container(
height: 30,
width: 30,
color: Colors.grey.shade300,
),
],
),
),
);
}
/// 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;
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;

View File

@ -14,7 +14,9 @@ import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/routes.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
final bool isOffline;
const MyApp({super.key, required this.isOffline});
Future<String> _getInitialRoute() async {
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
Widget build(BuildContext context) {
return Consumer<AppNotifier>(
@ -71,9 +129,18 @@ class MyApp extends StatelessWidget {
getPages: getPageRoute(),
builder: (context, child) {
NavigationService.registerContext(context);
return Directionality(
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
// This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
);
},
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/view/service_project/jobs_tab.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 {
final String projectId;
@ -429,6 +430,8 @@ class _ServiceProjectDetailsScreenState
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
@ -436,28 +439,38 @@ class _ServiceProjectDetailsScreenState
projectName: widget.projectName,
onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
),
body: SafeArea(
child: Column(
body: Stack(
children: [
// TabBar
// === TOP FADE BELOW APPBAR ===
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
indicatorWeight: 3,
isScrollable: false,
tabs: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// TabBarView
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Profile", "Jobs", "Teams"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary.withOpacity(0.1),
height: 48,
),
// === TABBAR VIEW ===
Expanded(
child: Obx(() {
if (controller.isLoading.value &&
@ -467,7 +480,8 @@ class _ServiceProjectDetailsScreenState
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
child:
MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
@ -486,6 +500,8 @@ class _ServiceProjectDetailsScreenState
],
),
),
],
),
floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended(
onPressed: () {

View File

@ -18,6 +18,8 @@ import 'dart:io';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:url_launcher/url_launcher.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 {
final String jobId;
@ -47,10 +49,15 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
@override
void initState() {
super.initState();
controller = Get.put(ServiceProjectDetailsController());
controller.fetchJobDetail(widget.jobId).then((_) {
controller = Get.find<ServiceProjectDetailsController>();
// Fetch job detail first
controller.fetchJobDetail(widget.jobId).then((_) async {
final job = controller.jobDetail.value?.data;
if (job != null) {
// Populate form fields
_selectedTags.value =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_titleController.text = job.title ?? '';
_descriptionController.text = job.description ?? '';
_startDateController.text = DateTimeUtils.convertUtcToLocal(
@ -60,7 +67,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
job.dueDate ?? '',
format: "yyyy-MM-dd");
_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();
}
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 {
_processTagsInput(); // process any new tag input
final job = controller.jobDetail.value?.data;
if (job == null) return;
final List<Map<String, dynamic>> operations = [];
// 1 Title
final trimmedTitle = _titleController.text.trim();
if (trimmedTitle != job.title) {
operations
.add({"op": "replace", "path": "/title", "value": trimmedTitle});
}
// 2 Description
final trimmedDescription = _descriptionController.text.trim();
if (trimmedDescription != job.description) {
operations.add({
@ -96,6 +130,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
});
}
// 3 Start & Due Date
final startDate = DateTime.tryParse(_startDateController.text);
final dueDate = DateTime.tryParse(_dueDateController.text);
@ -115,41 +150,57 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
});
}
final originalAssignees = job.assignees;
final assigneesPayload = originalAssignees?.map((a) {
// 4 Assignees
final originalAssignees = job.assignees ?? [];
final assigneesPayload = originalAssignees.map((a) {
final isSelected = _selectedAssignees.any((s) => s.id == a.id);
return {"employeeId": a.id, "isActive": isSelected};
}).toList();
for (var s in _selectedAssignees) {
if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) {
assigneesPayload?.add({"employeeId": s.id, "isActive": true});
if (!originalAssignees.any((a) => a.id == s.id)) {
assigneesPayload.add({"employeeId": s.id, "isActive": true});
}
}
operations.add(
{"op": "replace", "path": "/assignees", "value": assigneesPayload});
final originalTags = job.tags;
final replaceTagsPayload = originalTags?.map((t) {
final isSelected = _selectedTags.any((s) => s.id == t.id);
return {"id": t.id, "name": t.name, "isActive": isSelected};
}).toList();
// 5 Tags
final originalTags = job.tags ?? [];
final currentTags = _selectedTags.toList();
if (_tagsAreDifferent(originalTags, currentTags)) {
final List<Map<String, dynamic>> finalTagsPayload = [];
final addTagsPayload = _selectedTags
.where((t) => t.id == "0")
.map((t) => {"name": t.name, "isActive": true})
.toList();
for (var ot in originalTags) {
final isSelected = currentTags.any((ct) =>
(ct.id != null && ct.id == ot.id) ||
(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
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload});
.add({"op": "replace", "path": "/tags", "value": finalTagsPayload});
}
if (addTagsPayload.isNotEmpty) {
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload});
// 6 Job Status
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) {
showAppSnackbar(
title: "Info",
@ -158,6 +209,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
return;
}
// 8 Call API
final success = await ApiService.editServiceProjectJobApi(
jobId: job.id ?? "",
operations: operations,
@ -168,7 +220,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
title: "Success",
message: "Job updated successfully",
type: SnackbarType.success);
// Re-fetch job detail & update tags locally
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;
} else {
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 {
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -402,10 +487,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
border: Border.all(color: Colors.grey.shade400),
),
alignment: Alignment.centerLeft,
child: Text(
"Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
child: Text("Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700])),
),
),
],
@ -416,19 +499,24 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Widget _tagEditor() {
return Obx(() {
final editing = isEditing.value;
final tags = _selectedTags;
final job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: tags
children: displayTags
.map(
(t) => Chip(
label: Text(t.name ?? ''),
onDeleted: editing
? () {
_selectedTags.remove(t);
_selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
}
: null,
),
@ -439,17 +527,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (editing)
TextField(
controller: _tagTextController,
onSubmitted: (v) {
final value = v.trim();
if (value.isNotEmpty && !tags.any((t) => t.name == value)) {
_selectedTags.add(Tag(id: "0", name: value));
onChanged: (value) {
// If space or comma typed process tags immediately
if (value.endsWith(" ") || value.contains(",")) {
_processTagsInput();
}
_tagTextController.clear();
},
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
},
decoration: InputDecoration(
hintText: "Type and press enter to add tags",
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
hintText: "Type tags (space or comma to add multiple tags)",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
@ -492,8 +584,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600],
),
color: Colors.grey[600]),
onPressed: () async {
isAttendanceExpanded.value =
!isAttendanceExpanded.value;
@ -520,22 +611,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
height: 16,
width: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
color: Colors.white, strokeWidth: 2))
: Icon(action == 0 ? Icons.login : Icons.logout),
label: MyText.bodyMedium(
action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600,
color: Colors.white,
),
color: Colors.white),
onPressed: isLoading ? null : _handleTagAction,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
borderRadius: BorderRadius.circular(5)),
backgroundColor:
action == 0 ? Colors.green : Colors.red,
),
@ -557,10 +643,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (logs.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium(
"No attendance logs available",
color: Colors.grey[600],
),
child: MyText.bodyMedium("No attendance logs available",
color: Colors.grey[600]),
);
}
@ -595,25 +679,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Row(
children: [
Icon(
log.action == 0 ? Icons.login : Icons.logout,
log.action == 0
? Icons.login
: Icons.logout,
color: log.action == 0
? Colors.green
: Colors.red,
size: 18,
),
size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
employeeName,
child: Text(employeeName,
style: const TextStyle(
fontWeight: FontWeight.w600),
),
),
Text(
"$date | $time",
fontWeight: FontWeight.w600))),
Text("$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
fontSize: 12, color: Colors.grey[700])),
],
),
const SizedBox(height: 4),
@ -622,11 +702,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (log.comment?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
),
child: Text(log.comment!,
style: const TextStyle(fontSize: 13))),
// Location
if (log.latitude != null && log.longitude != null)
@ -651,14 +728,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Icon(Icons.location_on,
size: 14, color: Colors.blue),
SizedBox(width: 4),
Text(
"View Location",
Text("View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration:
TextDecoration.underline),
),
TextDecoration.underline)),
],
),
),
@ -677,12 +752,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.broken_image,
const Icon(Icons.broken_image,
size: 50,
color: Colors.grey,
),
),
color: Colors.grey)),
),
),
child: ClipRRect(
@ -697,8 +769,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 40,
color: Colors.grey,
),
color: Colors.grey),
),
),
),
@ -724,12 +795,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Expanded(
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700),
),
Expanded(
flex: 5,
child: MyText.bodyMedium(value, fontWeight: 500),
),
fontWeight: 600, color: Colors.grey.shade700)),
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
Widget build(BuildContext context) {
final projectName = widget.projectName;
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Job Details Screen",
onBackPressed: () => Get.back(),
projectName: projectName,
backgroundColor: appBarColor,
),
floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed:
isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: contentTheme.primary,
backgroundColor: appBarColor,
label: MyText.bodyMedium(
isEditing.value ? "Save" : "Edit",
color: Colors.white,
@ -772,14 +963,50 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
),
icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)),
body: Obx(() {
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Bottom fade (for smooth transition above FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60, // adjust based on FAB height
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
// Main scrollable content
Obx(() {
if (controller.isJobDetailLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobDetailErrorMessage.value));
child: MyText.bodyMedium(
controller.jobDetailErrorMessage.value));
}
final job = controller.jobDetail.value?.data;
@ -792,6 +1019,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildJobStatusCard(),
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
@ -819,16 +1047,31 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
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),
],
),
);
}),
],
),
);
}
}
@ -868,9 +1111,8 @@ class JobTimeline extends StatelessWidget {
width: 16,
height: 16,
indicator: DecoratedBox(
decoration:
BoxDecoration(color: Colors.blue, shape: BoxShape.circle)),
),
decoration: BoxDecoration(
color: Colors.blue, shape: BoxShape.circle))),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding(
padding: const EdgeInsets.all(12),
@ -890,8 +1132,7 @@ class JobTimeline extends StatelessWidget {
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600),
),
child: MyText.bodySmall(initials, fontWeight: 600)),
const SizedBox(width: 6),
Expanded(child: MyText.bodySmall(updatedBy)),
]),

View File

@ -22,11 +22,11 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
final TextEditingController searchController = TextEditingController();
final ServiceProjectController controller =
Get.put(ServiceProjectController());
@override
void initState() {
super.initState();
// Fetch projects safely after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchProjects();
});
@ -49,7 +49,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
// Navigate to ServiceProjectDetailsScreen
Get.to(() => ServiceProjectDetailsScreen(
projectId: project.id,
projectName: project.name,
@ -60,7 +59,6 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Project Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -92,20 +90,14 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
),
],
),
MySpacing.height(10),
/// Assigned Date
_buildDetailRow(
Icons.date_range_outlined,
Colors.teal,
"Assigned: ${DateTimeUtils.convertUtcToLocal(project.assignedDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}",
fontSize: 13,
),
MySpacing.height(8),
/// Client Info
if (project.client != null)
_buildDetailRow(
Icons.account_circle_outlined,
@ -113,20 +105,14 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
"Client: ${project.client!.name} (${project.client!.contactPerson})",
fontSize: 13,
),
MySpacing.height(8),
/// Contact Info
_buildDetailRow(
Icons.phone,
Colors.green,
"Contact: ${project.contactName} (${project.contactPhone})",
fontSize: 13,
),
MySpacing.height(12),
/// Services List
if (project.services.isNotEmpty)
Wrap(
spacing: 6,
@ -195,16 +181,36 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Service Projects",
projectName: 'All Service Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Column(
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
bottom: true,
child: Column(
children: [
/// Search bar and actions
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
@ -219,12 +225,12 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
suffixIcon:
ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
if (value.text.isEmpty)
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
@ -240,11 +246,13 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
),
),
@ -253,15 +261,13 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
],
),
),
/// Project List
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
@ -271,7 +277,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 80),
left: 8, right: 8, top: 4, bottom: 120),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
@ -282,6 +288,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
),
],
),
),
],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
// Assuming 'package:on_field_work/images.dart' correctly provides 'Images.logoDark'
import 'package:on_field_work/images.dart';
class SplashScreen extends StatefulWidget {
@ -8,8 +9,9 @@ class SplashScreen extends StatefulWidget {
const SplashScreen({
super.key,
this.message,
this.logoSize = 120,
this.message =
'GET WORK DONE, ANYWHERE.', // Default message for a modern look
this.logoSize = 150, // Slightly larger logo
this.backgroundColor = Colors.white,
});
@ -20,20 +22,59 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
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
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
duration:
const Duration(seconds: 3), // Longer duration for complex sequence
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
@ -42,79 +83,73 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose();
}
Widget _buildAnimatedDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
double opacity;
if (index == 0) {
opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0);
} else if (index == 1) {
opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0);
} else {
opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(opacity),
shape: BoxShape.circle,
// A simple, modern custom progress indicator
Widget _buildProgressIndicator() {
return SizedBox(
width: 60,
child: LinearProgressIndicator(
backgroundColor: Colors.blueAccent.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blueAccent),
),
);
},
);
}),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: widget.backgroundColor,
body: SafeArea(
child: Center(
// Full screen display, no SafeArea needed for a full bleed splash
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo with slight bounce animation
ScaleTransition(
scale: Tween(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
),
// Animated Logo (Scale, Opacity, and Float)
FadeTransition(
opacity: _opacityAnimation,
child: AnimatedBuilder(
animation: _floatAnimation,
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: 20),
// Text message
const SizedBox(height: 30),
// Text Message (Fades in slightly after logo)
if (widget.message != null)
Text(
FadeTransition(
opacity: _opacityAnimation,
child: Text(
widget.message!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 30),
// Animated loading dots
_buildAnimatedDots(),
),
const SizedBox(height: 40),
// Modern Loading Indicator
_buildProgressIndicator(),
],
),
),
),
);
}
}

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';
class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
final String projectId;
const DailyProgressReportScreen({super.key, required this.projectId});
@override
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) {
dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId);
}
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
// Removed the ever<ProjectController> block to keep it independent
}
@override
@ -88,69 +83,13 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
body: Stack(
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(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
body: SafeArea(
SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
@ -165,8 +104,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
@ -182,9 +120,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
color: Colors.black,
),
const SizedBox(width: 4),
Icon(Icons.tune,
const Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
@ -206,11 +143,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
),
),
],
),
);
}
Future<void> _openFilterSheet() async {
// Fetch filter data first
if (dailyTaskController.taskFilterData == null) {
await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
@ -307,32 +245,27 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No data available
if (groupedTasks.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Progress Report Found",
"No Progress Report Found for selected filters.",
fontWeight: 600,
),
);
}
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// 🔹 Auto expand if only one date present
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
// 🧱 Return a scrollable column of cards
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -351,7 +284,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🗓 Date Header
GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Padding(
@ -376,8 +308,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
),
),
// 🔽 Task List (expandable)
Obx(() {
if (!dailyTaskController.expandedDates
.contains(dateKey)) {
@ -415,15 +345,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName,
fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location,
color: Colors.grey),
const SizedBox(height: 8),
// 👥 Team Members
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
@ -441,8 +368,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
),
const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
@ -487,8 +412,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700],
),
const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
@ -547,8 +470,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
);
}),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
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/controller/permission_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:on_field_work/model/dailyTaskPlanning/assign_task_bottom_sheet .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';
class DailyTaskPlanningScreen extends StatefulWidget {
DailyTaskPlanningScreen({super.key});
final String projectId; // Optional projectId from parent
DailyTaskPlanningScreen({super.key, required this.projectId});
@override
State<DailyTaskPlanningScreen> createState() =>
@ -30,100 +31,31 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Get.put(DailyTaskPlanningController());
final PermissionController permissionController =
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
@override
void initState() {
super.initState();
final projectId = projectController.selectedProjectId.value;
// Use widget.projectId if passed; otherwise fallback to selectedProjectId
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
// Now this will fetch only services + building list (no deep infra)
dailyTaskPlanningController.fetchTaskData(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
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
body: Stack(
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(
'Daily Task Planning',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
),
body: SafeArea(
SafeArea(
child: MyRefreshIndicator(
onRefresh: () async {
final projectId = projectController.selectedProjectId.value;
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
@ -156,13 +88,12 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData(
await dailyTaskPlanningController
.fetchTaskData(
projectId,
serviceId:
service?.id, // <-- pass selected service
serviceId: service?.id,
);
}
},
@ -181,6 +112,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
),
),
),
],
),
);
}
@ -227,8 +160,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final buildings = dailyTasks
.expand((task) => task.buildings)
.where((building) =>
(building.plannedWork ) > 0 ||
(building.completedWork ) > 0)
(building.plannedWork) > 0 || (building.completedWork) > 0)
.toList();
if (buildings.isEmpty) {
@ -267,16 +199,14 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
});
if (expanded && !buildingLoaded && !buildingLoading) {
// fetch infra details for this building lazily
final projectId =
projectController.selectedProjectId.value;
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchBuildingInfra(
building.id.toString(),
projectId,
serviceController.selectedService?.id,
);
setMainState(() {}); // rebuild to reflect loaded data
setMainState(() {});
}
}
},
@ -320,7 +250,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Padding(
padding: const EdgeInsets.all(16.0),
child: MyText.bodySmall(
"No Progress Report Found",
"No Progress Report Found for this Project",
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.
# 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.
# 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
# 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.
version: 1.0.0+16
version: 1.0.0+18
environment:
sdk: ^3.5.3
@ -32,7 +32,6 @@ dependencies:
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
@ -56,7 +55,7 @@ dependencies:
appflowy_board: ^0.1.2
syncfusion_flutter_calendar: ^29.1.40
syncfusion_flutter_maps: ^29.1.40
http: ^1.2.2
http: ^1.6.0
geolocator: ^14.0.2
permission_handler: ^12.0.1
image: ^4.0.17
@ -84,7 +83,10 @@ dependencies:
timeago: ^3.7.1
cached_network_image: ^3.4.1
gallery_saver_plus: ^3.2.9
share_plus: ^12.0.1
timeline_tile: ^2.0.0
dev_dependencies:
flutter_test:
sdk: flutter
@ -147,3 +149,6 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
dependency_overrides:
http: ^1.6.0