Compare commits

...

292 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
c69e4280ef Merge pull request 'Service_Project_Branching' (#82) from Service_Project_Branching into main
Reviewed-on: #82
2025-11-24 06:32:57 +00:00
761cd1f7e8 chore: update version number to 1.0.0+16 in pubspec.yaml 2025-11-24 10:28:02 +05:30
516d6b0489 feat: enhance notification action handling and add refresh indicators in ServiceProjectDetailsScreen 2025-11-22 16:17:17 +05:30
e9075dcdf5 feat: add project name support in CustomAppBar and related screens for improved context 2025-11-22 15:15:38 +05:30
5c53a3f4be Refactor project structure and rename from 'marco' to 'on field work'
- Updated import paths across multiple files to reflect the new package name.
- Changed application name and identifiers in CMakeLists.txt, Runner.rc, and other configuration files.
- Modified web index.html and manifest.json to update the app title and name.
- Adjusted macOS and Windows project settings to align with the new application name.
- Ensured consistency in naming across all relevant files and directories.
2025-11-22 14:20:37 +05:30
603e7ee7e5 feat: add advance payment indicator in payment request screens and improve code formatting 2025-11-21 16:57:37 +05:30
2c98ac359c feat: enhance payment request data models for null safety and improve filter options handling in PaymentRequestController 2025-11-21 16:42:33 +05:30
fc78806af2 feat: implement job search functionality and archived job toggle in JobsTab; refactor ServiceProjectDetailsScreen to integrate JobsTab 2025-11-21 16:33:09 +05:30
cf7021a982 feat: refactor ExpenseDetailScreen to use tagged controller for improved state management 2025-11-20 18:02:03 +05:30
efefb1c34b feat: add app version display to user profile bar and update version number in pubspec.yaml 2025-11-20 17:19:48 +05:30
846ca64402 feat: update project fetching method to use getGlobalProjects for improved data retrieval 2025-11-20 16:52:51 +05:30
765f537cf9 feat: enhance tenant fetching logic to handle empty responses and invalid JSON gracefully 2025-11-20 16:33:08 +05:30
87a7d19672 feat: enhance job detail screen to display project branch information and improve payment request handling 2025-11-20 16:21:41 +05:30
d0b40a9822 feat: enhance JSON parsing in ServiceProjectAllocationResponse and related models to handle null values gracefully 2025-11-20 11:55:15 +05:30
c44d10d35a feat: update JobDetailsResponse and JobData models to support nullable fields and improve JSON parsing 2025-11-20 11:12:59 +05:30
02ef996753 feat: update projectBranchId field in API service and increment version to 1.0.0+14 2025-11-20 09:58:36 +05:30
ec6a45ed43 feat: integrate snackbar notifications for document and job updates in user document controller and job detail screen 2025-11-19 17:29:10 +05:30
92c739045c feat: rename Expense Type to Expense Category across controllers, models, and views for consistency 2025-11-19 17:20:44 +05:30
5dd09869ad feat: enhance ExpenseDetailModel and ExpenseDetailScreen with additional fields and improved UI structure 2025-11-19 17:17:04 +05:30
8edd189479 feat: add branch selection functionality and API integration for service project jobs 2025-11-19 16:36:32 +05:30
bbadcc4139 feat: update API endpoints and enhance attendance log handling in job detail screen 2025-11-19 15:44:55 +05:30
d05e26bc87 feat: enhance date range picker logic to restrict date selection and improve validation 2025-11-19 12:19:47 +05:30
49326e9929 feat: enhance employee selection and validation in service project job form 2025-11-19 12:00:26 +05:30
0e575c393d feat: enhance job fetching logic and add auto-refresh after job addition 2025-11-19 11:28:39 +05:30
804f0eba7b enhansed ui 2025-11-18 17:50:47 +05:30
253aa55a80 added manage team 2025-11-18 17:44:12 +05:30
17fc04f3ee feat: add cached_network_image dependency and improve image handling in attendance logs 2025-11-18 12:11:30 +05:30
474ecac53c refactor: improve null safety in employee details and enhance UI handling 2025-11-18 11:17:33 +05:30
dc4ea7979c resoled payment request model crash issue 2025-11-18 10:57:46 +05:30
618ac6f27a chore: update package name and bundle identifier to com.marcoonfieldwork.aiot 2025-11-17 20:17:14 +05:30
2260382990 refactor: enhance job attendance handling and improve null safety in models 2025-11-17 18:03:59 +05:30
919310644d added tag in tag out api and code 2025-11-17 17:36:11 +05:30
605617695e refactor: improve code readability and consistency in JobDetailsScreen 2025-11-17 16:02:03 +05:30
4d47ee3002 fixed issues 2025-11-17 15:23:51 +05:30
9dd66bd297 Merge pull request 'OH_Dev_Manish' (#81) from OH_Dev_Manish into Service_Project_Feature
Reviewed-on: #81
2025-11-17 09:48:29 +00:00
f506bd1bfc Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 15:16:01 +05:30
39abfa2260 Rebase issues solved 2025-11-17 15:15:13 +05:30
ed88e0dfc9 implementation of Manage reporting 2025-11-17 15:14:48 +05:30
a910d37f22 All Employees fetching task done in advance payment screen 2025-11-17 15:14:13 +05:30
2df4afcea7 implementation of manage reporting inside employee profile 2025-11-17 15:14:13 +05:30
ceee12df33 implementation of Manage reporting 2025-11-17 15:14:12 +05:30
58ed2676e3 implementation of Manage reporting 2025-11-17 15:14:12 +05:30
e1b6794e15 made chnages in details screen and list screen 2025-11-17 15:14:12 +05:30
01f3cd24da added service projet screen 2025-11-17 15:14:12 +05:30
a8b31b3a95 All Employees fetching task done in advance payment screen 2025-11-17 15:13:51 +05:30
b9195c7fdd .. 2025-11-17 15:13:31 +05:30
be835bd2bd implementation of manage reporting inside employee profile 2025-11-17 15:13:31 +05:30
2d35addeeb implementation of Manage reporting 2025-11-17 15:13:31 +05:30
e3fe3c0d86 implementation of manage reporting inside employee profile 2025-11-17 15:13:31 +05:30
b8aefc544f implementation of Manage reporting 2025-11-17 15:13:30 +05:30
58c6a3f9af All Employees fetching task done in advance payment screen 2025-11-17 15:13:30 +05:30
1f694381a1 .. 2025-11-17 15:13:30 +05:30
ba6d58fd0a implementation of manage reporting inside employee profile 2025-11-17 15:13:30 +05:30
e9a43af350 implementation of Manage reporting 2025-11-17 15:13:15 +05:30
fdd2d51ed0 implementation of manage reporting inside employee profile 2025-11-17 15:12:52 +05:30
cddc2990fb created new emploee selector bottomsheet 2025-11-17 15:12:22 +05:30
3974ae8a4c Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 15:09:21 +05:30
68ed97b64a Rebase issues solved 2025-11-17 15:08:18 +05:30
793eeec7fa Rebase issues solved 2025-11-17 15:07:46 +05:30
29af4e53a9 implementation of Manage reporting 2025-11-17 15:07:13 +05:30
0b894a5091 All Employees fetching task done in advance payment screen 2025-11-17 15:06:39 +05:30
8153d315b7 implementation of manage reporting inside employee profile 2025-11-17 15:06:39 +05:30
bcfa29c0ed implementation of Manage reporting 2025-11-17 15:06:39 +05:30
7f7d73c790 implementation of Manage reporting 2025-11-17 15:06:38 +05:30
7fdeb103cf made chnages in details screen and list screen 2025-11-17 15:06:38 +05:30
3bae3429c6 added service projet screen 2025-11-17 15:06:38 +05:30
79b727aeb1 implementation of Manage reporting 2025-11-17 15:05:39 +05:30
52da540271 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 15:00:04 +05:30
b2619eabe9 added edit job fucntioanllity 2025-11-17 14:59:01 +05:30
b21c00faac added service project job details screen 2025-11-17 14:58:27 +05:30
083eece75b added edit job api 2025-11-17 14:56:29 +05:30
bc62bc903d added service project job details screen 2025-11-17 14:56:29 +05:30
a39af6519a added edit job api 2025-11-17 14:56:29 +05:30
d530077444 added service project job details screen 2025-11-17 14:56:28 +05:30
a2aef3355f fixed the model crashing 2025-11-17 14:56:05 +05:30
de657a4bce All Employees fetching task done in advance payment screen 2025-11-17 14:56:04 +05:30
4c9fecdfcf UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 14:50:59 +05:30
52782098f8 All Employees fetching task done in advance payment screen 2025-11-17 14:50:59 +05:30
fc09673cd0 .. 2025-11-17 14:50:59 +05:30
bd6fc9f81b implementation of manage reporting inside employee profile 2025-11-17 14:50:59 +05:30
3660291482 implementation of Manage reporting 2025-11-17 14:50:59 +05:30
81692a1930 implementation of Manage reporting 2025-11-17 14:50:59 +05:30
5c386cb8ff implementation of manage reporting inside employee profile 2025-11-17 14:50:58 +05:30
0334a6b7a8 implementation of Manage reporting 2025-11-17 14:50:58 +05:30
38528f1ade removed unused code 2025-11-17 14:50:58 +05:30
8619a9e43e created new emploee selector bottomsheet 2025-11-17 14:50:58 +05:30
5a3e9c2977 corrected the payment proceed validation 2025-11-17 14:50:58 +05:30
9f72f689c5 made chnages into dynamic menus 2025-11-17 14:50:58 +05:30
c8a8d45c66 made chnages in details screen and list screen 2025-11-17 14:50:57 +05:30
10e9f4a315 added edit job fucntioanllity 2025-11-17 14:49:44 +05:30
5ce37379cb Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 14:47:42 +05:30
d5868d213a Rebase issues solved 2025-11-17 14:46:33 +05:30
14ed2a8417 implementation of Manage reporting 2025-11-17 14:41:46 +05:30
55fffbbe78 implementation of Manage reporting 2025-11-17 14:40:51 +05:30
6f8f1f1856 All Employees fetching task done in advance payment screen 2025-11-17 14:39:22 +05:30
548ecb33a6 implementation of manage reporting inside employee profile 2025-11-17 14:39:22 +05:30
01d7a2fd3b implementation of Manage reporting 2025-11-17 14:39:09 +05:30
8dabac2eff implementation of Manage reporting 2025-11-17 14:38:26 +05:30
b476986807 made chnages in details screen and list screen 2025-11-17 14:36:14 +05:30
2149fd6bc5 added service projet screen 2025-11-17 14:36:00 +05:30
30405f7de4 Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 14:30:24 +05:30
ffb02027cc Rebase issues solved 2025-11-17 13:02:16 +05:30
d3b8ce17b8 All Employees fetching task done in advance payment screen 2025-11-17 12:58:43 +05:30
e024cf4576 .. 2025-11-17 12:54:15 +05:30
8edf3e89a5 implementation of manage reporting inside employee profile 2025-11-17 12:54:15 +05:30
a61b9c50af implementation of Manage reporting 2025-11-17 12:48:51 +05:30
509813ca2d implementation of manage reporting inside employee profile 2025-11-17 12:46:27 +05:30
61ca8f4250 implementation of Manage reporting 2025-11-17 12:46:27 +05:30
36a3d586a0 UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 12:44:59 +05:30
899252f215 All Employees fetching task done in advance payment screen 2025-11-17 12:44:59 +05:30
411e9afcc9 .. 2025-11-17 12:44:59 +05:30
f3f1f02282 implementation of manage reporting inside employee profile 2025-11-17 12:44:59 +05:30
477667c06a implementation of Manage reporting 2025-11-17 12:44:59 +05:30
575fcc200c implementation of Manage reporting 2025-11-17 12:44:59 +05:30
1b883ac524 implementation of manage reporting inside employee profile 2025-11-17 12:44:59 +05:30
c631f8b092 implementation of Manage reporting 2025-11-17 12:44:59 +05:30
bdfc492e65 removed unused code 2025-11-17 12:44:59 +05:30
674a9c691b created new emploee selector bottomsheet 2025-11-17 12:44:58 +05:30
02c10d8115 corrected the payment proceed validation 2025-11-17 12:44:58 +05:30
456df30c8e made chnages into dynamic menus 2025-11-17 12:44:58 +05:30
d229facfba made chnages in details screen and list screen 2025-11-17 12:44:58 +05:30
4900ce87f7 Rebase issues solved 2025-11-17 11:32:07 +05:30
3ebc9ab602 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 11:25:21 +05:30
7e3d2ac2b9 added edit job api 2025-11-17 11:25:16 +05:30
7ee65115be added service project job details screen 2025-11-17 11:24:37 +05:30
e1952d505b modified api service 2025-11-17 11:22:31 +05:30
2b42393741 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-17 11:17:21 +05:30
ffd18a9e40 added edit job api 2025-11-17 11:17:12 +05:30
9ef1f57ca4 added service project job details screen 2025-11-17 11:17:12 +05:30
6e37e0dd04 fixed the model crashing 2025-11-17 11:16:16 +05:30
ee1e5014b4 added edit job api 2025-11-17 11:15:17 +05:30
31a27da85d added service project job details screen 2025-11-17 11:08:50 +05:30
2e750ad19c Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-17 11:03:29 +05:30
5f99827b23 UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-17 10:51:18 +05:30
70c8160dce All Employees fetching task done in advance payment screen 2025-11-17 10:51:18 +05:30
831aacc202 .. 2025-11-17 10:49:11 +05:30
fe4a64e4d2 implementation of manage reporting inside employee profile 2025-11-17 10:49:11 +05:30
2d4d71b847 implementation of Manage reporting 2025-11-17 10:49:11 +05:30
03b16e0ad9 implementation of Manage reporting 2025-11-17 10:39:28 +05:30
c0b4a74f87 implementation of manage reporting inside employee profile 2025-11-17 10:07:32 +05:30
b0472503d1 implementation of Manage reporting 2025-11-17 10:07:32 +05:30
9666d39d5e fixed the model crashing 2025-11-15 15:35:56 +05:30
befeef3c02 removed unused code 2025-11-14 15:36:32 +05:30
1f47a55d9c created new emploee selector bottomsheet 2025-11-14 15:35:41 +05:30
3bc7b5092f UI Enhancements in Finance Module – Payment Request & Expense Screens 2025-11-13 17:59:51 +05:30
2255ae29fa All Employees fetching task done in advance payment screen 2025-11-13 15:27:13 +05:30
214816ac0f corrected the payment proceed validation 2025-11-13 12:26:39 +05:30
c4bb8331c9 .. 2025-11-13 11:30:36 +05:30
00aa53d496 Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-13 10:54:25 +05:30
818e0a144a implementation of manage reporting inside employee profile 2025-11-13 10:51:32 +05:30
0b60276e03 implementation of Manage reporting 2025-11-13 10:50:05 +05:30
68f17214fd made chnages into dynamic menus 2025-11-12 17:58:33 +05:30
cdba511d43 implementation of manage reporting inside employee profile 2025-11-12 17:17:01 +05:30
6c14fc1507 made chnages in details screen and list screen 2025-11-12 16:48:53 +05:30
feb12862fa Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-12 16:00:25 +05:30
1de5e7fae7 added service projet screen 2025-11-12 16:00:20 +05:30
7b6520597e added routes 2025-11-12 15:58:48 +05:30
11393922e2 Chnaged the Expense Naming and Dashboard menu handeling and chart modifications 2025-11-12 15:58:29 +05:30
cb00911983 added service projet screen 2025-11-12 15:36:10 +05:30
0414a109be Merge branch 'OH_Dev_Manish' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into OH_Dev_Manish 2025-11-12 12:16:27 +05:30
340d0b8a1e implementation of Manage reporting 2025-11-12 12:16:05 +05:30
8fb65d31e2 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-12 12:07:10 +05:30
7f756f3d4c implementation of Manage reporting 2025-11-12 12:04:35 +05:30
d2c7f92a02 added routes 2025-11-12 12:04:12 +05:30
6b56351a49 displayed possible filds in details screen 2025-11-12 11:53:43 +05:30
5db53c29df added validation for payment request in and expense reumbersemrnt bottomsheet 2025-11-12 11:23:13 +05:30
be2f97cc0e fixed the make expense button display issue on creaying expense also 2025-11-11 19:34:54 +05:30
44bbbf9bfb fixed the assign project to eployeee issue after creating employee and added fabicon on expense details screen for assign to project 2025-11-11 19:21:53 +05:30
f62b7d924b fixed add and edit expense issue 2025-11-11 18:50:13 +05:30
ee9c94dc86 fixed the wrong time display 2025-11-11 18:07:20 +05:30
3e99fc67a3 added widgets in finance screen 2025-11-11 17:25:42 +05:30
cffa4456b9 fixed wrong list display due to date format , and added tds calculation in the payment proceed sheet 2025-11-11 17:03:06 +05:30
af5bfcaa59 replaced fab icon with icon on page 2025-11-11 16:12:13 +05:30
7f0c4d075d fixed the no data building are dispalyed 2025-11-11 15:27:19 +05:30
fe950c5b07 fixed pegination and for rejected status no data displayed 2025-11-11 14:55:37 +05:30
034de5a601 added permission for create payment request button 2025-11-11 14:38:05 +05:30
77a6d50571 added dynamic menu for finance cards as per expense title 2025-11-11 14:31:16 +05:30
077dbf35ee added routes 2025-11-11 14:21:54 +05:30
f232ab42b0 Merge branch 'Service_Project_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Service_Project_Feature 2025-11-11 14:21:01 +05:30
3543770654 made chnages to expense category of type 2025-11-11 12:53:55 +05:30
1dc68033df fixed category issue while updating and adding expense 2025-11-11 12:48:57 +05:30
b1f5fb8d78 chnaged expense type to category 2025-11-10 16:39:25 +05:30
8d688f2575 fixed date pickering issue 2025-11-10 16:22:44 +05:30
502bb1e2d9 fixed add remove doscument 2025-11-10 16:10:45 +05:30
a88d085001 fixed the loading issue 2025-11-10 14:38:10 +05:30
fd57686c8a corrected the attachment issue 2025-11-10 12:57:32 +05:30
fd861b3adb chnaged make expense flow 2025-11-10 12:08:07 +05:30
1c253df5f9 fixed the expense screen neigation build issue 2025-11-10 10:26:03 +05:30
92f7fec083 added payment request screens 2025-11-08 16:18:26 +05:30
1070f04d1a added finance code 2025-11-08 15:46:28 +05:30
eb46194679 made chnages in employee details screen 2025-11-08 12:17:17 +05:30
910bb5e6b4 removed employees by project 2025-11-08 11:53:34 +05:30
80d7ef96cb feat: Add daily progress planning skeleton loader and improve UI formatting 2025-11-04 17:41:29 +05:30
99166801da feat: Implement lazy loading for building infrastructure and enhance task fetching logic 2025-11-04 17:07:54 +05:30
87520d7664 fix: Adjust spacing and add today's planned tasks display in daily task planning 2025-11-04 16:26:33 +05:30
e8d4931016 fix: Enhance employee sorting by adding latest entry comparison after action priority 2025-11-04 15:45:53 +05:30
f0d42edcc1 fix: Update regularization time picker to use reference time and allow past times for regularization 2025-11-04 15:18:42 +05:30
82c2cc3c58 feat: Improve task data fetching by clearing grouped tasks on new load and preventing duplicates 2025-11-04 14:52:17 +05:30
ac7a75c92f Refactor date handling in controllers and UI components
- Updated `user_document_controller.dart` to use DateTime for start and end dates.
- Enhanced `daily_task_controller.dart` with Rx fields for date range and added methods to update date ranges.
- Reverted API base URL in `api_endpoints.dart` to stage environment.
- Introduced `date_range_picker.dart` widget for reusable date selection.
- Integrated `DateRangePickerWidget` in attendance and daily task filter bottom sheets.
- Simplified date range selection logic in `attendance_filter_sheet.dart`, `daily_progress_report_filter.dart`, and `user_document_filter_bottom_sheet.dart`.
- Updated expense filter bottom sheet to utilize the new date range picker.
2025-11-04 14:15:21 +05:30
b1437db9e0 fix: Update text label in attendance filter bottom sheet for clarity 2025-11-03 17:46:33 +05:30
4d3a42e851 feat: Update image picker section to use dynamic primary color for icons and buttons 2025-11-03 17:07:15 +05:30
c78c14e409 refactor: Clean up attendance controller initialization and enhance project change handling 2025-11-03 16:43:51 +05:30
ebc8996f71 feat: Enhance monthly expense dashboard with expense type filtering functionality 2025-11-03 15:00:59 +05:30
3c95583a23 feat: Add monthly expense reporting functionality with dashboard integration 2025-11-03 14:24:39 +05:30
91174dd960 fix: Adjust padding and inner radius for expense donut chart based on mobile layout 2025-11-03 10:25:58 +05:30
9e52e50cc9 fix: Change ProjectController injection method from Get.find to Get.put for proper dependency management 2025-11-03 10:14:49 +05:30
177f8c32e2 feat: Add Expense By Status Widget and related models
- Implemented ExpenseByStatusWidget to display expenses categorized by status.
- Added ExpenseReportResponse, ExpenseTypeReportResponse, and related models for handling expense data.
- Introduced Skeleton loaders for expense status and charts for better UI experience during data loading.
- Updated DashboardScreen to include the new ExpenseByStatusWidget and ensure proper integration with existing components.
2025-11-01 17:29:16 +05:30
d15d9f22df Refactor theme editor widget and dashboard screen layout; enhance user document screen with improved search, filter, and document management features 2025-10-31 14:57:29 +05:30
3c89b4ddbb feat: Implement color theme persistence and toggle functionality in ThemeCustomizer and ThemeEditorWidget 2025-10-31 10:54:56 +05:30
d208648350 feat: Add timestamp functionality to images in attendance and expense controllers
- Implemented TimestampImageHelper to add timestamps to images.
- Updated AttendanceController to apply timestamps when capturing images.
- Enhanced AddExpenseController to process images with timestamps.
- Modified ReportTaskActionController and ReportTaskController to include timestamping for images.
- Updated UI components to show loading indicators while processing images.
- Refactored image picking logic to handle timestamping and loading states.
2025-10-30 18:03:54 +05:30
16a2e1e53a Merge pull request 'Feature_Theme_Chnage_Main' (#76) from Feature_Theme_Chnage_Main into main
Reviewed-on: #76
2025-10-30 05:26:05 +00:00
15b6dbd374 refactor: change default colorTheme from purple to red in ThemeCustomizer 2025-10-29 11:46:18 +05:30
7007a86ac7 Refactor button color references to use 'primary' instead of 'buttonColor' across multiple files
- Updated AttendanceButtonHelper to rename getButtonColor to getprimary.
- Changed button color references in AttendanceActionButton, ReusableListCard, UserDocumentFilterBottomSheet, and various auth screens to use contentTheme.primary.
- Adjusted color references in employee detail, expense, and directory views to align with the new primary color scheme.
- Removed unused ThemeOption class in UserProfileBar and updated color references accordingly.
- Ensured consistency in color usage for buttons and icons throughout the application.
2025-10-29 11:29:11 +05:30
04da062f4f Refactor UI components to use contentTheme colors
- Updated various screens to replace hardcoded color values with contentTheme.buttonColor for consistency.
- Changed icons in NotesView, UserDocumentsPage, EmployeeDetailPage, EmployeesScreen, ExpenseDetailScreen, and others to use updated icon styles.
- Refactored OfflineScreen and TenantSelectionScreen to utilize new wave background widget.
- Introduced ThemeEditorWidget in UserProfileBar for dynamic theme adjustments.
- Enhanced logout confirmation dialog styling to align with new theme colors.
2025-10-28 17:38:19 +05:30
97b45ebd91 chore: remove flutter_quill and related dependencies from pubspec.yaml 2025-10-27 15:21:46 +05:30
a245670e0a refactor: simplify comment and note handling by removing unused dependencies and consolidating logic 2025-10-27 15:20:17 +05:30
038b33e3b8 icon name changed to badge_alert 2025-10-24 12:48:23 +05:30
83218166ba revert 7fb5a5217aee404e3c940f0729086460d9bd1c4c
revert fixed initial loging
2025-10-11 08:49:37 +00:00
26611d3650 reduced snaxkbar time 2025-10-11 14:16:38 +05:30
7fb5a5217a fixed initial loging 2025-10-11 11:42:40 +05:30
706881d08d Merge pull request 'Issue_10_10_2025' (#75) from Issue_10_10_2025 into main
Reviewed-on: #75
2025-10-11 04:57:46 +00:00
e8acfe10d9 enhanced the restore button logic 2025-10-10 20:01:16 +05:30
16e2f5a4f3 enhanced desabled cards 2025-10-10 19:52:12 +05:30
cd92d4d309 added multiple bucket assignment 2025-10-10 19:46:17 +05:30
5dc2db0a8b fixed the issues 2025-10-10 19:41:13 +05:30
bb5fdb27b2 fixed delete and ad comment not updating properly in contact detailsnotes 2025-10-10 10:10:46 +05:30
acb203848e fixed the bugs 2025-10-09 18:09:54 +05:30
e6238ca5b0 Merge pull request 'Tenant_Selection_Issue_Fixed' (#74) from Tenant_Selection_Issue_Fixed into main
Reviewed-on: #74
2025-10-09 09:28:43 +00:00
d5a8d08e63 added splash screen 2025-10-08 17:33:20 +05:30
041b62ca2f fixed the tennt selection process 2025-10-08 16:07:26 +05:30
d1d48b1a74 fixed auto tenant selection 2025-10-08 11:41:38 +05:30
7e75431feb fixed permissions loading issue on employee screen 2025-10-08 11:20:50 +05:30
45bc492683 fixed tenant issue 2025-10-08 11:06:39 +05:30
26675388dd corrected the prefield data while editing employee 2025-10-06 12:27:43 +05:30
312 changed files with 35922 additions and 11203 deletions

View File

@ -1,4 +1,4 @@
# marco # On Field Work
A new Flutter project. A new Flutter project.

View File

@ -15,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
// Define the namespace for your Android application // Define the namespace for your Android application
namespace = "com.marco.aiot" namespace = "com.marcoonfieldwork.aiot"
// Set the compile SDK version based on Flutter's configuration // Set the compile SDK version based on Flutter's configuration
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// Set the NDK version based on Flutter's configuration // Set the NDK version based on Flutter's configuration
@ -37,7 +37,7 @@ android {
// Default configuration for your application // Default configuration for your application
defaultConfig { defaultConfig {
// Specify your unique Application ID. This identifies your app on Google Play. // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marco.aiot" applicationId = "com.marcoonfieldwork.aiot"
// Set minimum and target SDK versions based on Flutter's configuration // Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23 minSdk = 23
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion

View File

@ -9,7 +9,7 @@
"client_info": { "client_info": {
"mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024", "mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024",
"android_client_info": { "android_client_info": {
"package_name": "com.marco.aiot" "package_name": "com.marcoonfieldwork.aiot"
} }
}, },
"oauth_client": [], "oauth_client": [],

View File

@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="Marco" android:label="On Field Work"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View File

@ -1,4 +1,4 @@
package com.marco.aiot package com.marcoonfieldwork.aiot
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

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

View File

@ -14,7 +14,7 @@ YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# App info # App info
APP_NAME="Marco" APP_NAME="On Field Work"
BUILD_DIR="build/app/outputs" BUILD_DIR="build/app/outputs"
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}" echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"

View File

@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -547,7 +547,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -569,7 +569,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiot; PRODUCT_BUNDLE_IDENTIFIER = com.marcoonfieldwork.aiot;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Marco</string> <string>On Field Work</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>marco</string> <string>on field work</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@ -8,5 +8,5 @@ class AppConstant {
static int iOSAppVersion = 1; static int iOSAppVersion = 1;
static String version = "1.0.0"; static String version = "1.0.0";
static String get appName => 'Marco'; static String get appName => 'On Field Work';
} }

View File

@ -1,60 +1,67 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/attendance/attendance_model.dart'; import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
import 'package:marco/model/project_model.dart'; import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/attendance/attendance_log_model.dart';
import 'package:marco/model/attendance/attendance_log_model.dart'; import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
import 'package:marco/model/regularization_log_model.dart'; import 'package:on_field_work/model/attendance/attendance_model.dart';
import 'package:marco/model/attendance/attendance_log_view_model.dart'; import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/model/project_model.dart';
import 'package:on_field_work/model/regularization_log_model.dart';
class AttendanceController extends GetxController { class AttendanceController extends GetxController {
// Data models // ------------------ Data Models ------------------
List<AttendanceModel> attendances = []; final List<AttendanceModel> attendances = <AttendanceModel>[];
List<ProjectModel> projects = []; final List<ProjectModel> projects = <ProjectModel>[];
List<EmployeeModel> employees = []; final List<EmployeeModel> employees = <EmployeeModel>[];
List<AttendanceLogModel> attendanceLogs = []; final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[];
List<RegularizationLogModel> regularizationLogs = []; final List<RegularizationLogModel> regularizationLogs =
List<AttendanceLogViewModel> attendenceLogsView = []; <RegularizationLogModel>[];
final List<AttendanceLogViewModel> attendenceLogsView =
<AttendanceLogViewModel>[];
// ------------------ Organizations ------------------ // ------------------ Organizations ------------------
List<Organization> organizations = []; final List<Organization> organizations = <Organization>[];
Organization? selectedOrganization; Organization? selectedOrganization;
final isLoadingOrganizations = false.obs; final RxBool isLoadingOrganizations = false.obs;
// States // ------------------ States ------------------
String selectedTab = 'todaysAttendance'; String selectedTab = 'todaysAttendance';
DateTime? startDateAttendance;
DateTime? endDateAttendance;
final isLoading = true.obs; // Reactive date range
final isLoadingProjects = true.obs; final Rx<DateTime> startDateAttendance =
final isLoadingEmployees = true.obs; DateTime.now().subtract(const Duration(days: 7)).obs;
final isLoadingAttendanceLogs = true.obs; final Rx<DateTime> endDateAttendance =
final isLoadingRegularizationLogs = true.obs; DateTime.now().subtract(const Duration(days: 1)).obs;
final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs; final RxBool isLoading = true.obs;
var showPendingOnly = false.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 RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
final RxBool showPendingOnly = false.obs;
final RxString searchQuery = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeDefaults(); _initializeDefaults();
// 🔹 Fetch organizations for the selected project
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
fetchOrganizations(projectId);
}
} }
void _initializeDefaults() { void _initializeDefaults() {
@ -62,55 +69,60 @@ String selectedTab = 'todaysAttendance';
} }
void _setDefaultDateRange() { void _setDefaultDateRange() {
final today = DateTime.now(); final DateTime today = DateTime.now();
startDateAttendance = today.subtract(const Duration(days: 7)); startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance = today.subtract(const Duration(days: 1)); endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe( logSafe(
"Default date range set: $startDateAttendance to $endDateAttendance"); 'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
} }
// ------------------ Project & Employee ------------------ // ------------------ Computed Filters ------------------
/// Called when a notification says attendance has been updated List<EmployeeModel> get filteredEmployees {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return employees;
return employees
.where(
(EmployeeModel e) => e.name.toLowerCase().contains(query),
)
.toList();
}
List<AttendanceLogModel> get filteredLogs {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return attendanceLogs;
return attendanceLogs
.where(
(AttendanceLogModel log) => log.name.toLowerCase().contains(query),
)
.toList();
}
List<RegularizationLogModel> get filteredRegularizationLogs {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return regularizationLogs;
return regularizationLogs
.where(
(RegularizationLogModel log) =>
log.name.toLowerCase().contains(query),
)
.toList();
}
// ------------------ Project & Employee APIs ------------------
Future<void> refreshDataFromNotification({String? projectId}) async { Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id; projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) { if (projectId == null) {
logSafe("No project selected for attendance refresh from notification", logSafe(
level: LogLevel.warning); 'No project selected for attendance refresh from notification',
level: LogLevel.warning,
);
return; return;
} }
await fetchProjectData(projectId); await fetchProjectData(projectId);
logSafe( logSafe(
"Attendance data refreshed from notification for project $projectId"); 'Attendance data refreshed from notification for project $projectId',
} );
// 🔍 Search query
final searchQuery = ''.obs;
// Computed filtered employees
List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered logs
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
} }
Future<void> fetchTodaysAttendance(String? projectId) async { Future<void> fetchTodaysAttendance(String? projectId) async {
@ -118,105 +130,105 @@ String selectedTab = 'todaysAttendance';
isLoadingEmployees.value = true; isLoadingEmployees.value = true;
final response = await ApiService.getTodaysAttendance( final List<dynamic>? response = await ApiService.getTodaysAttendance(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); employees
for (var emp in employees) { ..clear()
..addAll(
response
.map<EmployeeModel>(
(dynamic e) => EmployeeModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
for (final EmployeeModel emp in employees) {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
logSafe("Employees fetched: ${employees.length} for project $projectId");
logSafe(
'Employees fetched: ${employees.length} for project $projectId',
);
} else { } else {
logSafe("Failed to fetch employees for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch employees for project $projectId',
level: LogLevel.error,
);
} }
isLoadingEmployees.value = false; isLoadingEmployees.value = false;
update(); update();
} }
Future<void> fetchOrganizations(String projectId) async { Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true; isLoadingOrganizations.value = true;
// Keep original return type inference from your ApiService
final response = await ApiService.getAssignedOrganizations(projectId); final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) { if (response != null) {
organizations = response.data; organizations
logSafe("Organizations fetched: ${organizations.length}"); ..clear()
..addAll(response.data);
logSafe('Organizations fetched: ${organizations.length}');
} else { } else {
logSafe("Failed to fetch organizations for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch organizations for project $projectId',
level: LogLevel.error,
);
} }
isLoadingOrganizations.value = false; isLoadingOrganizations.value = false;
update(); update();
} }
// ------------------ Attendance Capture ------------------ // ------------------ Attendance Capture ------------------
Future<bool> captureAndUploadAttendance( Future<bool> captureAndUploadAttendance(
String id, String id,
String employeeId, String employeeId,
String projectId, { String projectId, {
String comment = "Marked via mobile app", String comment = 'Marked via mobile app',
required int action, required int action,
bool imageCapture = true, bool imageCapture = true,
String? markTime, // still optional in controller String? markTime,
String? date, // new optional param String? date,
}) async { }) async {
try { try {
uploadingStates[employeeId]?.value = true; _setUploading(employeeId, true);
XFile? image; final XFile? image = await _captureAndPrepareImage(
if (imageCapture) { employeeId: employeeId,
image = await ImagePicker() imageCapture: imageCapture,
.pickImage(source: ImageSource.camera, imageQuality: 80); );
if (image == null) { if (imageCapture && image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning); return false;
return false;
}
final compressedBytes =
await compressImageToUnder100KB(File(image.path));
if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error);
return false;
}
final compressedFile = await saveCompressedImageToFile(compressedBytes);
image = XFile(compressedFile.path);
} }
if (!await _handleLocationPermission()) return false; final Position? position = await _getCurrentPositionSafely();
final position = await Geolocator.getCurrentPosition( if (position == null) return false;
desiredAccuracy: LocationAccuracy.high);
final imageName = imageCapture final String imageName = imageCapture
? ApiService.generateImageName(employeeId, employees.length + 1) ? ApiService.generateImageName(
: ""; employeeId,
employees.length + 1,
)
: '';
// ---------------- DATE / TIME LOGIC ---------------- final DateTime effectiveDate =
final now = DateTime.now(); _resolveEffectiveDateForAction(action, employeeId);
// Default effectiveDate = now final DateTime now = DateTime.now();
DateTime effectiveDate = now; final String formattedMarkTime =
markTime ?? DateFormat('hh:mm a').format(now);
if (action == 1) { final String formattedDate =
// Checkout
// Try to find today's open log for this employee
final log = attendanceLogs.firstWhereOrNull(
(log) => log.employeeId == employeeId && log.checkOut == null,
);
if (log?.checkIn != null) {
effectiveDate = log!.checkIn!; // use check-in date
}
}
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
final formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate); date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
// ---------------- API CALL ---------------- final bool result = await ApiService.uploadAttendanceImage(
final result = await ApiService.uploadAttendanceImage(
id, id,
employeeId, employeeId,
image, image,
@ -231,15 +243,99 @@ String selectedTab = 'todaysAttendance';
date: formattedDate, date: formattedDate,
); );
logSafe( if (result) {
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate"); logSafe(
'Attendance uploaded for $employeeId, action: $action, date: $formattedDate',
);
if (Get.isRegistered<DashboardController>()) {
final DashboardController dashboardController =
Get.find<DashboardController>();
await dashboardController.fetchTodaysAttendance(projectId);
}
}
return result; return result;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error uploading attendance", logSafe(
level: LogLevel.error, error: e, stackTrace: stacktrace); 'Error uploading attendance',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
return false; return false;
} finally { } finally {
uploadingStates[employeeId]?.value = false; _setUploading(employeeId, false);
}
}
Future<XFile?> _captureAndPrepareImage({
required String employeeId,
required bool imageCapture,
}) async {
if (!imageCapture) return null;
final XFile? rawImage = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (rawImage == null) {
logSafe(
'Image capture cancelled.',
level: LogLevel.warning,
);
return null;
}
final File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(rawImage.path),
);
final List<int>? compressedBytes =
await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) {
logSafe(
'Image compression failed.',
level: LogLevel.error,
);
return null;
}
// FIX: convert List<int> -> Uint8List
final Uint8List compressedUint8List = Uint8List.fromList(compressedBytes);
final File compressedFile =
await saveCompressedImageToFile(compressedUint8List);
return XFile(compressedFile.path);
}
Future<Position?> _getCurrentPositionSafely() async {
final bool permissionGranted = await _handleLocationPermission();
if (!permissionGranted) return null;
return Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
}
DateTime _resolveEffectiveDateForAction(int action, String employeeId) {
final DateTime now = DateTime.now();
if (action != 1) return now;
final AttendanceLogModel? log = attendanceLogs.firstWhereOrNull(
(AttendanceLogModel log) =>
log.employeeId == employeeId && log.checkOut == null,
);
return log?.checkIn ?? now;
}
void _setUploading(String employeeId, bool value) {
final RxBool? state = uploadingStates[employeeId];
if (state != null) {
state.value = value;
} else {
uploadingStates[employeeId] = value.obs;
} }
} }
@ -249,14 +345,19 @@ String selectedTab = 'todaysAttendance';
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission(); permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
logSafe('Location permissions are denied', level: LogLevel.warning); logSafe(
'Location permissions are denied',
level: LogLevel.warning,
);
return false; return false;
} }
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
logSafe('Location permissions are permanently denied', logSafe(
level: LogLevel.error); 'Location permissions are permanently denied',
level: LogLevel.error,
);
return false; return false;
} }
@ -264,26 +365,40 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Logs ------------------ // ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(
Future<void> fetchAttendanceLogs(String? projectId, String? projectId, {
{DateTime? dateFrom, DateTime? dateTo}) async { DateTime? dateFrom,
DateTime? dateTo,
}) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingAttendanceLogs.value = true; isLoadingAttendanceLogs.value = true;
final response = await ApiService.getAttendanceLogs( final List<dynamic>? response = await ApiService.getAttendanceLogs(
projectId, projectId,
dateFrom: dateFrom, dateFrom: dateFrom,
dateTo: dateTo, dateTo: dateTo,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
attendanceLogs = attendanceLogs
response.map((e) => AttendanceLogModel.fromJson(e)).toList(); ..clear()
logSafe("Attendance logs fetched: ${attendanceLogs.length}"); ..addAll(
response
.map<AttendanceLogModel>(
(dynamic e) => AttendanceLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
} else { } else {
logSafe("Failed to fetch attendance logs for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch attendance logs for project $projectId',
level: LogLevel.error,
);
} }
isLoadingAttendanceLogs.value = false; isLoadingAttendanceLogs.value = false;
@ -291,45 +406,70 @@ String selectedTab = 'todaysAttendance';
} }
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() { Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final groupedLogs = <String, List<AttendanceLogModel>>{}; final Map<String, List<AttendanceLogModel>> groupedLogs =
<String, List<AttendanceLogModel>>{};
for (var logItem in attendanceLogs) { for (final AttendanceLogModel logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null final String checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!) ? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown'; : 'Unknown';
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
groupedLogs.putIfAbsent(
checkInDate,
() => <AttendanceLogModel>[],
)..add(logItem);
} }
final sortedEntries = groupedLogs.entries.toList() final List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries =
..sort((a, b) { groupedLogs.entries.toList()
if (a.key == 'Unknown') return 1; ..sort(
if (b.key == 'Unknown') return -1; (MapEntry<String, List<AttendanceLogModel>> a,
final dateA = DateFormat('dd MMM yyyy').parse(a.key); MapEntry<String, List<AttendanceLogModel>> b) {
final dateB = DateFormat('dd MMM yyyy').parse(b.key); if (a.key == 'Unknown') return 1;
return dateB.compareTo(dateA); if (b.key == 'Unknown') return -1;
});
return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries); final DateTime dateA = DateFormat('dd MMM yyyy').parse(a.key);
final DateTime dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
},
);
return Map<String, List<AttendanceLogModel>>.fromEntries(
sortedEntries,
);
} }
// ------------------ Regularization Logs ------------------ // ------------------ Regularization Logs ------------------
Future<void> fetchRegularizationLogs(String? projectId) async { Future<void> fetchRegularizationLogs(String? projectId) async {
if (projectId == null) return; if (projectId == null) return;
isLoadingRegularizationLogs.value = true; isLoadingRegularizationLogs.value = true;
final response = await ApiService.getRegularizationLogs( final List<dynamic>? response = await ApiService.getRegularizationLogs(
projectId, projectId,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
if (response != null) { if (response != null) {
regularizationLogs = regularizationLogs
response.map((e) => RegularizationLogModel.fromJson(e)).toList(); ..clear()
logSafe("Regularization logs fetched: ${regularizationLogs.length}"); ..addAll(
response
.map<RegularizationLogModel>(
(dynamic e) => RegularizationLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe(
'Regularization logs fetched: ${regularizationLogs.length}',
);
} else { } else {
logSafe("Failed to fetch regularization logs for project $projectId", logSafe(
level: LogLevel.error); 'Failed to fetch regularization logs for project $projectId',
level: LogLevel.error,
);
} }
isLoadingRegularizationLogs.value = false; isLoadingRegularizationLogs.value = false;
@ -337,22 +477,38 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Attendance Log View ------------------ // ------------------ Attendance Log View ------------------
Future<void> fetchLogsView(String? id) async { Future<void> fetchLogsView(String? id) async {
if (id == null) return; if (id == null) return;
isLoadingLogView.value = true; isLoadingLogView.value = true;
final response = await ApiService.getAttendanceLogView(id); final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
if (response != null) { if (response != null) {
attendenceLogsView = attendenceLogsView
response.map((e) => AttendanceLogViewModel.fromJson(e)).toList(); ..clear()
attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000)) ..addAll(
.compareTo(a.activityTime ?? DateTime(2000))); response
logSafe("Attendance log view fetched for ID: $id"); .map<AttendanceLogViewModel>(
(dynamic e) => AttendanceLogViewModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
attendenceLogsView.sort(
(AttendanceLogViewModel a, AttendanceLogViewModel b) =>
(b.activityTime ?? DateTime(2000))
.compareTo(a.activityTime ?? DateTime(2000)),
);
logSafe('Attendance log view fetched for ID: $id');
} else { } else {
logSafe("Failed to fetch attendance log view for ID $id", logSafe(
level: LogLevel.error); 'Failed to fetch attendance log view for ID $id',
level: LogLevel.error,
);
} }
isLoadingLogView.value = false; isLoadingLogView.value = false;
@ -360,7 +516,6 @@ String selectedTab = 'todaysAttendance';
} }
// ------------------ Combined Load ------------------ // ------------------ Combined Load ------------------
Future<void> loadAttendanceData(String projectId) async { Future<void> loadAttendanceData(String projectId) async {
isLoading.value = true; isLoading.value = true;
await fetchProjectData(projectId); await fetchProjectData(projectId);
@ -372,7 +527,6 @@ String selectedTab = 'todaysAttendance';
await fetchOrganizations(projectId); await fetchOrganizations(projectId);
// Call APIs depending on the selected tab only
switch (selectedTab) { switch (selectedTab) {
case 'todaysAttendance': case 'todaysAttendance':
await fetchTodaysAttendance(projectId); await fetchTodaysAttendance(projectId);
@ -380,8 +534,8 @@ String selectedTab = 'todaysAttendance';
case 'attendanceLogs': case 'attendanceLogs':
await fetchAttendanceLogs( await fetchAttendanceLogs(
projectId, projectId,
dateFrom: startDateAttendance, dateFrom: startDateAttendance.value,
dateTo: endDateAttendance, dateTo: endDateAttendance.value,
); );
break; break;
case 'regularizationRequests': case 'regularizationRequests':
@ -390,31 +544,35 @@ String selectedTab = 'todaysAttendance';
} }
logSafe( logSafe(
"Project data fetched for project ID: $projectId, tab: $selectedTab"); 'Project data fetched for project ID: $projectId, tab: $selectedTab',
);
update(); update();
} }
// ------------------ UI Interaction ------------------ // ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance( Future<void> selectDateRangeForAttendance(
BuildContext context, AttendanceController controller) async { BuildContext context,
final today = DateTime.now(); AttendanceController controller,
) async {
final DateTime today = DateTime.now();
final picked = await showDateRangePicker( final DateTimeRange? picked = await showDateRangePicker(
context: context, context: context,
firstDate: DateTime(2022), firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)), lastDate: today.subtract(const Duration(days: 1)),
initialDateRange: DateTimeRange( initialDateRange: DateTimeRange(
start: startDateAttendance ?? today.subtract(const Duration(days: 7)), start: startDateAttendance.value,
end: endDateAttendance ?? today.subtract(const Duration(days: 1)), end: endDateAttendance.value,
), ),
); );
if (picked != null) { if (picked != null) {
startDateAttendance = picked.start; startDateAttendance.value = picked.start;
endDateAttendance = picked.end; endDateAttendance.value = picked.end;
logSafe( logSafe(
"Date range selected: $startDateAttendance to $endDateAttendance"); 'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
await controller.fetchAttendanceLogs( await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id, Get.find<ProjectController>().selectedProject?.id,

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart'; import 'package:on_field_work/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ForgotPasswordController extends MyController { class ForgotPasswordController extends MyController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart'; import 'package:on_field_work/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class LoginController extends MyController { class LoginController extends MyController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -14,6 +14,7 @@ class LoginController extends MyController {
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxBool showPassword = false.obs; final RxBool showPassword = false.obs;
final RxBool isChecked = false.obs; final RxBool isChecked = false.obs;
final RxBool showSplash = false.obs;
@override @override
void onInit() { void onInit() {
@ -40,18 +41,14 @@ class LoginController extends MyController {
); );
} }
void onChangeCheckBox(bool? value) { void onChangeCheckBox(bool? value) => isChecked.value = value ?? false;
isChecked.value = value ?? false;
}
void onChangeShowPassword() { void onChangeShowPassword() => showPassword.toggle();
showPassword.toggle();
}
Future<void> onLogin() async { Future<void> onLogin() async {
if (!basicValidator.validateForm()) return; if (!basicValidator.validateForm()) return;
isLoading.value = true; showSplash.value = true;
try { try {
final loginData = basicValidator.getData(); final loginData = basicValidator.getData();
@ -60,49 +57,30 @@ class LoginController extends MyController {
final errors = await AuthService.loginUser(loginData); final errors = await AuthService.loginUser(loginData);
if (errors != null) { if (errors != null) {
logSafe(
"Login failed for user: ${loginData['username']} with errors: $errors",
level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
title: "Login Failed", title: "Login Failed",
message: "Username or password is incorrect", message: "Username or password is incorrect",
type: SnackbarType.error, type: SnackbarType.error,
); );
basicValidator.addErrors(errors); basicValidator.addErrors(errors);
basicValidator.validateForm(); basicValidator.validateForm();
basicValidator.clearErrors(); basicValidator.clearErrors();
} else { } else {
await _handleRememberMe(); await _handleRememberMe();
// Enable remote logging after successful login
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login.");
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after login."
: "⚠️ Failed to register FCM token after login.",
level: LogLevel.warning);
}
logSafe("Login successful for user: ${loginData['username']}"); logSafe("Login successful for user: ${loginData['username']}");
Get.offNamed('/select-tenant');
Get.toNamed('/select_tenant');
} }
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
showAppSnackbar( showAppSnackbar(
title: "Login Error", title: "Login Error",
message: "An unexpected error occurred", message: "An unexpected error occurred",
type: SnackbarType.error, type: SnackbarType.error,
); );
logSafe("Exception during login",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally { } finally {
isLoading.value = false; showSplash.value = false;
} }
} }
@ -134,11 +112,7 @@ class LoginController extends MyController {
} }
} }
void goToForgotPassword() { void goToForgotPassword() => Get.toNamed('/auth/forgot_password');
Get.toNamed('/auth/forgot_password');
}
void gotoRegister() { void gotoRegister() => Get.offAndToNamed('/auth/register_account');
Get.offAndToNamed('/auth/register_account');
}
} }

View File

@ -1,12 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
class MPINController extends GetxController { class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
@ -138,16 +139,17 @@ class MPINController extends GetxController {
} }
/// Navigate to dashboard /// Navigate to dashboard
void _navigateToDashboard({String? message}) { /// Navigate to tenant selection after MPIN verification
void _navigateToTenantSelection({String? message}) {
if (message != null) { if (message != null) {
logSafe("Navigating to Dashboard with message: $message"); logSafe("Navigating to Tenant Selection with message: $message");
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: message, message: message,
type: SnackbarType.success, type: SnackbarType.success,
); );
} }
Get.offAll(() => const DashboardScreen()); Get.offAllNamed('/select-tenant');
} }
/// Clear the primary MPIN fields /// Clear the primary MPIN fields
@ -239,15 +241,12 @@ class MPINController extends GetxController {
logSafe("verifyMPIN triggered"); logSafe("verifyMPIN triggered");
final enteredMPIN = digitControllers.map((c) => c.text).join(); final enteredMPIN = digitControllers.map((c) => c.text).join();
logSafe("Entered MPIN: $enteredMPIN");
if (enteredMPIN.length < 4) { if (enteredMPIN.length < 4) {
_showError("Please enter all 4 digits."); _showError("Please enter all 4 digits.");
return; return;
} }
final mpinToken = await LocalStorage.getMpinToken(); final mpinToken = await LocalStorage.getMpinToken();
if (mpinToken == null || mpinToken.isEmpty) { if (mpinToken == null || mpinToken.isEmpty) {
_showError("Missing MPIN token. Please log in again."); _showError("Missing MPIN token. Please log in again.");
return; return;
@ -270,12 +269,25 @@ class MPINController extends GetxController {
logSafe("MPIN verified successfully"); logSafe("MPIN verified successfully");
await LocalStorage.setBool('mpin_verified', true); await LocalStorage.setBool('mpin_verified', true);
// 🔹 Ensure controllers are injected and loaded
final token = await LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
await Get.find<PermissionController>().loadData(token);
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
await Get.find<ProjectController>().fetchProjects();
}
}
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "MPIN Verified Successfully", message: "MPIN Verified Successfully",
type: SnackbarType.success, type: SnackbarType.success,
); );
_navigateToDashboard(); _navigateToTenantSelection();
} else { } else {
final errorMessage = response["error"] ?? "Invalid MPIN"; final errorMessage = response["error"] ?? "Invalid MPIN";
logSafe("MPIN verification failed: $errorMessage", logSafe("MPIN verification failed: $errorMessage",
@ -291,11 +303,7 @@ class MPINController extends GetxController {
} catch (e) { } catch (e) {
isLoading.value = false; isLoading.value = false;
logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e); logSafe("Exception in verifyMPIN", level: LogLevel.error, error: e);
showAppSnackbar( _showError("Something went wrong. Please try again.");
title: "Error",
message: "Something went wrong. Please try again.",
type: SnackbarType.error,
);
} }
} }

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class OTPController extends GetxController { class OTPController extends GetxController {
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
@ -109,7 +109,8 @@ class OTPController extends GetxController {
} }
void onOTPChanged(String value, int index) { void onOTPChanged(String value, int index) {
logSafe("[OTPController] OTP field changed: index=$index", level: LogLevel.debug); logSafe("[OTPController] OTP field changed: index=$index",
level: LogLevel.debug);
if (value.isNotEmpty) { if (value.isNotEmpty) {
if (index < otpControllers.length - 1) { if (index < otpControllers.length - 1) {
focusNodes[index + 1].requestFocus(); focusNodes[index + 1].requestFocus();
@ -125,30 +126,24 @@ class OTPController extends GetxController {
Future<void> verifyOTP() async { Future<void> verifyOTP() async {
final enteredOTP = otpControllers.map((c) => c.text).join(); final enteredOTP = otpControllers.map((c) => c.text).join();
logSafe("[OTPController] Verifying OTP");
final result = await AuthService.verifyOtp( final result = await AuthService.verifyOtp(
email: email.value, email: email.value,
otp: enteredOTP, otp: enteredOTP,
); );
if (result == null) { if (result == null) {
logSafe("[OTPController] OTP verified successfully"); // Handle remember-me like in LoginController
showAppSnackbar( final remember = LocalStorage.getBool('remember_me') ?? false;
title: "Success", if (remember) await LocalStorage.setToken('otp_email', email.value);
message: "OTP verified successfully",
type: SnackbarType.success,
);
final bool isMpinEnabled = LocalStorage.getIsMpin();
logSafe("[OTPController] MPIN Enabled: $isMpinEnabled");
Get.offAllNamed('/home'); // Enable remote logging
enableRemoteLogging();
Get.offAllNamed('/select-tenant');
} else { } else {
final error = result['error'] ?? "Failed to verify OTP";
logSafe("[OTPController] OTP verification failed", level: LogLevel.warning, error: error);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: error, message: result['error']!,
type: SnackbarType.error, type: SnackbarType.error,
); );
} }
@ -215,7 +210,8 @@ class OTPController extends GetxController {
final savedEmail = LocalStorage.getToken('otp_email') ?? ''; final savedEmail = LocalStorage.getToken('otp_email') ?? '';
emailController.text = savedEmail; emailController.text = savedEmail;
email.value = savedEmail; email.value = savedEmail;
logSafe("[OTPController] Loaded saved email from local storage: $savedEmail"); logSafe(
"[OTPController] Loaded saved email from local storage: $savedEmail");
} }
} }
} }

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart'; import 'package:on_field_work/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class RegisterAccountController extends MyController { class RegisterAccountController extends MyController {
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart'; import 'package:on_field_work/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class ResetPasswordController extends MyController { class ResetPasswordController extends MyController {
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
@ -49,8 +49,8 @@ class ResetPasswordController extends MyController {
basicValidator.clearErrors(); basicValidator.clearErrors();
} }
logSafe("[ResetPasswordController] Navigating to /home"); logSafe("[ResetPasswordController] Navigating to /dashboard");
Get.toNamed('/home'); Get.toNamed('/dashboard');
update(); update();
} else { } else {
logSafe("[ResetPasswordController] Form validation failed", level: LogLevel.warning); logSafe("[ResetPasswordController] Form validation failed", level: LogLevel.warning);

View File

@ -1,262 +1,370 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:on_field_work/model/dashboard/project_progress_model.dart';
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 { class DashboardController extends GetxController {
// ========================= // Dependencies
// Attendance overview final ProjectController projectController = Get.put(ProjectController());
// =========================
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 // 1. STATE VARIABLES
// ========================= // =========================
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
final RxString projectSelectedRange = '15D'.obs;
final RxBool projectIsChartView = true.obs;
final RxBool isProjectLoading = false.obs;
// ========================= // Attendance
// Projects overview final roleWiseData = <Map<String, dynamic>>[].obs;
// ========================= final attendanceSelectedRange = '15D'.obs;
final RxInt totalProjects = 0.obs; final attendanceIsChartView = true.obs;
final RxInt ongoingProjects = 0.obs; final isAttendanceLoading = false.obs;
final RxBool isProjectsLoading = false.obs;
// ========================= // Project Progress
// Tasks overview final projectChartData = <ChartTaskData>[].obs;
// ========================= final projectSelectedRange = '15D'.obs;
final RxInt totalTasks = 0.obs; final projectIsChartView = true.obs;
final RxInt completedTasks = 0.obs; final isProjectLoading = false.obs;
final RxBool isTasksLoading = false.obs;
// ========================= // Overview Counts
// Teams overview final totalProjects = 0.obs;
// ========================= final ongoingProjects = 0.obs;
final RxInt totalEmployees = 0.obs; final isProjectsLoading = false.obs;
final RxInt inToday = 0.obs;
final RxBool isTeamsLoading = false.obs;
// Common ranges final totalTasks = 0.obs;
final completedTasks = 0.obs;
final isTasksLoading = false.obs;
final totalEmployees = 0.obs;
final inToday = 0.obs;
final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final expenseReportEndDate = DateTime.now().obs;
final isMonthlyExpenseLoading = false.obs;
final monthlyExpenseList = <MonthlyExpenseData>[].obs;
final selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final selectedMonthsCount = 12.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// Teams/Employees
final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs;
// Collection
final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
// Inject ProjectController // =========================
final ProjectController projectController = Get.find<ProjectController>(); // 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 @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info);
logSafe( // Project Selection Listener
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info,
);
fetchAllDashboardData();
// React to project change
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
fetchAllDashboardData(); if (id.isNotEmpty) {
fetchAllDashboardData();
fetchTodaysAttendance(id);
}
}); });
// React to range changes // Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
}
});
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
} }
// ========================= // =========================
// Helper Methods // 4. USER ACTIONS
// ========================= // =========================
int _getDaysFromRange(String range) {
switch (range) { void updateAttendanceRange(String range) =>
case '7D': attendanceSelectedRange.value = range;
return 7; void updateProjectRange(String range) => projectSelectedRange.value = range;
case '15D': void toggleAttendanceChartView(bool isChart) =>
return 15; attendanceIsChartView.value = isChart;
case '30D': void toggleProjectChartView(bool isChart) =>
return 30; projectIsChartView.value = isChart;
case '3M':
return 90; void updateSelectedExpenseType(ExpenseTypeModel? type) {
case '6M': selectedExpenseType.value = type;
return 180; fetchMonthlyExpenses(categoryId: type?.id);
default: }
return 7;
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
// Efficient Map lookup instead of Switch
const durationMap = {
MonthlyExpenseDuration.oneMonth: 1,
MonthlyExpenseDuration.threeMonths: 3,
MonthlyExpenseDuration.sixMonths: 6,
MonthlyExpenseDuration.twelveMonths: 12,
MonthlyExpenseDuration.all: 0,
};
selectedMonthsCount.value = durationMap[duration] ?? 12;
fetchMonthlyExpenses();
}
Future<void> refreshDashboard() => fetchAllDashboardData();
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async {
final id = projectController.selectedProjectId.value;
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
}
// =========================
// 5. DATA FETCHING (API)
// =========================
/// Wrapper to reduce try-finally boilerplate for loading states
Future<void> _executeApiCall(
RxBool loader, Future<void> Function() apiLogic) async {
loader.value = true;
try {
await apiLogic();
} finally {
loader.value = false;
} }
} }
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
void updateAttendanceRange(String range) {
attendanceSelectedRange.value = range;
logSafe('Attendance range updated to $range', level: LogLevel.debug);
}
void updateProjectRange(String range) {
projectSelectedRange.value = range;
logSafe('Project range updated to $range', level: LogLevel.debug);
}
void toggleAttendanceChartView(bool isChart) {
attendanceIsChartView.value = isChart;
logSafe('Attendance chart view toggled to: $isChart',
level: LogLevel.debug);
}
void toggleProjectChartView(bool isChart) {
projectIsChartView.value = isChart;
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
}
// =========================
// Manual Refresh Methods
// =========================
Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData();
}
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshTasks() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// Fetch All Dashboard Data
// =========================
Future<void> fetchAllDashboardData() async { Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value; final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
return;
}
await Future.wait([ await Future.wait([
fetchRoleWiseAttendance(), fetchRoleWiseAttendance(),
fetchProjectProgress(), fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId), fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId), fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(),
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
),
fetchMonthlyExpenses(),
fetchMasterData(),
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]); ]);
} }
// ========================= Future<void> fetchCollectionOverview() async {
// API Calls final projectId = projectController.selectedProjectId.value;
// =========================
Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
try { await _executeApiCall(isCollectionOverviewLoading, () async {
isAttendanceLoading.value = true; final response =
final List<dynamic>? response = await ApiService.getCollectionOverview(projectId: projectId);
await ApiService.getDashboardAttendanceOverview( collectionOverviewData.value =
projectId, getAttendanceDays()); (response?.success == true) ? response!.data : null;
});
}
Future<void> fetchTodaysAttendance(String projectId) async {
await _executeApiCall(isLoadingEmployees, () async {
final response = await ApiService.getAttendanceForDashboard(projectId);
if (response != null) { if (response != null) {
roleWiseData.value = employees.value = response;
response.map((e) => Map<String, dynamic>.from(e)).toList(); for (var emp in employees) {
logSafe('Attendance overview fetched successfully.', uploadingStates.putIfAbsent(emp.id, () => false.obs);
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); Future<void> fetchMasterData() async {
} finally { try {
isAttendanceLoading.value = false; final data = await ApiService.getMasterExpenseTypes();
} if (data is List) {
expenseTypes.value =
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (_) {}
}
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
await _executeApiCall(isMonthlyExpenseLoading, () async {
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId,
months: selectedMonthsCount.value,
);
monthlyExpenseList.value =
(response?.success == true) ? response!.data : [];
});
}
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 id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isPendingExpensesLoading, () async {
final response = await ApiService.getPendingExpensesApi(projectId: id);
pendingExpensesData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchRoleWiseAttendance() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays());
roleWiseData.value =
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
});
}
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: id,
startDate: startDate,
endDate: endDate,
);
expenseTypeReportData.value =
(response?.success == true) ? response!.data : null;
});
} }
Future<void> fetchProjectProgress() async { Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (projectId.isEmpty) return; if (id.isEmpty) return;
try { await _executeApiCall(isProjectLoading, () async {
isProjectLoading.value = true;
final response = await ApiService.getProjectProgress( final response = await ApiService.getProjectProgress(
projectId: projectId, days: getProjectDays()); projectId: id, days: getProjectDays());
if (response?.success == true) {
if (response != null && response.success) { projectChartData.value = response!.data
projectChartData.value = .map((d) => ChartTaskData.fromProjectData(d))
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList(); .toList();
logSafe('Project progress data mapped for chart', level: LogLevel.info);
} else { } else {
projectChartData.clear(); projectChartData.clear();
logSafe('Failed to fetch project progress', level: LogLevel.error);
} }
} catch (e, st) { });
projectChartData.clear();
logSafe('Error fetching project progress',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isProjectLoading.value = false;
}
} }
Future<void> fetchDashboardTasks({required String projectId}) async { Future<void> fetchDashboardTasks({required String projectId}) async {
if (projectId.isEmpty) return; await _executeApiCall(isTasksLoading, () async {
try {
isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId); final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) {
if (response != null && response.success) { totalTasks.value = response!.data?.totalTasks ?? 0;
totalTasks.value = response.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0; completedTasks.value = response.data?.completedTasks ?? 0;
logSafe('Dashboard tasks fetched', level: LogLevel.info);
} else { } else {
totalTasks.value = 0; totalTasks.value = 0;
completedTasks.value = 0; completedTasks.value = 0;
logSafe('Failed to fetch tasks', level: LogLevel.error);
} }
} catch (e, st) { });
totalTasks.value = 0;
completedTasks.value = 0;
logSafe('Error fetching tasks',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTasksLoading.value = false;
}
} }
Future<void> fetchDashboardTeams({required String projectId}) async { Future<void> fetchDashboardTeams({required String projectId}) async {
if (projectId.isEmpty) return; await _executeApiCall(isTeamsLoading, () async {
try {
isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId); final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) {
if (response != null && response.success) { totalEmployees.value = response!.data?.totalEmployees ?? 0;
totalEmployees.value = response.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0; inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else { } else {
totalEmployees.value = 0; totalEmployees.value = 0;
inToday.value = 0; inToday.value = 0;
logSafe('Failed to fetch teams', level: LogLevel.error);
} }
} catch (e, st) { });
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Error fetching teams',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTeamsLoading.value = false;
}
} }
} }
enum MonthlyExpenseDuration {
oneMonth,
threeMonths,
sixMonths,
twelveMonths,
all,
}

View File

@ -1,9 +1,9 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:on_field_work/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:on_field_work/controller/directory/notes_controller.dart';
class AddCommentController extends GetxController { class AddCommentController extends GetxController {
final String contactId; final String contactId;

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
class AddContactController extends GetxController { class AddContactController extends GetxController {
final RxList<String> categories = <String>[].obs; final RxList<String> categories = <String>[].obs;
@ -10,7 +10,7 @@ class AddContactController extends GetxController {
final RxList<String> tags = <String>[].obs; final RxList<String> tags = <String>[].obs;
final RxString selectedCategory = ''.obs; final RxString selectedCategory = ''.obs;
final RxString selectedBucket = ''.obs; final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedProject = ''.obs; final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs; final RxList<String> enteredTags = <String>[].obs;
@ -50,7 +50,7 @@ class AddContactController extends GetxController {
void resetForm() { void resetForm() {
selectedCategory.value = ''; selectedCategory.value = '';
selectedProject.value = ''; selectedProject.value = '';
selectedBucket.value = ''; selectedBuckets.clear();
enteredTags.clear(); enteredTags.clear();
filteredSuggestions.clear(); filteredSuggestions.clear();
filteredOrgSuggestions.clear(); filteredOrgSuggestions.clear();
@ -100,7 +100,21 @@ class AddContactController extends GetxController {
isSubmitting.value = true; isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketIds = selectedBuckets
.map((name) => bucketsMap[name])
.whereType<String>()
.toList();
if (bucketIds.isEmpty) {
showAppSnackbar(
title: "Missing Buckets",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
final projectIds = selectedProjects final projectIds = selectedProjects
.map((name) => projectsMap[name]) .map((name) => projectsMap[name])
.whereType<String>() .whereType<String>()
@ -126,10 +140,10 @@ class AddContactController extends GetxController {
return; return;
} }
if (selectedBucket.value.trim().isEmpty || bucketId == null) { if (selectedBuckets.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Bucket", title: "Missing Bucket",
message: "Please select a bucket.", message: "Please select at least one bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false; isSubmitting.value = false;
@ -151,7 +165,7 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null) if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds, if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId], "bucketIds": bucketIds,
if (enteredTags.isNotEmpty) "tags": tagObjects, if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails, if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones, if (phones.isNotEmpty) "contactPhones": phones,

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
class BucketController extends GetxController { class BucketController extends GetxController {
RxBool isCreating = false.obs; RxBool isCreating = false.obs;

View File

@ -1,12 +1,13 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/directory/contact_model.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart'; import 'package:on_field_work/model/directory/contact_model.dart';
import 'package:marco/model/directory/directory_comment_model.dart'; import 'package:on_field_work/model/directory/contact_bucket_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/model/directory/directory_comment_model.dart';
class DirectoryController extends GetxController { class DirectoryController extends GetxController {
// -------------------- CONTACTS --------------------
RxList<ContactModel> allContacts = <ContactModel>[].obs; RxList<ContactModel> allContacts = <ContactModel>[].obs;
RxList<ContactModel> filteredContacts = <ContactModel>[].obs; RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
RxList<ContactCategory> contactCategories = <ContactCategory>[].obs; RxList<ContactCategory> contactCategories = <ContactCategory>[].obs;
@ -16,16 +17,10 @@ class DirectoryController extends GetxController {
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs; RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
RxString searchQuery = ''.obs; RxString searchQuery = ''.obs;
RxBool showFabMenu = false.obs;
final RxBool showFullEditorToolbar = false.obs;
final RxBool isEditorFocused = false.obs;
RxBool isNotesView = false.obs;
final Map<String, RxList<DirectoryComment>> contactCommentsMap = {};
RxList<DirectoryComment> getCommentsForContact(String contactId) {
return contactCommentsMap[contactId] ?? <DirectoryComment>[].obs;
}
// -------------------- COMMENTS --------------------
final Map<String, RxList<DirectoryComment>> activeCommentsMap = {};
final Map<String, RxList<DirectoryComment>> inactiveCommentsMap = {};
final editingCommentId = Rxn<String>(); final editingCommentId = Rxn<String>();
@override @override
@ -34,26 +29,75 @@ class DirectoryController extends GetxController {
fetchContacts(); fetchContacts();
fetchBuckets(); fetchBuckets();
} }
// inside DirectoryController
// -------------------- COMMENTS HANDLING --------------------
RxList<DirectoryComment> getCommentsForContact(String contactId,
{bool active = true}) {
return active
? activeCommentsMap[contactId] ?? <DirectoryComment>[].obs
: inactiveCommentsMap[contactId] ?? <DirectoryComment>[].obs;
}
Future<void> fetchCommentsForContact(String contactId,
{bool active = true}) async {
try {
final data =
await ApiService.getDirectoryComments(contactId, active: active);
var comments =
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
// Deduplicate by ID before storing
final Map<String, DirectoryComment> uniqueMap = {
for (var c in comments) c.id: c,
};
comments = uniqueMap.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (active) {
activeCommentsMap[contactId] = <DirectoryComment>[].obs
..assignAll(comments);
} else {
inactiveCommentsMap[contactId] = <DirectoryComment>[].obs
..assignAll(comments);
}
} catch (e, stack) {
logSafe("Error fetching ${active ? 'active' : 'inactive'} comments: $e",
level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
if (active) {
activeCommentsMap[contactId] = <DirectoryComment>[].obs;
} else {
inactiveCommentsMap[contactId] = <DirectoryComment>[].obs;
}
}
}
List<DirectoryComment> combinedComments(String contactId) {
final activeList = getCommentsForContact(contactId, active: true);
final inactiveList = getCommentsForContact(contactId, active: false);
// Deduplicate by ID (active wins)
final Map<String, DirectoryComment> byId = {};
for (final c in inactiveList) {
byId[c.id] = c;
}
for (final c in activeList) {
byId[c.id] = c;
}
final combined = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return combined;
}
Future<void> updateComment(DirectoryComment comment) async { Future<void> updateComment(DirectoryComment comment) async {
try { try {
logSafe( final existing = getCommentsForContact(comment.contactId)
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}"); .firstWhereOrNull((c) => c.id == comment.id);
final commentList = contactCommentsMap[comment.contactId]; if (existing != null && existing.note.trim() == comment.note.trim()) {
final oldComment =
commentList?.firstWhereOrNull((c) => c.id == comment.id);
if (oldComment == null) {
logSafe("Old comment not found. id: ${comment.id}");
} else {
logSafe("Old comment note: ${oldComment.note}");
logSafe("New comment note: ${comment.note}");
}
if (oldComment != null && oldComment.note.trim() == comment.note.trim()) {
logSafe("No changes detected in comment. id: ${comment.id}");
showAppSnackbar( showAppSnackbar(
title: "No Changes", title: "No Changes",
message: "No changes were made to the comment.", message: "No changes were made to the comment.",
@ -63,32 +107,26 @@ class DirectoryController extends GetxController {
} }
final success = await ApiService.updateContactComment( final success = await ApiService.updateContactComment(
comment.id, comment.id, comment.note, comment.contactId);
comment.note,
comment.contactId,
);
if (success) { if (success) {
logSafe("Comment updated successfully. id: ${comment.id}"); await fetchCommentsForContact(comment.contactId, active: true);
await fetchCommentsForContact(comment.contactId); await fetchCommentsForContact(comment.contactId, active: false);
// Show success message
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "Comment updated successfully.", message: "Comment updated successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to update comment via API. id: ${comment.id}");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to update comment.", message: "Failed to update comment.",
type: SnackbarType.error, type: SnackbarType.error,
); );
} }
} catch (e, stackTrace) { } catch (e, stack) {
logSafe("Update comment failed: ${e.toString()}"); logSafe("Update comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: ${stackTrace.toString()}"); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to update comment.", message: "Failed to update comment.",
@ -97,53 +135,20 @@ class DirectoryController extends GetxController {
} }
} }
Future<void> fetchCommentsForContact(String contactId,
{bool active = true}) async {
try {
final data =
await ApiService.getDirectoryComments(contactId, active: active);
logSafe(
"Fetched ${active ? 'active' : 'inactive'} comments for contact $contactId: $data");
final comments =
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
if (!contactCommentsMap.containsKey(contactId)) {
contactCommentsMap[contactId] = <DirectoryComment>[].obs;
}
contactCommentsMap[contactId]!.assignAll(comments);
contactCommentsMap[contactId]?.refresh();
} catch (e) {
logSafe(
"Error fetching ${active ? 'active' : 'inactive'} comments for contact $contactId: $e",
level: LogLevel.error);
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
contactCommentsMap[contactId]!.clear();
}
}
/// 🗑 Delete a comment (soft delete)
Future<void> deleteComment(String commentId, String contactId) async { Future<void> deleteComment(String commentId, String contactId) async {
try { try {
logSafe("Deleting comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, false); final success = await ApiService.restoreContactComment(commentId, false);
if (success) { if (success) {
logSafe("Comment deleted successfully. id: $commentId"); if (editingCommentId.value == commentId) editingCommentId.value = null;
await fetchCommentsForContact(contactId, active: true);
// Refresh comments after deletion await fetchCommentsForContact(contactId, active: false);
await fetchCommentsForContact(contactId);
showAppSnackbar( showAppSnackbar(
title: "Deleted", title: "Deleted",
message: "Comment deleted successfully.", message: "Comment deleted successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to delete comment via API. id: $commentId");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to delete comment.", message: "Failed to delete comment.",
@ -151,8 +156,8 @@ class DirectoryController extends GetxController {
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe("Delete comment failed: ${e.toString()}", level: LogLevel.error); logSafe("Delete comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Something went wrong while deleting comment.", message: "Something went wrong while deleting comment.",
@ -161,26 +166,19 @@ class DirectoryController extends GetxController {
} }
} }
/// Restore a previously deleted comment
Future<void> restoreComment(String commentId, String contactId) async { Future<void> restoreComment(String commentId, String contactId) async {
try { try {
logSafe("Restoring comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, true); final success = await ApiService.restoreContactComment(commentId, true);
if (success) { if (success) {
logSafe("Comment restored successfully. id: $commentId"); await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
// Refresh comments after restore
await fetchCommentsForContact(contactId);
showAppSnackbar( showAppSnackbar(
title: "Restored", title: "Restored",
message: "Comment restored successfully.", message: "Comment restored successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to restore comment via API. id: $commentId");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to restore comment.", message: "Failed to restore comment.",
@ -188,8 +186,8 @@ class DirectoryController extends GetxController {
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe("Restore comment failed: ${e.toString()}", level: LogLevel.error); logSafe("Restore comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Something went wrong while restoring comment.", message: "Something went wrong while restoring comment.",
@ -198,6 +196,8 @@ class DirectoryController extends GetxController {
} }
} }
// -------------------- CONTACTS HANDLING --------------------
Future<void> fetchBuckets() async { Future<void> fetchBuckets() async {
try { try {
final response = await ApiService.getContactBucketList(); final response = await ApiService.getContactBucketList();
@ -213,11 +213,71 @@ class DirectoryController extends GetxController {
logSafe("Bucket fetch error: $e", level: LogLevel.error); logSafe("Bucket fetch error: $e", level: LogLevel.error);
} }
} }
// -------------------- CONTACT DELETION / RESTORE --------------------
Future<void> deleteContact(String contactId) async {
try {
final success = await ApiService.deleteDirectoryContact(contactId);
if (success) {
// Refresh contacts after deletion
await fetchContacts(active: true);
await fetchContacts(active: false);
showAppSnackbar(
title: "Deleted",
message: "Contact deleted successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to delete contact.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Delete contact failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting contact.",
type: SnackbarType.error,
);
}
}
Future<void> restoreContact(String contactId) async {
try {
final success = await ApiService.restoreDirectoryContact(contactId);
if (success) {
// Refresh contacts after restore
await fetchContacts(active: true);
await fetchContacts(active: false);
showAppSnackbar(
title: "Restored",
message: "Contact restored successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore contact.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Restore contact failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while restoring contact.",
type: SnackbarType.error,
);
}
}
Future<void> fetchContacts({bool active = true}) async { Future<void> fetchContacts({bool active = true}) async {
try { try {
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getDirectoryData(isActive: active); final response = await ApiService.getDirectoryData(isActive: active);
if (response != null) { if (response != null) {
@ -238,14 +298,12 @@ class DirectoryController extends GetxController {
void extractCategoriesFromContacts() { void extractCategoriesFromContacts() {
final uniqueCategories = <String, ContactCategory>{}; final uniqueCategories = <String, ContactCategory>{};
for (final contact in allContacts) { for (final contact in allContacts) {
final category = contact.contactCategory; final category = contact.contactCategory;
if (category != null && !uniqueCategories.containsKey(category.id)) { if (category != null) {
uniqueCategories[category.id] = category; uniqueCategories.putIfAbsent(category.id, () => category);
} }
} }
contactCategories.value = uniqueCategories.values.toList(); contactCategories.value = uniqueCategories.values.toList();
} }
@ -270,6 +328,7 @@ class DirectoryController extends GetxController {
contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
final categoryNameMatch = final categoryNameMatch =
contact.contactCategory?.name.toLowerCase().contains(query) ?? false; contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
final bucketNameMatch = contact.bucketIds.any((id) { final bucketNameMatch = contact.bucketIds.any((id) {
final bucketName = contactBuckets final bucketName = contactBuckets
.firstWhereOrNull((b) => b.id == id) .firstWhereOrNull((b) => b.id == id)
@ -291,7 +350,6 @@ class DirectoryController extends GetxController {
return categoryMatch && bucketMatch && searchMatch; return categoryMatch && bucketMatch && searchMatch;
}).toList(); }).toList();
// 🔑 Ensure results are always alphabetically sorted
filteredContacts filteredContacts
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
} }

View File

@ -1,9 +1,9 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:on_field_work/controller/directory/directory_controller.dart';
class ManageBucketController extends GetxController { class ManageBucketController extends GetxController {
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;

View File

@ -1,8 +1,8 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/directory/note_list_response_model.dart'; import 'package:on_field_work/model/directory/note_list_response_model.dart';
class NotesController extends GetxController { class NotesController extends GetxController {
RxList<NoteModel> notesList = <NoteModel>[].obs; RxList<NoteModel> notesList = <NoteModel>[].obs;

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/document/document_details_model.dart'; import 'package:on_field_work/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart'; import 'package:on_field_work/model/document/document_version_model.dart';
class DocumentDetailsController extends GetxController { class DocumentDetailsController extends GetxController {
/// Observables /// Observables

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/document/master_document_type_model.dart'; import 'package:on_field_work/model/document/master_document_type_model.dart';
import 'package:marco/model/document/master_document_tags.dart'; import 'package:on_field_work/model/document/master_document_tags.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
class DocumentUploadController extends GetxController { class DocumentUploadController extends GetxController {
// Observables // Observables

View File

@ -1,58 +1,67 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart'; import 'package:on_field_work/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart'; import 'package:on_field_work/model/document/documents_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
class DocumentController extends GetxController { class DocumentController extends GetxController {
// ------------------ Observables --------------------- // ==================== Observables ====================
var isLoading = false.obs; final isLoading = false.obs;
var documents = <DocumentItem>[].obs; final documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>(); final filters = Rxn<DocumentFiltersData>();
// Selected filters (multi-select support) // Selected filters (multi-select)
var selectedUploadedBy = <String>[].obs; final selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs; final selectedCategory = <String>[].obs;
var selectedType = <String>[].obs; final selectedType = <String>[].obs;
var selectedTag = <String>[].obs; final selectedTag = <String>[].obs;
// Pagination state // Pagination
var pageNumber = 1.obs; final pageNumber = 1.obs;
final int pageSize = 20; final pageSize = 20;
var hasMore = true.obs; final hasMore = true.obs;
// Error message // Error handling
var errorMessage = "".obs; final errorMessage = ''.obs;
// NEW: show inactive toggle // Preferences
var showInactive = false.obs; final showInactive = false.obs;
// NEW: search // Search
var searchQuery = ''.obs; final searchQuery = ''.obs;
var searchController = TextEditingController(); final searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls ----------------------- // Additional filters
final isUploadedAt = true.obs;
final isVerified = RxnBool();
final startDate = Rxn<DateTime>();
final endDate = Rxn<DateTime>();
/// Fetch Document Filters for an Entity // ==================== Lifecycle ====================
@override
void onClose() {
// Don't dispose searchController here - it's managed by the page
super.onClose();
}
// ==================== API Methods ====================
/// Fetch document filters for entity
Future<void> fetchFilters(String entityTypeId) async { Future<void> fetchFilters(String entityTypeId) async {
try { try {
isLoading.value = true;
final response = await ApiService.getDocumentFilters(entityTypeId); final response = await ApiService.getDocumentFilters(entityTypeId);
if (response != null && response.success) { if (response != null && response.success) {
filters.value = response.data; filters.value = response.data;
} else { } else {
errorMessage.value = response?.message ?? "Failed to fetch filters"; errorMessage.value = response?.message ?? 'Failed to fetch filters';
_showError('Failed to load filters');
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching filters: $e"; errorMessage.value = 'Error fetching filters: $e';
} finally { _showError('Error loading filters');
isLoading.value = false; debugPrint('❌ Error fetching filters: $e');
} }
} }
@ -65,53 +74,44 @@ class DocumentController extends GetxController {
}) async { }) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive); final success = await ApiService.deleteDocumentApi(
id: id,
isActive: isActive,
);
if (success) { if (success) {
// 🔥 Always fetch fresh list after toggle // Refresh list after state change
await fetchDocuments( await fetchDocuments(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
reset: true, reset: true,
); );
// Show success snackbar
showAppSnackbar(
title: 'Success',
message: isActive ? 'Document deactivated' : 'Document activated',
type: SnackbarType.success,
);
return true; return true;
} else { } else {
errorMessage.value = "Failed to update document state"; errorMessage.value = 'Failed to update document state';
_showError('Failed to update document state');
return false; return false;
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error updating document: $e"; errorMessage.value = 'Error updating document: $e';
_showError('Error updating document: $e');
debugPrint('❌ Error toggling document state: $e');
return false; return false;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Permanently delete a document (or deactivate depending on API) /// Fetch documents for entity with pagination
Future<bool> deleteDocument(String id, {bool isActive = false}) async {
try {
isLoading.value = true;
final success =
await ApiService.deleteDocumentApi(id: id, isActive: isActive);
if (success) {
// remove from local list immediately for better UX
documents.removeWhere((doc) => doc.id == id);
return true;
} else {
errorMessage.value = "Failed to delete document";
return false;
}
} catch (e) {
errorMessage.value = "Error deleting document: $e";
return false;
} finally {
isLoading.value = false;
}
}
/// Fetch Documents for an entity
Future<void> fetchDocuments({ Future<void> fetchDocuments({
required String entityTypeId, required String entityTypeId,
required String entityId, required String entityId,
@ -126,14 +126,15 @@ class DocumentController extends GetxController {
hasMore.value = true; hasMore.value = true;
} }
if (!hasMore.value) return; if (!hasMore.value && !reset) return;
if (isLoading.value) return;
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getDocumentListApi( final response = await ApiService.getDocumentListApi(
entityTypeId: entityTypeId, entityTypeId: entityTypeId,
entityId: entityId, entityId: entityId,
filter: filter ?? "", filter: filter ?? '',
searchString: searchString ?? searchQuery.value, searchString: searchString ?? searchQuery.value,
pageNumber: pageNumber.value, pageNumber: pageNumber.value,
pageSize: pageSize, pageSize: pageSize,
@ -141,25 +142,45 @@ class DocumentController extends GetxController {
); );
if (response != null && response.success) { if (response != null && response.success) {
if (response.data.data.isNotEmpty) { if (response.data?.data.isNotEmpty ?? false) {
documents.addAll(response.data.data); documents.addAll(response.data!.data);
pageNumber.value++; pageNumber.value++;
} else { } else {
hasMore.value = false; hasMore.value = false;
} }
errorMessage.value = '';
} else { } else {
errorMessage.value = response?.message ?? "Failed to fetch documents"; errorMessage.value = response?.message ?? 'Failed to fetch documents';
if (documents.isEmpty) {
_showError('Failed to load documents');
} else {
showAppSnackbar(
title: 'Warning',
message: 'No more documents to load',
type: SnackbarType.warning,
);
}
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching documents: $e"; errorMessage.value = 'Error fetching documents: $e';
if (documents.isEmpty) {
_showError('Error loading documents');
} else {
showAppSnackbar(
title: 'Error',
message: 'Error fetching additional documents',
type: SnackbarType.error,
);
}
debugPrint('❌ Error fetching documents: $e');
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
// ------------------ Helpers ----------------------- // ==================== Helper Methods ====================
/// Clear selected filters /// Clear all selected filters
void clearFilters() { void clearFilters() {
selectedUploadedBy.clear(); selectedUploadedBy.clear();
selectedCategory.clear(); selectedCategory.clear();
@ -171,11 +192,35 @@ class DocumentController extends GetxController {
endDate.value = null; endDate.value = null;
} }
/// Check if any filters are active (for red dot indicator) /// Check if any filters are active
bool hasActiveFilters() { bool hasActiveFilters() {
return selectedUploadedBy.isNotEmpty || return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty || selectedCategory.isNotEmpty ||
selectedType.isNotEmpty || selectedType.isNotEmpty ||
selectedTag.isNotEmpty; selectedTag.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
isVerified.value != null;
}
/// Show error message via snackbar
void _showError(String message) {
showAppSnackbar(
title: 'Error',
message: message,
type: SnackbarType.error,
);
}
/// Reset controller state
void reset() {
documents.clear();
clearFilters();
searchController.clear();
searchQuery.value = '';
pageNumber.value = 1;
hasMore.value = true;
showInactive.value = false;
errorMessage.value = '';
} }
} }

View File

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart'; import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class DynamicMenuController extends GetxController { class DynamicMenuController extends GetxController {
// UI reactive states // UI reactive states

View File

@ -1,11 +1,11 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';

View File

@ -1,10 +1,10 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/global_project_model.dart'; import 'package:on_field_work/model/global_project_model.dart';
import 'package:marco/model/employees/assigned_projects_model.dart'; import 'package:on_field_work/model/employees/assigned_projects_model.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
class AssignProjectController extends GetxController { class AssignProjectController extends GetxController {
final String employeeId; final String employeeId;

View File

@ -1,91 +1,60 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/attendance/attendance_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:on_field_work/model/employees/employee_details_model.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/employees/employee_details_model.dart';
import 'package:marco/controller/project_controller.dart';
class EmployeesScreenController extends GetxController { class EmployeesScreenController extends GetxController {
List<AttendanceModel> attendances = []; /// Data lists
List<ProjectModel> projects = [];
String? selectedProjectId;
List<EmployeeDetailsModel> employeeDetails = [];
RxBool isAllEmployeeSelected = false.obs;
RxList<EmployeeModel> employees = <EmployeeModel>[].obs; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
Rxn<EmployeeDetailsModel> selectedEmployeeDetails = Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
Rxn<EmployeeDetailsModel>(); Rxn<EmployeeDetailsModel>();
/// Loading states
RxBool isLoading = false.obs;
RxBool isLoadingEmployeeDetails = false.obs; RxBool isLoadingEmployeeDetails = false.obs;
/// Selection state
RxBool isAllEmployeeSelected = false.obs;
RxSet<String> selectedEmployeeIds = <String>{}.obs;
/// Upload state tracking (if needed later)
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployeeSecondaryManagers =
<EmployeeModel>[].obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
isLoading.value = true; fetchAllEmployees();
fetchAllProjects().then((_) {
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
selectedProjectId = projectId;
fetchEmployeesByProject(projectId);
} else if (isAllEmployeeSelected.value) {
fetchAllEmployees();
} else {
clearEmployees();
}
});
}
Future<void> fetchAllProjects() async {
isLoading.value = true;
await _handleApiCall(
ApiService.getProjects,
onSuccess: (data) {
projects = data.map((json) => ProjectModel.fromJson(json)).toList();
logSafe(
"Projects fetched: ${projects.length} projects loaded.",
level: LogLevel.info,
);
},
onEmpty: () {
logSafe("No project data found or API call failed.",
level: LogLevel.warning);
},
);
isLoading.value = false;
update();
}
void clearEmployees() {
employees.clear();
logSafe("Employees cleared", level: LogLevel.info);
update(['employee_screen_controller']);
} }
/// 🔹 Fetch all employees (no project filter)
Future<void> fetchAllEmployees({String? organizationId}) async { Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true; isLoading.value = true;
update(['employee_screen_controller']); update(['employee_screen_controller']);
await _handleApiCall( await _handleApiCall(
() => ApiService.getAllEmployees( () => ApiService.getAllEmployees(organizationId: organizationId),
organizationId: organizationId), // pass orgId to API
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
logSafe( logSafe(
"All Employees fetched: ${employees.length} employees loaded.", "All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info, level: LogLevel.info,
); );
// Reset selection states when new data arrives
selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false;
}, },
onEmpty: () { onEmpty: () {
employees.clear(); employees.clear();
logSafe( selectedEmployeeIds.clear();
"No Employee data found or API call failed", isAllEmployeeSelected.value = false;
level: LogLevel.warning, logSafe("No Employee data found or API call failed",
); level: LogLevel.warning);
}, },
); );
@ -93,28 +62,7 @@ class EmployeesScreenController extends GetxController {
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
Future<void> fetchEmployeesByProject(String projectId, /// 🔹 Fetch details for a specific employee
{String? organizationId}) async {
if (projectId.isEmpty) return;
isLoading.value = true;
await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId,
organizationId: organizationId),
onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
},
onEmpty: () => employees.clear(),
);
isLoading.value = false;
update(['employee_screen_controller']);
}
Future<void> fetchEmployeeDetails(String? employeeId) async { Future<void> fetchEmployeeDetails(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return; if (employeeId == null || employeeId.isEmpty) return;
@ -124,31 +72,80 @@ class EmployeesScreenController extends GetxController {
() => ApiService.getEmployeeDetails(employeeId), () => ApiService.getEmployeeDetails(employeeId),
onSuccess: (data) { onSuccess: (data) {
selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data); selectedEmployeeDetails.value = EmployeeDetailsModel.fromJson(data);
logSafe( logSafe("Employee details loaded for $employeeId",
"Employee details loaded for $employeeId", level: LogLevel.info);
level: LogLevel.info,
);
}, },
onEmpty: () { onEmpty: () {
selectedEmployeeDetails.value = null; selectedEmployeeDetails.value = null;
logSafe( logSafe("No employee details found for $employeeId",
"No employee details found for $employeeId", level: LogLevel.warning);
level: LogLevel.warning,
);
}, },
onError: (e) { onError: (e) {
selectedEmployeeDetails.value = null; selectedEmployeeDetails.value = null;
logSafe( logSafe("Error fetching employee details for $employeeId",
"Error fetching employee details for $employeeId", level: LogLevel.error, error: e);
level: LogLevel.error,
error: e,
);
}, },
); );
isLoadingEmployeeDetails.value = false; isLoadingEmployeeDetails.value = false;
} }
/// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId
Future<void> fetchReportingManagers(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return;
try {
// Always clear before new fetch (to avoid mixing old data)
selectedEmployeePrimaryManagers.clear();
selectedEmployeeSecondaryManagers.clear();
// Fetch from existing API helper
final data = await ApiService.getOrganizationHierarchyList(employeeId);
if (data == null || data.isEmpty) {
update(['employee_screen_controller']);
return;
}
for (final item in data) {
try {
final reportTo = item['reportTo'];
if (reportTo == null) continue;
final emp = EmployeeModel.fromJson(reportTo);
final isPrimary = item['isPrimary'] == true;
if (isPrimary) {
if (!selectedEmployeePrimaryManagers.any((e) => e.id == emp.id)) {
selectedEmployeePrimaryManagers.add(emp);
}
} else {
if (!selectedEmployeeSecondaryManagers.any((e) => e.id == emp.id)) {
selectedEmployeeSecondaryManagers.add(emp);
}
}
} catch (_) {
// ignore malformed items
}
}
update(['employee_screen_controller']);
} catch (e) {
logSafe("Error fetching reporting managers for $employeeId",
level: LogLevel.error, error: e);
}
}
/// 🔹 Clear all employee data
void clearEmployees() {
employees.clear();
selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false;
logSafe("Employees cleared", level: LogLevel.info);
update(['employee_screen_controller']);
}
/// 🔹 Generic handler for list API responses
Future<void> _handleApiCall( Future<void> _handleApiCall(
Future<List<dynamic>?> Function() apiCall, { Future<List<dynamic>?> Function() apiCall, {
required Function(List<dynamic>) onSuccess, required Function(List<dynamic>) onSuccess,
@ -171,6 +168,7 @@ class EmployeesScreenController extends GetxController {
} }
} }
/// 🔹 Generic handler for single-object API responses
Future<void> _handleSingleApiCall( Future<void> _handleSingleApiCall(
Future<Map<String, dynamic>?> Function() apiCall, { Future<Map<String, dynamic>?> Function() apiCall, {
required Function(Map<String, dynamic>) onSuccess, required Function(Map<String, dynamic>) onSuccess,

View File

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class ComingSoonController extends MyController { class ComingSoonController extends MyController {
Timer? countdownTimer; Timer? countdownTimer;

View File

@ -1,5 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class Error404Controller extends MyController { class Error404Controller extends MyController {
void goToDashboardScreen() { void goToDashboardScreen() {

View File

@ -1,5 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class Error500Controller extends MyController { class Error500Controller extends MyController {
void goToDashboardScreen() { void goToDashboardScreen() {

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -10,13 +11,14 @@ import 'package:intl/intl.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:on_field_work/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
class AddExpenseController extends GetxController { class AddExpenseController extends GetxController {
// --- Text Controllers --- // --- Text Controllers ---
@ -49,10 +51,22 @@ class AddExpenseController extends GetxController {
final isEditMode = false.obs; final isEditMode = false.obs;
final isSearchingEmployees = false.obs; final isSearchingEmployees = false.obs;
// --- Paid By (Single + Multi Selection Support) ---
// single selection
final selectedPaidBy = Rxn<EmployeeModel>();
// helper setters
void setSelectedPaidBy(EmployeeModel? emp) {
selectedPaidBy.value = emp;
}
// --- Dropdown Selections & Data --- // --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>(); final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>(); final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>(); // final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs; final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>(); final selectedTransactionDate = Rxn<DateTime>();
@ -65,6 +79,7 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs; final employeeSearchResults = <EmployeeModel>[].obs;
final isProcessingAttachment = false.obs;
String? editingExpenseId; String? editingExpenseId;
@ -194,7 +209,7 @@ class AddExpenseController extends GetxController {
'Location: ${locationController.text}', 'Location: ${locationController.text}',
'Transaction Date: ${transactionDateController.text}', 'Transaction Date: ${transactionDateController.text}',
'No. of Persons: ${noOfPersonsController.text}', 'No. of Persons: ${noOfPersonsController.text}',
'Expense Type: ${selectedExpenseType.value?.name}', 'Expense Category: ${selectedExpenseType.value?.name}',
'Payment Mode: ${selectedPaymentMode.value?.name}', 'Payment Mode: ${selectedPaymentMode.value?.name}',
'Paid By: ${selectedPaidBy.value?.name}', 'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}', 'Attachments: ${attachments.length}',
@ -252,9 +267,22 @@ class AddExpenseController extends GetxController {
Future<void> pickFromCamera() async { Future<void> pickFromCamera() async {
try { try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera); final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path)); if (pickedFile != null) {
isProcessingAttachment.value = true; // start loading
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh(); // refresh UI
}
} catch (e) { } catch (e) {
_errorSnackbar("Camera error: $e"); _errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
} }
} }
@ -379,47 +407,86 @@ class AddExpenseController extends GetxController {
} }
} }
Future<bool> _submitToApi(Map<String, dynamic> payload) async { Future<bool> _submitToApi(Map<String, dynamic>? payload) async {
if (isEditMode.value && editingExpenseId != null) { if (payload == null) {
return ApiService.editExpenseApi( _errorSnackbar("Payload is empty. Cannot submit.");
expenseId: editingExpenseId!, return false;
payload: payload, }
);
try {
if (isEditMode.value && editingExpenseId != null) {
// Edit existing expense
return await ApiService.editExpenseApi(
expenseId: editingExpenseId!,
payload: payload,
);
} else {
// Create new expense
return await ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expenseCategoryId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
}
} catch (e) {
_errorSnackbar("Failed to submit expense: $e");
return false;
} }
return ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expensesTypeId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
} }
Future<Map<String, dynamic>> _buildExpensePayload() async { Future<Map<String, dynamic>?> _buildExpensePayload() async {
final now = DateTime.now(); final now = DateTime.now();
// --- Get IDs safely ---
final projectId = projectsMap[selectedProject.value];
final expenseType = selectedExpenseType.value;
final paymentMode = selectedPaymentMode.value;
final paidBy = selectedPaidBy.value;
// --- Validate essential fields ---
if (projectId == null) {
_errorSnackbar("Project not selected or invalid");
return null;
}
if (expenseType == null) {
_errorSnackbar("Expense Category not selected");
return null;
}
if (paymentMode == null) {
_errorSnackbar("Payment mode not selected");
return null;
}
if (paidBy == null) {
_errorSnackbar("Paid By not selected");
return null;
}
// --- Process existing attachments (for edit mode) ---
final existingPayload = isEditMode.value final existingPayload = isEditMode.value
? existingAttachments ? existingAttachments
.map((e) => { .map((e) => {
"documentId": e['documentId'], "documentId": e['documentId'],
"fileName": e['fileName'], "fileName": e['fileName'] ?? "",
"contentType": e['contentType'], "contentType": e['contentType'] ?? "",
"fileSize": 0, "fileSize": 0,
"description": "", "description": "",
"url": e['url'], "url": e['url'] ?? "",
"isActive": e['isActive'] ?? true, "isActive": e['isActive'] ?? true,
"base64Data": "", "base64Data": "",
}) })
.toList() .toList()
: <Map<String, dynamic>>[]; : <Map<String, dynamic>>[];
// --- Process new attachments ---
final newPayload = await Future.wait( final newPayload = await Future.wait(
attachments.map((file) async { attachments.map((file) async {
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
@ -434,38 +501,36 @@ class AddExpenseController extends GetxController {
}), }),
); );
final type = selectedExpenseType.value!; // --- Build final payload ---
final payload = {
return {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!, "projectId": projectId,
"expensesTypeId": type.id, "expenseCategoryId": expenseType.id,
"paymentModeId": selectedPaymentMode.value!.id, "paymentModeId": paymentMode.id,
"paidById": selectedPaidBy.value!.id, "paidById": paidBy.id,
"transactionDate": "transactionDate":
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(), (selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
"transactionId": transactionIdController.text, "transactionId": transactionIdController.text.trim(),
"description": descriptionController.text, "description": descriptionController.text.trim(),
"location": locationController.text, "location": locationController.text.trim(),
"supplerName": supplierController.text, "supplerName": supplierController.text.trim(),
"amount": double.parse(amountController.text.trim()), "amount": double.tryParse(amountController.text.trim()) ?? 0,
"noOfPersons": type.noOfPersonsRequired == true "noOfPersons": expenseType.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0 ? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0, : 0,
"billAttachments": [ "billAttachments": [...existingPayload, ...newPayload].isEmpty
...existingPayload,
...newPayload,
].isEmpty
? null ? null
: [...existingPayload, ...newPayload], : [...existingPayload, ...newPayload],
}; };
return payload;
} }
String validateForm() { String validateForm() {
final missing = <String>[]; final missing = <String>[];
if (selectedProject.value.isEmpty) missing.add("Project"); if (selectedProject.value.isEmpty) missing.add("Project");
if (selectedExpenseType.value == null) missing.add("Expense Type"); if (selectedExpenseType.value == null) missing.add("Expense Category");
if (selectedPaymentMode.value == null) missing.add("Payment Mode"); if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By"); if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount"); if (amountController.text.trim().isEmpty) missing.add("Amount");

View File

@ -1,8 +1,8 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController { class ExpenseDetailController extends GetxController {
@ -142,6 +142,10 @@ class ExpenseDetailController extends GetxController {
required String reimburseDate, required String reimburseDate,
required String reimburseById, required String reimburseById,
required String statusId, required String statusId,
double? baseAmount,
double? taxAmount,
double? tdsPercent,
double? netPayable,
}) async { }) async {
final success = await _apiCallWrapper( final success = await _apiCallWrapper(
() => ApiService.updateExpenseStatusApi( () => ApiService.updateExpenseStatusApi(
@ -151,13 +155,16 @@ class ExpenseDetailController extends GetxController {
reimburseTransactionId: reimburseTransactionId, reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate, reimburseDate: reimburseDate,
reimbursedById: reimburseById, reimbursedById: reimburseById,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercent: tdsPercent,
netPayable: netPayable,
), ),
"submit reimbursement", "submit reimbursement",
); );
if (success == true) { if (success == true) {
// Explicitly check for true as _apiCallWrapper returns T? await fetchExpenseDetails();
await fetchExpenseDetails(); // Refresh details after successful update
return true; return true;
} else { } else {
errorMessage.value = "Failed to submit reimbursement."; errorMessage.value = "Failed to submit reimbursement.";

View File

@ -1,13 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_list_model.dart'; import 'package:on_field_work/model/expense/expense_list_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart'; import 'package:on_field_work/model/expense/expense_status_model.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ExpenseController extends GetxController { class ExpenseController extends GetxController {
@ -213,7 +213,7 @@ class ExpenseController extends GetxController {
selectedCreatedByEmployees.clear(); selectedCreatedByEmployees.clear();
} }
/// Fetch master data: expense types, payment modes, and expense status /// Fetch master data: Expense Categorys, payment modes, and expense status
Future<void> fetchMasterData() async { Future<void> fetchMasterData() async {
try { try {
final expenseTypesData = await ApiService.getMasterExpenseTypes(); final expenseTypesData = await ApiService.getMasterExpenseTypes();

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart'; import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
class FaqsController extends MyController { class FaqsController extends MyController {
final List<bool> dataExpansionPanel = [true, false, false, false, false, false]; final List<bool> dataExpansionPanel = [true, false, false, false, false, false];

View File

@ -1,4 +1,4 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class PricingController extends MyController { class PricingController extends MyController {
bool isMonth = false; bool isMonth = false;

View File

@ -0,0 +1,419 @@
// payment_request_controller.dart
import 'dart:io';
import 'dart:convert';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:intl/intl.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_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
final isLoadingPayees = false.obs;
final isLoadingCategories = false.obs;
final isLoadingCurrencies = false.obs;
final isProcessingAttachment = false.obs;
final isSubmitting = false.obs;
// Data Lists
final payees = <String>[].obs;
final categories = <ExpenseCategory>[].obs;
final currencies = <Currency>[].obs;
final globalProjects = <Map<String, dynamic>>[].obs;
// Selected Values
final selectedProject = Rx<Map<String, dynamic>?>(null);
final selectedCategory = Rx<ExpenseCategory?>(null);
final selectedPayee = Rx<EmployeeModel?>(null);
final selectedCurrency = Rx<Currency?>(null);
final isAdvancePayment = false.obs;
final selectedDueDate = Rx<DateTime?>(null);
// Text Controllers
final titleController = TextEditingController();
final dueDateController = TextEditingController();
final amountController = TextEditingController();
final descriptionController = TextEditingController();
final removedAttachments = <Map<String, dynamic>>[].obs;
// Attachments
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
fetchAllMasterData();
fetchGlobalProjects();
}
@override
void onClose() {
titleController.dispose();
dueDateController.dispose();
amountController.dispose();
descriptionController.dispose();
super.onClose();
}
/// Fetch all master data concurrently
Future<void> fetchAllMasterData() async {
await Future.wait([
_fetchData(
payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees),
_fetchData(categories, ApiService.getMasterExpenseCategoriesApi,
isLoadingCategories),
_fetchData(
currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies),
]);
}
/// Generic fetch handler
Future<void> _fetchData<T>(
RxList<T> list, Future<dynamic> Function() apiCall, RxBool loader) async {
try {
loader.value = true;
final response = await apiCall();
if (response != null && response.data.isNotEmpty) {
list.value = response.data;
} else {
list.clear();
}
} catch (e) {
logSafe("Error fetching data: $e", level: LogLevel.error);
list.clear();
} finally {
loader.value = false;
}
}
/// Fetch projects
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
globalProjects.value = (response ?? [])
.map<Map<String, dynamic>>((e) => {
'id': e['id']?.toString() ?? '',
'name': e['name']?.toString().trim() ?? '',
})
.where((p) => p['id']!.isNotEmpty && p['name']!.isNotEmpty)
.toList();
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
globalProjects.clear();
}
}
/// Pick due date
Future<void> pickDueDate(BuildContext context) async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDueDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime(DateTime.now().year + 5),
);
if (pickedDate != null) {
selectedDueDate.value = pickedDate;
dueDateController.text = DateFormat('dd MMM yyyy').format(pickedDate);
}
}
/// Generic file picker for multiple sources
Future<void> pickAttachments(
{bool fromGallery = false, bool fromCamera = false}) async {
try {
if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
isProcessingAttachment.value = true;
final timestamped = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path));
attachments.add(timestamped);
}
} else if (fromGallery) {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) attachments.add(File(pickedFile.path));
} else {
final result = await FilePicker.platform
.pickFiles(type: FileType.any, allowMultiple: true);
if (result != null && result.paths.isNotEmpty)
attachments.addAll(result.paths.whereType<String>().map(File.new));
}
attachments.refresh();
} catch (e) {
_errorSnackbar("Attachment error: $e");
} finally {
isProcessingAttachment.value = false;
}
}
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
isProcessingAttachment.value = true;
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh(); // refresh UI
}
} catch (e) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false; // stop loading
}
}
/// Selection handlers
void selectProject(Map<String, dynamic> project) =>
selectedProject.value = project;
void selectCategory(ExpenseCategory category) =>
selectedCategory.value = category;
void selectPayee(EmployeeModel payee) => selectedPayee.value = payee;
void selectCurrency(Currency currency) => selectedCurrency.value = currency;
void addAttachment(File file) => attachments.add(file);
void removeAttachment(File file) {
if (attachments.contains(file)) {
attachments.remove(file);
}
}
void removeExistingAttachment(Map<String, dynamic> existingAttachment) {
final index = existingAttachments.indexWhere(
(e) => e['id'] == existingAttachment['id']); // match by normalized id
if (index != -1) {
// Mark as inactive
existingAttachments[index]['isActive'] = false;
existingAttachments.refresh();
// Add to removedAttachments to inform API
removedAttachments.add({
"documentId": existingAttachment['id'], // ensure API receives id
"isActive": false,
});
// Show snackbar feedback
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
}
}
/// Build attachment payload
Future<List<Map<String, dynamic>>> buildAttachmentPayload() async {
final existingPayload = existingAttachments
.map((e) => {
"documentId": e['id'], // use the normalized id
"fileName": e['fileName'],
"contentType": e['contentType'] ?? 'application/octet-stream',
"fileSize": e['fileSize'] ?? 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
})
.toList();
final newPayload = await Future.wait(attachments.map((file) async {
final bytes = await file.readAsBytes();
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": await file.length(),
"description": "",
};
}));
// Combine active + removed attachments
return [...existingPayload, ...newPayload, ...removedAttachments];
}
/// Submit edited payment request
Future<bool> submitEditedPaymentRequest({required String requestId}) async {
if (isSubmitting.value) return false;
try {
isSubmitting.value = true;
// Validate form
if (!_validateForm()) return false;
// Build attachment payload
final billAttachments = await buildAttachmentPayload();
final payload = {
"id": requestId,
"title": titleController.text.trim(),
"projectId": selectedProject.value?['id'] ?? '',
"expenseCategoryId": selectedCategory.value?.id ?? '',
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(),
"payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) {
return {
"documentId": a['documentId'],
"fileName": a['fileName'],
"base64Data": a['base64Data'] ?? "",
"contentType": a['contentType'],
"fileSize": a['fileSize'],
"description": a['description'] ?? "",
"isActive": a['isActive'] ?? true,
};
}).toList(),
};
logSafe("💡 Submitting Edited Payment Request: ${jsonEncode(payload)}");
final success = await ApiService.editExpensePaymentRequestApi(
id: payload['id'],
title: payload['title'],
projectId: payload['projectId'],
expenseCategoryId: payload['expenseCategoryId'],
amount: payload['amount'],
currencyId: payload['currencyId'],
description: payload['description'],
payee: payload['payee'],
dueDate: payload['dueDate'] ?? '',
isAdvancePayment: payload['isAdvancePayment'],
billAttachments: payload['billAttachments'],
);
logSafe("💡 Edit Payment Request API Response: $success");
if (success == true) {
logSafe("✅ Payment request edited successfully.");
return true;
} else {
return _errorSnackbar("Failed to edit payment request.");
}
} catch (e, st) {
logSafe("💥 Submit Edited Payment Request Error: $e\n$st",
level: LogLevel.error);
return _errorSnackbar("Something went wrong. Please try again later.");
} finally {
isSubmitting.value = false;
}
}
/// Submit payment request (Project API style)
Future<bool> submitPaymentRequest() async {
if (isSubmitting.value) return false;
try {
isSubmitting.value = true;
// Validate form
if (!_validateForm()) return false;
// Build attachment payload
final billAttachments = await buildAttachmentPayload();
final payload = {
"title": titleController.text.trim(),
"projectId": selectedProject.value?['id'] ?? '',
"expenseCategoryId": selectedCategory.value?.id ?? '',
"amount": double.tryParse(amountController.text.trim()) ?? 0,
"currencyId": selectedCurrency.value?.id ?? '',
"description": descriptionController.text.trim(),
"payee": selectedPayee.value?.id ?? "",
"dueDate": selectedDueDate.value?.toIso8601String(),
"isAdvancePayment": isAdvancePayment.value,
"billAttachments": billAttachments.map((a) {
return {
"fileName": a['fileName'],
"fileSize": a['fileSize'],
"contentType": a['contentType'],
};
}).toList(),
};
logSafe("💡 Submitting Payment Request: ${jsonEncode(payload)}");
final success = await ApiService.createExpensePaymentRequestApi(
title: payload['title'],
projectId: payload['projectId'],
expenseCategoryId: payload['expenseCategoryId'],
amount: payload['amount'],
currencyId: payload['currencyId'],
description: payload['description'],
payee: payload['payee'],
dueDate: selectedDueDate.value,
isAdvancePayment: payload['isAdvancePayment'],
billAttachments: billAttachments,
);
logSafe("💡 Payment Request API Response: $success");
if (success == true) {
logSafe("✅ Payment request created successfully.");
return true;
} else {
return _errorSnackbar("Failed to create payment request.");
}
} catch (e, st) {
logSafe("💥 Submit Payment Request Error: $e\n$st",
level: LogLevel.error);
return _errorSnackbar("Something went wrong. Please try again later.");
} finally {
isSubmitting.value = false;
}
}
/// Form validation
bool _validateForm() {
if (selectedProject.value == null ||
selectedProject.value!['id'].toString().isEmpty)
return _errorSnackbar("Please select a project");
if (selectedCategory.value == null)
return _errorSnackbar("Please select a category");
if (selectedPayee.value == null)
return _errorSnackbar("Please select a payee");
if (selectedCurrency.value == null)
return _errorSnackbar("Please select currency");
return true;
}
bool _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
return false;
}
/// Clear form
void clearForm() {
titleController.clear();
dueDateController.clear();
amountController.clear();
descriptionController.clear();
selectedProject.value = null;
selectedCategory.value = null;
selectedPayee.value = null;
selectedCurrency.value = null;
isAdvancePayment.value = false;
attachments.clear();
existingAttachments.clear();
removedAttachments.clear();
}
}

View File

@ -0,0 +1,149 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:on_field_work/model/finance/advance_payment_model.dart';
import 'package:on_field_work/model/finance/get_employee_model.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
class AdvancePaymentController extends GetxController {
/// Advance payments list
var payments = <AdvancePayment>[].obs;
var isLoading = false.obs;
/// Employees for dropdown search
var employees = <Employee>[].obs;
var allEmployees = <Employee>[]; // cache of last API response
var employeesLoading = false.obs;
var searchQuery = ''.obs;
var selectedEmployee = Rxn<Employee>();
/// Prevents unwanted API calls while programmatically updating search
var _suppressSearch = false.obs;
Timer? _debounceTimer;
@override
void onInit() {
super.onInit();
ever<String>(searchQuery, (q) {
if (_suppressSearch.value) return; // Skip while selecting employee
// 🔹 When user types new text, clear previous employee + payments instantly
if (selectedEmployee.value != null) {
selectedEmployee.value = null;
payments.clear();
}
// 🔹 Show fresh dropdown results for new query
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
if (q.isNotEmpty) {
fetchEmployees(q); // repopulate dropdown
} else {
employees.clear(); // hide dropdown when search cleared
}
});
});
}
@override
void onClose() {
_debounceTimer?.cancel();
super.onClose();
}
/// Fetch employees by query
Future<void> fetchEmployees(String q) async {
if (q.isEmpty) {
employees.clear();
return;
}
if (employeesLoading.value) return;
try {
employeesLoading.value = true;
// Build query params
final queryParams = {
'allEmployee': 'true',
if (q.isNotEmpty) 'q': q, // only include search query if not empty
};
final list = await ApiService.getEmployees(queryParams: queryParams);
final parsed = Employee.listFromJson(list);
logSafe(
"✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}");
allEmployees = parsed;
_filterEmployees(q);
} catch (e, s) {
logSafe("❌ fetchEmployees error: $e\n$s", level: LogLevel.error);
employees.clear();
} finally {
employeesLoading.value = false;
}
}
/// Local filter to update list based on search text
void _filterEmployees(String query) {
final q = query.toLowerCase();
employees
..clear()
..addAll(allEmployees.where((e) {
return e.name.toLowerCase().contains(q) ||
e.email.toLowerCase().contains(q);
}));
}
/// When user selects employee
void selectEmployee(Employee emp) {
_suppressSearch.value = true;
selectedEmployee.value = emp;
employees.clear(); // hide dropdown
searchQuery.value = emp.name;
fetchAdvancePayments(emp.id);
// Re-enable search after a short delay
Future.delayed(const Duration(milliseconds: 300), () {
_suppressSearch.value = false;
});
}
/// Fetch advance payments for the selected employee
Future<void> fetchAdvancePayments(String employeeId) async {
if (employeeId.isEmpty) {
payments.clear();
return;
}
try {
isLoading.value = true;
final list = await ApiService.getAdvancePayments(employeeId);
payments.assignAll(list);
} catch (e, s) {
logSafe("❌ fetchAdvancePayments error: $e\n$s", level: LogLevel.error);
payments.clear();
} finally {
isLoading.value = false;
}
}
/// Clear employee selection
void clearSelection() {
selectedEmployee.value = null;
payments.clear();
employees.clear();
searchQuery.value = '';
}
void resetSelectionOnNewSearch() {
if (selectedEmployee.value != null) {
selectedEmployee.value = null;
payments.clear();
}
}
}

View File

@ -0,0 +1,134 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/finance/payment_request_list_model.dart';
import 'package:on_field_work/model/finance/payment_request_filter.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
class PaymentRequestController extends GetxController {
// ---------------- Observables ----------------
final RxList<PaymentRequest> paymentRequests = <PaymentRequest>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxBool isFilterApplied = false.obs;
// ---------------- Pagination ----------------
int _pageSize = 20;
int _pageNumber = 1;
bool _hasMoreData = true;
// ---------------- Filters ----------------
RxMap<String, dynamic> appliedFilter = <String, dynamic>{}.obs;
RxString searchString = ''.obs;
// ---------------- Filter Options ----------------
RxList<IdNameModel> projects = <IdNameModel>[].obs;
RxList<IdNameModel> payees = <IdNameModel>[].obs;
RxList<IdNameModel> categories = <IdNameModel>[].obs;
RxList<IdNameModel> currencies = <IdNameModel>[].obs;
RxList<IdNameModel> statuses = <IdNameModel>[].obs;
RxList<IdNameModel> createdBy = <IdNameModel>[].obs;
// ---------------- Fetch Filter Options ----------------
Future<void> fetchPaymentRequestFilterOptions() async {
try {
final response = await ApiService.getExpensePaymentRequestFilterApi();
if (response != null && response.data != null) {
projects.assignAll(response.data!.projects ?? []);
payees.assignAll(response.data!.payees ?? []);
categories.assignAll(response.data!.expenseCategory ?? []);
currencies.assignAll(response.data!.currency ?? []);
statuses.assignAll(response.data!.status ?? []);
createdBy.assignAll(response.data!.createdBy ?? []);
} else {
logSafe("Payment request filter API returned null",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception in fetchPaymentRequestFilterOptions: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
}
// ---------------- Fetch Payment Requests ----------------
Future<void> fetchPaymentRequests({int pageSize = 20}) async {
isLoading.value = true;
errorMessage.value = '';
_pageNumber = 1;
_pageSize = pageSize;
_hasMoreData = true;
paymentRequests.clear();
await _fetchPaymentRequestsFromApi();
isLoading.value = false;
}
// ---------------- Load More ----------------
Future<void> loadMorePaymentRequests() async {
if (isLoading.value || !_hasMoreData) return;
_pageNumber += 1;
isLoading.value = true;
await _fetchPaymentRequestsFromApi();
isLoading.value = false;
}
// ---------------- Internal API Call ----------------
Future<void> _fetchPaymentRequestsFromApi() async {
try {
final response = await ApiService.getExpensePaymentRequestListApi(
pageSize: _pageSize,
pageNumber: _pageNumber,
filter: appliedFilter,
searchString: searchString.value,
);
final data = response?.data;
final reqList = data?.data ?? [];
if (response != null && data != null && reqList.isNotEmpty) {
if (_pageNumber == 1) {
paymentRequests.assignAll(reqList);
} else {
paymentRequests.addAll(reqList);
}
if (reqList.length < _pageSize) {
_hasMoreData = false;
}
} else {
if (_pageNumber == 1) {
errorMessage.value = 'No payment requests found.';
}
_hasMoreData = false;
}
} catch (e, stack) {
errorMessage.value = 'Failed to fetch payment requests.';
logSafe("Exception in _fetchPaymentRequestsFromApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
_hasMoreData = false;
}
}
// ---------------- Filter Management ----------------
void setFilterApplied(bool applied) {
isFilterApplied.value = applied;
}
void applyFilter(Map<String, dynamic> filter, {String search = ''}) {
appliedFilter.assignAll(filter);
searchString.value = search;
isFilterApplied.value = filter.isNotEmpty || search.isNotEmpty;
fetchPaymentRequests();
}
void clearFilter() {
appliedFilter.clear();
searchString.value = '';
isFilterApplied.value = false;
fetchPaymentRequests();
}
}

View File

@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.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/finance/payment_request_details_model.dart';
import 'package:on_field_work/model/expense/payment_types_model.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mime/mime.dart';
import 'package:on_field_work/controller/finance/payment_request_controller.dart';
class PaymentRequestDetailController extends GetxController {
final Rx<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
// Employee selection
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
final TextEditingController employeeSearchController =
TextEditingController();
PaymentRequestController get paymentRequestController =>
Get.find<PaymentRequestController>();
final RxBool isSearchingEmployees = false.obs;
// Attachments
final RxList<File> attachments = <File>[].obs;
final RxList<Map<String, dynamic>> existingAttachments =
<Map<String, dynamic>>[].obs;
final isProcessingAttachment = false.obs;
// Payment mode
final selectedPaymentMode = Rxn<PaymentModeModel>();
// Text controllers for form
final TextEditingController locationController = TextEditingController();
final TextEditingController gstNumberController = TextEditingController();
// Form submission state
final RxBool isSubmitting = false.obs;
late String _requestId;
bool _isInitialized = false;
RxBool paymentSheetOpened = false.obs;
final ImagePicker _picker = ImagePicker();
/// Initialize controller
void init(String requestId) {
if (_isInitialized) return;
_isInitialized = true;
_requestId = requestId;
// Fetch payment request details + employees concurrently
Future.wait([
fetchPaymentRequestDetail(),
fetchAllEmployees(),
fetchPaymentModes(),
]);
}
/// Generic API wrapper for error handling
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
isLoading.value = true;
errorMessage.value = '';
try {
final result = await apiCall();
return result;
} catch (e) {
errorMessage.value = 'Error during $operationName: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
return null;
} finally {
isLoading.value = false;
}
}
/// Fetch payment request details
Future<void> fetchPaymentRequestDetail() async {
isLoading.value = true;
try {
final response =
await ApiService.getExpensePaymentRequestDetailApi(_requestId);
if (response != null) {
paymentRequest.value = response.data;
} else {
errorMessage.value = "Failed to fetch payment request details";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
}
} catch (e) {
errorMessage.value = "Error fetching payment request details: $e";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Pick files from gallery or file picker
Future<void> pickAttachments() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
attachments.addAll(
result.paths.whereType<String>().map(File.new),
);
}
} catch (e) {
_errorSnackbar("Attachment error: $e");
}
}
void removeAttachment(File file) => attachments.remove(file);
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
isProcessingAttachment.value = true;
File imageFile = File(pickedFile.path);
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh();
}
} catch (e) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false;
}
}
// --- Location ---
final RxBool isFetchingLocation = false.obs;
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
if (!await _ensureLocationPermission()) return;
final position = await Geolocator.getCurrentPosition();
final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude);
locationController.text = placemarks.isNotEmpty
? [
placemarks.first.name,
placemarks.first.street,
placemarks.first.locality,
placemarks.first.administrativeArea,
placemarks.first.country,
].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}";
} catch (e) {
_errorSnackbar("Location error: $e");
} finally {
isFetchingLocation.value = false;
}
}
Future<bool> _ensureLocationPermission() async {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
_errorSnackbar("Location permission denied.");
return false;
}
}
if (!await Geolocator.isLocationServiceEnabled()) {
_errorSnackbar("Location service disabled.");
return false;
}
return true;
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
final response = await _apiCallWrapper(
() => ApiService.getAllEmployees(), "fetch all employees");
if (response != null && response.isNotEmpty) {
try {
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
} catch (e) {
errorMessage.value = 'Failed to parse employee data: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
}
} else {
allEmployees.clear();
}
}
/// Fetch payment modes
Future<void> fetchPaymentModes() async {
isLoading.value = true;
try {
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
} else {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Failed to fetch payment modes',
type: SnackbarType.error);
}
} catch (e) {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Error fetching payment modes: $e',
type: SnackbarType.error);
} finally {
isLoading.value = false;
}
}
/// Search employees
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Update payment request status
Future<bool> updatePaymentRequestStatus({
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
isLoading.value = true;
try {
final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId,
statusId: statusId,
comment: comment,
paidTransactionId: paidTransactionId,
paidById: paidById,
paidAt: paidAt,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercentage: tdsPercentage,
);
if (success) {
// Controller refreshes the data but does not show snackbars.
await fetchPaymentRequestDetail();
paymentRequestController.fetchPaymentRequests();
}
return success;
} catch (e) {
// Controller returns false on error; UI will show the snackbar.
return false;
} finally {
isLoading.value = false;
}
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
// --- Payment Mode Selection ---
void selectPaymentMode(PaymentModeModel mode) {
selectedPaymentMode.value = mode;
}
// --- Submit Expense ---
Future<bool> submitExpense(
{required String statusId, String? comment}) async {
if (selectedPaymentMode.value == null) return false;
isSubmitting.value = true;
try {
// prepare attachments
final success = await ApiService.createExpenseForPRApi(
paymentModeId: selectedPaymentMode.value!.id,
location: locationController.text,
gstNumber: gstNumberController.text,
paymentRequestId: _requestId,
billAttachments: attachments.map((file) {
final bytes = file.readAsBytesSync();
final mimeType =
lookupMimeType(file.path) ?? 'application/octet-stream';
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": mimeType,
"description": "",
"fileSize": bytes.length,
"isActive": true,
};
}).toList(),
statusId: statusId,
comment: comment ?? '',
);
if (success) {
// Refresh the payment request details so the UI updates
await fetchPaymentRequestDetail();
}
return success;
} finally {
isSubmitting.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

@ -1,3 +1,3 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class AuthLayout2Controller extends MyController {} class AuthLayout2Controller extends MyController {}

View File

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart'; import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AuthLayoutController extends MyController { class AuthLayoutController extends MyController {

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:marco/model/project_model.dart'; import 'package:on_field_work/model/project_model.dart';
class LayoutController extends GetxController { class LayoutController extends GetxController {
// Theme Customization // Theme Customization
@ -55,7 +55,7 @@ class LayoutController extends GetxController {
isLoadingProjects.value = true; isLoadingProjects.value = true;
try { try {
final response = await ApiService.getProjects(); final response = await ApiService.getGlobalProjects();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
final fetchedProjects = response.map((json) => ProjectModel.fromJson(json)).toList(); final fetchedProjects = response.map((json) => ProjectModel.fromJson(json)).toList();

View File

@ -1,5 +1,5 @@
import 'package:get/get_state_manager/get_state_manager.dart'; import 'package:get/get_state_manager/get_state_manager.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
abstract class MyController extends GetxController { abstract class MyController extends GetxController {
@override @override

View File

@ -2,17 +2,21 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/permission_service.dart'; import 'package:on_field_work/helpers/services/permission_service.dart';
import 'package:marco/model/user_permission.dart'; import 'package:on_field_work/model/user_permission.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:marco/model/projects_model.dart'; import 'package:on_field_work/model/projects_model.dart';
class PermissionController extends GetxController { class PermissionController extends GetxController {
var permissions = <UserPermission>[].obs; var permissions = <UserPermission>[].obs;
var employeeInfo = Rxn<EmployeeInfo>(); var employeeInfo = Rxn<EmployeeInfo>();
var projectsInfo = <ProjectInfo>[].obs; var projectsInfo = <ProjectInfo>[].obs;
Timer? _refreshTimer; Timer? _refreshTimer;
var isLoading = true.obs;
/// NEW: reactive flag to signal permissions are loaded
var permissionsLoaded = false.obs;
@override @override
void onInit() { void onInit() {
@ -26,7 +30,8 @@ class PermissionController extends GetxController {
await loadData(token!); await loadData(token!);
_startAutoRefresh(); _startAutoRefresh();
} else { } else {
logSafe("Token is null or empty. Skipping API load and auto-refresh.", level: LogLevel.warning); logSafe("Token is null or empty. Skipping API load and auto-refresh.",
level: LogLevel.warning);
} }
} }
@ -37,19 +42,28 @@ class PermissionController extends GetxController {
logSafe("Auth token retrieved: $token", level: LogLevel.debug); logSafe("Auth token retrieved: $token", level: LogLevel.debug);
return token; return token;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error retrieving auth token", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error retrieving auth token",
level: LogLevel.error, error: e, stackTrace: stacktrace);
return null; return null;
} }
} }
Future<void> loadData(String token) async { Future<void> loadData(String token) async {
try { try {
isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token); final userData = await PermissionService.fetchAllUserData(token);
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");
// NEW: mark permissions as loaded
permissionsLoaded.value = true;
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error loading data from API", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} finally {
isLoading.value = false;
} }
} }
@ -60,7 +74,8 @@ class PermissionController extends GetxController {
projectsInfo.assignAll(userData['projects']); projectsInfo.assignAll(userData['projects']);
logSafe("State updated with user data."); logSafe("State updated with user data.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error updating state", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error updating state",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
@ -89,31 +104,33 @@ class PermissionController extends GetxController {
logSafe("User data successfully stored in SharedPreferences."); logSafe("User data successfully stored in SharedPreferences.");
} catch (e, stacktrace) { } catch (e, stacktrace) {
logSafe("Error storing data", level: LogLevel.error, error: e, stackTrace: stacktrace); logSafe("Error storing data",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} }
} }
void _startAutoRefresh() { void _startAutoRefresh() {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async { _refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered."); logSafe("Auto-refresh triggered.");
final token = await _getAuthToken(); final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) { if (token?.isNotEmpty ?? false) {
await loadData(token!); await loadData(token!);
} else { } else {
logSafe("Token missing during auto-refresh. Skipping.", level: LogLevel.warning); logSafe("Token missing during auto-refresh. Skipping.",
level: LogLevel.warning);
} }
}); });
} }
bool hasPermission(String permissionId) { bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId); final hasPerm = permissions.any((p) => p.id == permissionId);
logSafe("Checking permission $permissionId: $hasPerm", level: LogLevel.debug);
return hasPerm; return hasPerm;
} }
bool isUserAssignedToProject(String projectId) { bool isUserAssignedToProject(String projectId) {
final assigned = projectsInfo.any((project) => project.id == projectId); final assigned = projectsInfo.any((project) => project.id == projectId);
logSafe("Checking project assignment for $projectId: $assigned", level: LogLevel.debug); logSafe("Checking project assignment for $projectId: $assigned",
level: LogLevel.debug);
return assigned; return assigned;
} }

View File

@ -1,8 +1,8 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/global_project_model.dart'; import 'package:on_field_work/model/global_project_model.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ProjectController extends GetxController { class ProjectController extends GetxController {
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs; RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;

View File

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/controller/service_project/service_project_details_screen_controller.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/service_project/service_project_branches_model.dart';
class AddServiceProjectJobController extends GetxController {
// FORM CONTROLLERS
final titleCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
final searchFocusNode = FocusNode();
// OBSERVABLES
final startDate = Rx<DateTime?>(DateTime.now());
final dueDate = Rx<DateTime?>(DateTime.now().add(const Duration(days: 1)));
final enteredTags = <String>[].obs;
final selectedAssignees = <EmployeeModel>[].obs;
// Branches
final branches = <Branch>[].obs;
final selectedBranch = Rxn<Branch>();
final isBranchLoading = false.obs;
// Loading
final isLoading = false.obs;
@override
void onClose() {
titleCtrl.dispose();
descCtrl.dispose();
tagCtrl.dispose();
searchFocusNode.dispose();
super.onClose();
}
// FETCH BRANCHES
Future<void> fetchBranches(String projectId) async {
isBranchLoading.value = true;
final response = await ApiService.getServiceProjectBranchesFull(
projectId: projectId,
);
if (response != null && response.success) {
branches.assignAll(response.data?.data ?? []);
}
isBranchLoading.value = false;
}
// CREATE JOB
Future<void> createJob(String projectId) async {
if (titleCtrl.text.trim().isEmpty || descCtrl.text.trim().isEmpty) {
showAppSnackbar(
title: "Validation",
message: "Title and Description are required",
type: SnackbarType.warning,
);
return;
}
isLoading.value = true;
final jobId = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(),
description: descCtrl.text.trim(),
projectId: projectId,
branchId: selectedBranch.value?.id,
assignees: selectedAssignees // payload mapping
.map((e) => {"employeeId": e.id, "isActive": true})
.toList(),
startDate: startDate.value!,
dueDate: dueDate.value!,
tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true})
.toList(),
);
isLoading.value = false;
if (jobId != null) {
if (Get.isRegistered<ServiceProjectDetailsController>()) {
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();
showAppSnackbar(
title: "Success",
message: "Job created successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to create job",
type: SnackbarType.error,
);
}
}
}

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/model/service_project/job_allocation_model.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
class ServiceProjectAllocationController extends GetxController {
final projectId = ''.obs;
// Roles
var roles = <TeamRole>[].obs;
var selectedRole = Rxn<TeamRole>();
// Employees
var roleEmployees = <Employee>[].obs;
var selectedEmployees = <Employee>[].obs;
final displayController = TextEditingController();
// Loading
var isLoading = false.obs;
@override
void onInit() {
super.onInit();
ever(selectedEmployees, (_) {
displayController.text = selectedEmployees.isEmpty
? ''
: selectedEmployees
.map((e) => '${e.firstName} ${e.lastName}')
.join(', ');
});
}
// Fetch all roles
Future<void> fetchRoles() async {
isLoading.value = true;
final result = await ApiService.getTeamRoles();
if (result != null) {
roles.assignAll(result);
}
isLoading.value = false;
}
// Fetch employees by role
Future<void> fetchEmployeesByRole(String roleId) async {
isLoading.value = true;
final allocations = await ApiService.getServiceProjectAllocationList(
projectId: projectId.value);
if (allocations != null) {
roleEmployees.assignAll(
allocations
.where((a) => a.teamRole.id == roleId)
.map((a) => a.employee)
.toList(),
);
}
isLoading.value = false;
}
void toggleEmployee(Employee emp) {
if (selectedEmployees.contains(emp)) {
selectedEmployees.remove(emp);
} else {
selectedEmployees.add(emp);
}
}
Future<bool> submitAllocation() async {
final payload = selectedEmployees
.map((e) => {
"projectId": projectId.value,
"employeeId": e.id,
"teamRoleId": selectedRole.value?.id,
"isActive": true,
})
.toList();
return await ApiService.manageServiceProjectAllocation(payload: payload);
}
}

View File

@ -0,0 +1,479 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/service_project/service_projects_details_model.dart';
import 'package:on_field_work/model/service_project/job_list_model.dart';
import 'package:on_field_work/model/service_project/service_project_job_detail_model.dart';
import 'package:geolocator/geolocator.dart';
import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart';
import 'package:on_field_work/model/service_project/job_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 --------------------
var projectId = ''.obs;
var projectDetail = Rxn<ProjectDetail>();
var jobList = <JobEntity>[].obs;
var jobDetail = Rxn<JobDetailsResponse>();
var showArchivedJobs = false.obs; // true = archived, false = active
// Loading states
var isLoading = false.obs;
var isJobLoading = false.obs;
var isJobDetailLoading = false.obs;
// Error messages
var errorMessage = ''.obs;
var jobErrorMessage = ''.obs;
var jobDetailErrorMessage = ''.obs;
final ImagePicker picker = ImagePicker();
var isProcessingAttachment = false.obs;
// Pagination
var pageNumber = 1;
final int pageSize = 20;
var hasMoreJobs = true.obs;
var isTagging = false.obs;
var attendanceMessage = ''.obs;
var attendanceLog = Rxn<JobAttendanceResponse>();
var teamList = <ServiceProjectAllocation>[].obs;
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() {
super.onInit();
fetchProjectJobs();
filteredJobList.value = jobList;
}
// -------------------- Project --------------------
void setProjectId(String id) {
if (projectId.value == id) return;
projectId.value = id;
// Reset pagination and list
pageNumber = 1;
hasMoreJobs.value = true;
jobList.clear();
filteredJobList.clear();
// Fetch project detail
fetchProjectDetail();
// Always fetch jobs for this project
fetchProjectJobs(refresh: true);
}
void updateJobSearch(String searchText) {
if (searchText.isEmpty) {
filteredJobList.value = jobList;
} else {
filteredJobList.value = jobList.where((job) {
final lowerSearch = searchText.toLowerCase();
return job.title.toLowerCase().contains(lowerSearch) ||
(job.description.toLowerCase().contains(lowerSearch)) ||
(job.tags?.any(
(tag) => tag.name.toLowerCase().contains(lowerSearch)) ??
false);
}).toList();
}
}
Future<void> fetchProjectTeams() async {
if (projectId.value.isEmpty) {
teamErrorMessage.value = "Invalid project ID";
return;
}
isTeamLoading.value = true;
teamErrorMessage.value = '';
try {
final result = await ApiService.getServiceProjectAllocationList(
projectId: projectId.value,
isActive: true,
);
if (result != null) {
teamList.value = result;
} else {
teamErrorMessage.value = "No teams found";
}
} catch (e) {
teamErrorMessage.value = "Error fetching teams: $e";
} finally {
isTeamLoading.value = false;
}
}
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";
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
final result =
await ApiService.getServiceProjectDetailApi(projectId.value);
if (result != null && result.data != null) {
projectDetail.value = result.data!;
} else {
errorMessage.value =
result?.message ?? "Failed to fetch project details";
}
} catch (e) {
errorMessage.value = "Error: $e";
} finally {
isLoading.value = false;
}
}
Future<void> fetchJobAttendanceLog(String attendanceId) async {
if (attendanceId.isEmpty) {
attendanceMessage.value = "Invalid attendance ID";
return;
}
isJobDetailLoading.value = true;
attendanceMessage.value = '';
try {
final result =
await ApiService.getJobAttendanceLog(attendanceId: attendanceId);
if (result != null) {
attendanceLog.value = result;
} else {
attendanceMessage.value = "Attendance log not found or empty";
}
} catch (e) {
attendanceMessage.value = "Error fetching attendance log: $e";
} finally {
isJobDetailLoading.value = false;
}
}
// -------------------- Job List (modified to always load) --------------------
Future<void> fetchProjectJobs({bool refresh = false}) async {
if (projectId.value.isEmpty) return;
if (refresh) pageNumber = 1;
if (!hasMoreJobs.value && !refresh) return;
isJobLoading.value = true;
jobErrorMessage.value = '';
try {
final result = await ApiService.getServiceProjectJobListApi(
projectId: projectId.value,
pageNumber: pageNumber,
pageSize: pageSize,
isActive: true,
isArchive: showArchivedJobs.value,
);
if (result != null && result.data != null) {
final newJobs = result.data?.data ?? [];
if (refresh || pageNumber == 1) {
jobList.value = newJobs;
} else {
jobList.addAll(newJobs);
}
filteredJobList.value = jobList;
hasMoreJobs.value = newJobs.length == pageSize;
if (hasMoreJobs.value) pageNumber++;
} else {
jobErrorMessage.value = result?.message ?? "Failed to fetch jobs";
}
} catch (e) {
jobErrorMessage.value = "Error fetching jobs: $e";
} finally {
isJobLoading.value = false;
}
}
Future<void> fetchMoreJobs() async => fetchProjectJobs();
// -------------------- Manual Refresh --------------------
Future<void> refresh() async {
pageNumber = 1;
hasMoreJobs.value = true;
await Future.wait([
fetchProjectDetail(),
fetchProjectJobs(),
]);
}
// -------------------- Job Detail --------------------
Future<void> fetchJobDetail(String jobId) async {
if (jobId.isEmpty) {
jobDetailErrorMessage.value = "Invalid job ID";
return;
}
isJobDetailLoading.value = true;
jobDetailErrorMessage.value = '';
try {
final result = await ApiService.getServiceProjectJobDetailApi(jobId);
if (result != null) {
jobDetail.value = result;
} else {
jobDetailErrorMessage.value = "Failed to fetch job details";
}
} catch (e) {
jobDetailErrorMessage.value = "Error fetching job details: $e";
} finally {
isJobDetailLoading.value = false;
}
}
Future<Position?> _getCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
attendanceMessage.value = "Location services are disabled.";
return null;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
attendanceMessage.value = "Location permission denied";
return null;
}
}
if (permission == LocationPermission.deniedForever) {
attendanceMessage.value =
"Location permission permanently denied. Enable it from settings.";
return null;
}
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
} catch (e) {
attendanceMessage.value = "Failed to get location: $e";
return null;
}
}
Future<void> fetchJobComments({bool refresh = false}) async {
if (jobDetail.value?.data?.id == null) {
commentsErrorMessage.value = "Invalid job ID";
return;
}
if (refresh) pageNumber = 1;
isCommentsLoading.value = true;
commentsErrorMessage.value = '';
try {
final response = await ApiService.getJobCommentList(
jobTicketId: jobDetail.value!.data!.id!,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.data != null) {
final newComments = response.data?.data ?? [];
if (refresh || pageNumber == 1) {
jobComments.value = newComments;
} else {
jobComments.addAll(newComments);
}
hasMoreJobs.value =
(response.data?.totalEntities ?? 0) > (pageNumber * pageSize);
if (hasMoreJobs.value) pageNumber++;
} else {
commentsErrorMessage.value =
response?.message ?? "Failed to fetch comments";
}
} catch (e) {
commentsErrorMessage.value = "Error fetching comments: $e";
} finally {
isCommentsLoading.value = false;
}
}
Future<bool> addJobComment({
required String jobId,
required String comment,
List<File>? files,
}) async {
try {
List<Map<String, dynamic>> attachments = [];
if (files != null && files.isNotEmpty) {
for (final file in files) {
final bytes = await file.readAsBytes();
final base64Data = base64Encode(bytes);
final mimeType =
lookupMimeType(file.path) ?? "application/octet-stream";
attachments.add({
"fileName": file.path.split('/').last,
"base64Data": base64Data,
"contentType": mimeType,
"fileSize": bytes.length,
"description": "",
"isActive": true,
});
}
}
final success = await ApiService.addJobComment(
jobTicketId: jobId,
comment: comment,
attachments: attachments,
);
if (success) {
await fetchJobDetail(jobId);
refresh();
}
return success;
} catch (e) {
print("Error adding comment: $e");
return false;
}
}
/// Tag In / Tag Out for a job with proper payload
Future<void> updateJobAttendance({
required String jobId,
required int action,
String comment = "Updated via app",
File? attachment,
}) async {
if (jobId.isEmpty) return;
isTagging.value = true;
attendanceMessage.value = '';
try {
final position = await _getCurrentLocation();
if (position == null) {
isTagging.value = false;
return;
}
Map<String, dynamic>? attachmentPayload;
if (attachment != null) {
final bytes = await attachment.readAsBytes();
final base64Data = base64Encode(bytes);
final mimeType =
lookupMimeType(attachment.path) ?? 'application/octet-stream';
attachmentPayload = {
"documentId": jobId,
"fileName": attachment.path.split('/').last,
"base64Data": base64Data,
"contentType": mimeType,
"fileSize": bytes.length,
"description": "Attached via app",
"isActive": true,
};
}
final payload = {
"jobTcketId": jobId,
"action": action,
"latitude": position.latitude.toString(),
"longitude": position.longitude.toString(),
"comment": comment,
"attachment": attachmentPayload,
};
final success = await ApiService.updateServiceProjectJobAttendance(
payload: payload,
);
if (success) {
attendanceMessage.value =
action == 0 ? "Tagged In successfully" : "Tagged Out successfully";
await fetchJobDetail(jobId);
} else {
attendanceMessage.value = "Failed to update attendance";
}
} catch (e) {
attendanceMessage.value = "Error updating attendance: $e";
} finally {
isTagging.value = false;
}
}
// ------------------------------------------------------------
// 🔥 AUTO REFRESH JOB LIST AFTER ADDING A JOB
// ------------------------------------------------------------
Future<void> refreshJobsAfterAdd() async {
pageNumber = 1;
hasMoreJobs.value = true;
await fetchProjectJobs();
}
}

View File

@ -0,0 +1,59 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/service_project/service_projects_list_model.dart';
class ServiceProjectController extends GetxController {
final projects = <ProjectItem>[].obs;
final isLoading = false.obs;
final searchQuery = ''.obs;
/// Computed filtered project list
List<ProjectItem> get filteredProjects {
final query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return projects;
return projects.where((p) {
final nameMatch = p.name.toLowerCase().contains(query);
final shortNameMatch = p.shortName.toLowerCase().contains(query);
final addressMatch = p.address.toLowerCase().contains(query);
final contactMatch = p.contactName.toLowerCase().contains(query);
final clientMatch = p.client != null &&
(p.client!.name.toLowerCase().contains(query) ||
p.client!.contactPerson.toLowerCase().contains(query));
return nameMatch ||
shortNameMatch ||
addressMatch ||
contactMatch ||
clientMatch;
}).toList();
}
/// Fetch projects from API
Future<void> fetchProjects({int pageNumber = 1, int pageSize = 20}) async {
try {
isLoading.value = true;
final result = await ApiService.getServiceProjectsListApi(
pageNumber: pageNumber,
pageSize: pageSize,
);
if (result != null && result.data != null) {
projects.assignAll(result.data!.data);
} else {
projects.clear();
}
} catch (e) {
// Optional: log or show error
rethrow;
} finally {
isLoading.value = false;
}
}
/// Update search
void updateSearch(String query) {
searchQuery.value = query;
}
}

View File

@ -1,9 +1,9 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/master_work_category_model.dart'; import 'package:on_field_work/model/dailyTaskPlanning/master_work_category_model.dart';
class AddTaskController extends GetxController { class AddTaskController extends GetxController {
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:on_field_work/model/project_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:on_field_work/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:on_field_work/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class DailyTaskController extends GetxController { class DailyTaskController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
@ -12,6 +13,10 @@ class DailyTaskController extends GetxController {
DateTime? startDateTask; DateTime? startDateTask;
DateTime? endDateTask; DateTime? endDateTask;
// Rx fields for DateRangePickerWidget
Rx<DateTime> startDateTaskRx = DateTime.now().obs;
Rx<DateTime> endDateTaskRx = DateTime.now().obs;
List<TaskModel> dailyTasks = []; List<TaskModel> dailyTasks = [];
final RxSet<String> expandedDates = <String>{}.obs; final RxSet<String> expandedDates = <String>{}.obs;
@ -23,17 +28,28 @@ class DailyTaskController extends GetxController {
} }
} }
RxSet<String> selectedBuildings = <String>{}.obs;
RxSet<String> selectedFloors = <String>{}.obs;
RxSet<String> selectedActivities = <String>{}.obs;
RxSet<String> selectedServices = <String>{}.obs;
RxBool isFilterLoading = false.obs;
RxBool isLoading = true.obs; RxBool isLoading = true.obs;
RxBool isLoadingMore = false.obs; RxBool isLoadingMore = false.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {}; Map<String, List<TaskModel>> groupedDailyTasks = {};
// Pagination // Pagination
int currentPage = 1; int currentPage = 1;
int pageSize = 20; int pageSize = 20;
bool hasMore = true; bool hasMore = true;
FilterData? taskFilterData;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeDefaults(); _initializeDefaults();
_initializeRxDates();
} }
void _initializeDefaults() { void _initializeDefaults() {
@ -51,9 +67,41 @@ class DailyTaskController extends GetxController {
); );
} }
void _initializeRxDates() {
startDateTaskRx.value =
startDateTask ?? DateTime.now().subtract(const Duration(days: 7));
endDateTaskRx.value = endDateTask ?? DateTime.now();
}
void clearTaskFilters() {
selectedBuildings.clear();
selectedFloors.clear();
selectedActivities.clear();
selectedServices.clear();
startDateTask = null;
endDateTask = null;
// reset Rx dates as well
startDateTaskRx.value = DateTime.now().subtract(const Duration(days: 7));
endDateTaskRx.value = DateTime.now();
update();
}
void updateDateRange(DateTime? start, DateTime? end) {
if (start != null && end != null) {
startDateTask = start;
endDateTask = end;
startDateTaskRx.value = start;
endDateTaskRx.value = end;
update();
}
}
Future<void> fetchTaskData( Future<void> fetchTaskData(
String projectId, { String projectId, {
List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
bool isLoadMore = false, bool isLoadMore = false,
@ -68,22 +116,42 @@ class DailyTaskController extends GetxController {
isLoadingMore.value = true; isLoadingMore.value = true;
} }
// Create the filter object
final filter = {
"buildingIds": selectedBuildings.toList(),
"floorIds": selectedFloors.toList(),
"activityIds": selectedActivities.toList(),
"serviceIds": selectedServices.toList(),
"dateFrom": startDateTask?.toIso8601String(),
"dateTo": endDateTask?.toIso8601String(),
};
final response = await ApiService.getDailyTasks( final response = await ApiService.getDailyTasks(
projectId, projectId,
dateFrom: startDateTask, filter: filter,
dateTo: endDateTask,
serviceIds: serviceIds,
pageNumber: pageNumber, pageNumber: pageNumber,
pageSize: pageSize, pageSize: pageSize,
); );
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
for (var taskJson in response) { if (!isLoadMore) {
final task = TaskModel.fromJson(taskJson); groupedDailyTasks.clear();
}
for (var task in response) {
final assignmentDateKey = final assignmentDateKey =
task.assignmentDate.toIso8601String().split('T')[0]; task.assignmentDate.toIso8601String().split('T')[0];
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
// Initialize list if not present
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []);
// Only add task if it doesn't already exist (avoid duplicates)
if (!groupedDailyTasks[assignmentDateKey]!
.any((t) => t.id == task.id)) {
groupedDailyTasks[assignmentDateKey]!.add(task);
}
} }
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList(); dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
currentPage = pageNumber; currentPage = pageNumber;
} else { } else {
@ -96,6 +164,32 @@ class DailyTaskController extends GetxController {
update(); update();
} }
Future<void> fetchTaskFilter(String projectId) async {
isFilterLoading.value = true;
try {
final filterResponse = await ApiService.getDailyTaskFilter(projectId);
if (filterResponse != null && filterResponse.success) {
taskFilterData = filterResponse.data;
logSafe(
"Task filter fetched successfully. Buildings: ${taskFilterData?.buildings.length}, Floors: ${taskFilterData?.floors.length}",
level: LogLevel.info,
);
} else {
logSafe(
"Failed to fetch task filter for projectId: $projectId",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception in fetchTaskFilter: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isFilterLoading.value = false;
update();
}
}
Future<void> selectDateRangeForTaskData( Future<void> selectDateRangeForTaskData(
BuildContext context, BuildContext context,
DailyTaskController controller, DailyTaskController controller,
@ -119,12 +213,15 @@ class DailyTaskController extends GetxController {
startDateTask = picked.start; startDateTask = picked.start;
endDateTask = picked.end; endDateTask = picked.end;
// update Rx fields as well
startDateTaskRx.value = picked.start;
endDateTaskRx.value = picked.end;
logSafe( logSafe(
"Date range selected: $startDateTask to $endDateTask", "Date range selected: $startDateTask to $endDateTask",
level: LogLevel.info, level: LogLevel.info,
); );
// Add null check before calling fetchTaskData
final projectId = controller.selectedProjectId; final projectId = controller.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) { if (projectId != null && projectId.isNotEmpty) {
await controller.fetchTaskData(projectId); await controller.fetchTaskData(projectId);
@ -138,9 +235,7 @@ class DailyTaskController extends GetxController {
required String projectId, required String projectId,
required String taskAllocationId, required String taskAllocationId,
}) async { }) async {
// re-fetch tasks
await fetchTaskData(projectId); await fetchTaskData(projectId);
update();
update(); // rebuilds UI
} }
} }

View File

@ -1,41 +1,40 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/project_model.dart'; import 'package:on_field_work/model/project_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_planning_model.dart'; import 'package:on_field_work/model/dailyTaskPlanning/daily_task_planning_model.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
class DailyTaskPlanningController extends GetxController { class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = []; List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs; RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString(); RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs; RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
/// New: track per-building loading and loaded state for lazy infra loading
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
final Set<String> buildingsWithDetails = <String>{};
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchRoles(); fetchRoles();
_initializeDefaults();
}
void _initializeDefaults() {
fetchProjects();
} }
String? formFieldValidator(String? value, {required String fieldType}) { String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) return 'This field is required';
return 'This field is required';
}
if (fieldType == "target" && int.tryParse(value.trim()) == null) { if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number'; return 'Please enter a valid number';
} }
@ -46,9 +45,8 @@ class DailyTaskPlanningController extends GetxController {
} }
void updateSelectedEmployees() { void updateSelectedEmployees() {
final selected = selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList(); employees.where((e) => uploadingStates[e.id]?.value == true).toList();
selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug); logSafe("Updated selected employees", level: LogLevel.debug);
} }
@ -75,6 +73,8 @@ class DailyTaskPlanningController extends GetxController {
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
isAssigningTask.value = true; isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info); logSafe("Starting assign task...", level: LogLevel.info);
@ -85,6 +85,8 @@ class DailyTaskPlanningController extends GetxController {
description: description, description: description,
taskTeam: taskTeam, taskTeam: taskTeam,
assignmentDate: assignmentDate, assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
); );
isAssigningTask.value = false; isAssigningTask.value = false;
@ -108,68 +110,39 @@ class DailyTaskPlanningController extends GetxController {
} }
} }
Future<void> fetchProjects() async { /// Fetch buildings list only (no deep area/workItem calls) for initial load.
isLoading.value = true;
try {
final response = await ApiService.getProjects();
if (response?.isEmpty ?? true) {
logSafe("No project data found or API call failed",
level: LogLevel.warning);
return;
}
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
logSafe("Projects fetched: ${projects.length} projects loaded",
level: LogLevel.info);
update();
} catch (e, stack) {
logSafe("Error fetching projects",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
}
}
/// Fetch Infra details and then tasks per work area
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async { Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) { if (projectId == null) return;
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
isLoading.value = true; isFetchingTasks.value = true;
try { try {
// Fetch infra details final infraResponse = await ApiService.getInfraDetails(
final infraResponse = await ApiService.getInfraDetails(projectId); projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?; final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) { if (infraData == null || infraData.isEmpty) {
logSafe("No infra data found for project $projectId",
level: LogLevel.warning);
dailyTasks = []; dailyTasks = [];
return; return;
} }
// Map infra to dailyTasks structure // Filter buildings with 0 planned & completed work
dailyTasks = infraData.map((buildingJson) { final filteredBuildings = infraData.where((b) {
final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0;
final completed = (b['completedWork'] as num?)?.toDouble() ?? 0;
return planned > 0 || completed > 0;
}).toList();
dailyTasks = filteredBuildings.map((buildingJson) {
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
description: buildingJson['description'], description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>).map((floorJson) { floors: [],
return Floor( plannedWork: (buildingJson['plannedWork'] as num?)?.toDouble() ?? 0,
id: floorJson['id'], completedWork:
floorName: floorJson['floorName'], (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
workAreas:
(floorJson['workAreas'] as List<dynamic>).map((areaJson) {
return WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [], // Will fill after tasks API
);
}).toList(),
);
}).toList(),
); );
return TaskPlanningDetailsModel( return TaskPlanningDetailsModel(
@ -184,95 +157,210 @@ class DailyTaskPlanningController extends GetxController {
); );
}).toList(); }).toList();
// Fetch tasks for each work area, passing serviceId only if selected buildingLoadingStates.clear();
await Future.wait(dailyTasks buildingsWithDetails.clear();
.expand((task) => task.buildings) } catch (e, stack) {
.expand((b) => b.floors) logSafe("Error fetching daily task data",
.expand((f) => f.workAreas) level: LogLevel.error, error: e, stackTrace: stack);
.map((area) async { } finally {
try { isFetchingTasks.value = false;
final taskResponse = await ApiService.getWorkItemsByWorkArea( update();
area.id, }
// serviceId: serviceId, // <-- only pass if not null }
);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) { /// Fetch full infra for a single building (floors, workAreas, workItems).
return WorkItemWrapper( /// Called lazily when user expands a building in the UI.
workItemId: taskJson['id'], Future<void> fetchBuildingInfra(
workItem: WorkItem( String buildingId, String projectId, String? serviceId) async {
id: taskJson['id'], if (buildingId.isEmpty) return;
activityMaster: taskJson['activityMaster'] != null
? ActivityMaster.fromJson(taskJson['activityMaster']) // mark loading
: null, buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
workCategoryMaster: taskJson['workCategoryMaster'] != null buildingLoadingStates[buildingId]!.value = true;
? WorkCategoryMaster.fromJson( update();
taskJson['workCategoryMaster'])
: null, try {
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(), // Re-use getInfraDetails and find the building entry for the requested buildingId
completedWork: (taskJson['completedWork'] as num?)?.toDouble(), final infraResponse =
todaysAssigned: await ApiService.getInfraDetails(projectId, serviceId: serviceId);
(taskJson['todaysAssigned'] as num?)?.toDouble(), final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null final buildingJson = infraData.firstWhere(
? DateTime.tryParse(taskJson['taskDate']) (b) => b['id'].toString() == buildingId.toString(),
: null, orElse: () => null,
), );
);
})); if (buildingJson == null) {
logSafe("Building $buildingId not found in infra response",
level: LogLevel.warning);
return;
}
// Build floors & workAreas for this building
final building = Building(
id: buildingJson['id'],
name: buildingJson['buildingName'],
description: buildingJson['description'],
floors:
(buildingJson['floors'] as List<dynamic>? ?? []).map((floorJson) {
return Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas: (floorJson['workAreas'] as List<dynamic>? ?? [])
.map((areaJson) {
return WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [], // will populate later
);
}).toList(),
);
}).toList(),
plannedWork: (buildingJson['plannedWork'] as num?)?.toDouble() ?? 0,
completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
);
// For each workArea, fetch its work items and populate
await Future.wait(
building.floors.expand((f) => f.workAreas).map((area) async {
try {
final taskResponse = await ApiService.getWorkItemsByWorkArea(area.id,
serviceId: serviceId);
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) => WorkItemWrapper(
workItemId: taskJson['id'],
workItem: WorkItem(
id: taskJson['id'],
activityMaster: taskJson['activityMaster'] != null
? ActivityMaster.fromJson(taskJson['activityMaster'])
: null,
workCategoryMaster: taskJson['workCategoryMaster'] != null
? WorkCategoryMaster.fromJson(
taskJson['workCategoryMaster'])
: null,
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
completedWork:
(taskJson['completedWork'] as num?)?.toDouble(),
todaysAssigned:
(taskJson['todaysAssigned'] as num?)?.toDouble(),
description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null
? DateTime.tryParse(taskJson['taskDate'])
: null,
),
)));
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching tasks for work area ${area.id}", logSafe("Error fetching tasks for work area ${area.id}",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} }
})); }));
logSafe("Fetched infra and tasks for project $projectId", // Merge/replace the building into dailyTasks
level: LogLevel.info); bool merged = false;
for (var t in dailyTasks) {
final idx = t.buildings
.indexWhere((b) => b.id.toString() == building.id.toString());
if (idx != -1) {
t.buildings[idx] = building;
merged = true;
break;
}
}
if (!merged) {
// If not present, add a new TaskPlanningDetailsModel wrapper (fallback)
dailyTasks.add(TaskPlanningDetailsModel(
id: building.id,
name: building.name,
projectAddress: "",
contactPerson: "",
startDate: DateTime.now(),
endDate: DateTime.now(),
projectStatusId: "",
buildings: [building],
));
}
// Mark as loaded
buildingsWithDetails.add(buildingId.toString());
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching daily task data", logSafe("Error fetching infra for building $buildingId",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} finally { } finally {
isLoading.value = false; buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
buildingLoadingStates[buildingId]!.value = false;
update(); update();
} }
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProjectService({
if (projectId == null || projectId.isEmpty) { required String projectId,
logSafe("Project ID is required but was null or empty", String? serviceId,
level: LogLevel.error); String? organizationId,
return; }) async {
} isFetchingEmployees.value = true;
isLoading.value = true;
try { try {
final response = await ApiService.getAllEmployeesByProject(projectId); final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
employees = employees
response.map((json) => EmployeeModel.fromJson(json)).toList(); .assignAll(response.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) {
uploadingStates[emp.id] = false.obs; if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
} }
logSafe(
"Employees fetched: ${employees.length} for project $projectId", final currentEmployeeIds = employees.map((e) => e.id).toSet();
level: LogLevel.info,
); uploadingStates
.removeWhere((key, _) => !currentEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees
.removeWhere((e) => !currentEmployeeIds.contains(e.id));
logSafe("Employees fetched: ${employees.length}", level: LogLevel.info);
} else { } else {
employees = []; employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe( logSafe(
"No employees found for project $projectId", serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning, level: LogLevel.warning,
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe( logSafe("Error fetching employees",
"Error fetching employees for project $projectId", level: LogLevel.error, error: e, stackTrace: stack);
level: LogLevel.error,
error: e, if (serviceId == null &&
stackTrace: stack, organizationId == null &&
); allEmployeesCache.isNotEmpty) {
employees.assignAll(allEmployeesCache);
final cachedEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates
.removeWhere((key, _) => !cachedEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id));
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
}
} finally { } finally {
isLoading.value = false; isFetchingEmployees.value = false;
update(); update();
} }
} }

View File

@ -4,15 +4,16 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/dailyTaskPlanning/work_status_model.dart'; import 'package:on_field_work/model/dailyTaskPlanning/work_status_model.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
enum ApiStatus { idle, loading, success, failure } enum ApiStatus { idle, loading, success, failure }
@ -32,9 +33,11 @@ class ReportTaskActionController extends MyController {
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>(); final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
final RxString selectedWorkStatusName = ''.obs; final RxString selectedWorkStatusName = ''.obs;
final RxBool isPickingImage = false.obs;
final MyFormValidator basicValidator = MyFormValidator(); final MyFormValidator basicValidator = MyFormValidator();
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final assignedDateController = TextEditingController(); final assignedDateController = TextEditingController();
@ -83,18 +86,31 @@ class ReportTaskActionController extends MyController {
void _initializeFormFields() { void _initializeFormFields() {
basicValidator basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) ..addField('assigned_date',
..addField('work_area', label: "Work Area", controller: workAreaController) label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController) ..addField('activity', label: "Activity", controller: activityController)
..addField('team_size', label: "Team Size", controller: teamSizeController) ..addField('team_size',
label: "Team Size", controller: teamSizeController)
..addField('task_id', label: "Task Id", controller: taskIdController) ..addField('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController) ..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) ..addField('completed_work',
..addField('comment', label: "Comment", required: true, controller: commentController) label: "Completed Work",
..addField('assigned_by', label: "Assigned By", controller: assignedByController) required: true,
..addField('team_members', label: "Team Members", controller: teamMembersController) controller: completedWorkController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController) ..addField('comment',
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController); label: "Comment", required: true, controller: commentController)
..addField('assigned_by',
label: "Assigned By", controller: assignedByController)
..addField('team_members',
label: "Team Members", controller: teamMembersController)
..addField('planned_work',
label: "Planned Work", controller: plannedWorkController)
..addField('approved_task',
label: "Approved Task",
required: true,
controller: approvedTaskController);
} }
Future<bool> approveTask({ Future<bool> approveTask({
@ -108,7 +124,8 @@ class ReportTaskActionController extends MyController {
if (projectId.isEmpty || reportActionId.isEmpty) { if (projectId.isEmpty || reportActionId.isEmpty) {
_showError("Project ID and Report Action ID are required."); _showError("Project ID and Report Action ID are required.");
logSafe("Missing required projectId or reportActionId", level: LogLevel.warning); logSafe("Missing required projectId or reportActionId",
level: LogLevel.warning);
return false; return false;
} }
@ -117,13 +134,15 @@ class ReportTaskActionController extends MyController {
if (approvedTaskInt == null) { if (approvedTaskInt == null) {
_showError("Invalid approved task count."); _showError("Invalid approved task count.");
logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning); logSafe("Invalid approvedTaskCount: $approvedTaskCount",
level: LogLevel.warning);
return false; return false;
} }
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) { if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
_showError("Approved task count cannot exceed completed work."); _showError("Approved task count cannot exceed completed work.");
logSafe("Validation failed: approved > completed", level: LogLevel.warning); logSafe("Validation failed: approved > completed",
level: LogLevel.warning);
return false; return false;
} }
@ -159,7 +178,8 @@ class ReportTaskActionController extends MyController {
return false; return false;
} }
} catch (e, st) { } catch (e, st) {
logSafe("Error in approveTask: $e", level: LogLevel.error, error: e, stackTrace: st); logSafe("Error in approveTask: $e",
level: LogLevel.error, error: e, stackTrace: st);
_showError("An error occurred."); _showError("An error occurred.");
return false; return false;
} finally { } finally {
@ -207,7 +227,8 @@ class ReportTaskActionController extends MyController {
_showError("Failed to comment task."); _showError("Failed to comment task.");
} }
} catch (e, st) { } catch (e, st) {
logSafe("Error in commentTask: $e", level: LogLevel.error, error: e, stackTrace: st); logSafe("Error in commentTask: $e",
level: LogLevel.error, error: e, stackTrace: st);
_showError("An error occurred while commenting the task."); _showError("An error occurred while commenting the task.");
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@ -224,7 +245,8 @@ class ReportTaskActionController extends MyController {
workStatus.assignAll(model.data); workStatus.assignAll(model.data);
logSafe("Fetched ${model.data.length} work statuses"); logSafe("Fetched ${model.data.length} work statuses");
} else { } else {
logSafe("No work statuses found or API call failed", level: LogLevel.warning); logSafe("No work statuses found or API call failed",
level: LogLevel.warning);
} }
isLoadingWorkStatus.value = false; isLoadingWorkStatus.value = false;
@ -251,7 +273,8 @@ class ReportTaskActionController extends MyController {
}; };
})); }));
logSafe("_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images."); logSafe(
"_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images.");
return results.whereType<Map<String, dynamic>>().toList(); return results.whereType<Map<String, dynamic>>().toList();
} }
@ -267,23 +290,40 @@ class ReportTaskActionController extends MyController {
} }
Future<void> pickImages({required bool fromCamera}) async { Future<void> pickImages({required bool fromCamera}) async {
logSafe("Opening image picker..."); try {
if (fromCamera) { isPickingImage.value = true; // start loading
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); logSafe("Opening image picker...");
if (pickedFile != null) {
selectedImages.add(File(pickedFile.path)); if (fromCamera) {
logSafe("Image added from camera: ${pickedFile.path}", ); final pickedFile = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 75,
);
if (pickedFile != null) {
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path),
);
selectedImages.add(timestampedFile);
logSafe("Image added from camera with timestamp: ${pickedFile.path}");
}
} else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
logSafe("${pickedFiles.length} images added from gallery.");
} }
} else { } catch (e, st) {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); logSafe("Error picking images: $e",
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); level: LogLevel.error, stackTrace: st);
logSafe("${pickedFiles.length} images added from gallery.", ); } finally {
isPickingImage.value = false; // stop loading
} }
} }
void removeImageAt(int index) { void removeImageAt(int index) {
if (index >= 0 && index < selectedImages.length) { if (index >= 0 && index < selectedImages.length) {
logSafe("Removing image at index $index", ); logSafe(
"Removing image at index $index",
);
selectedImages.removeAt(index); selectedImages.removeAt(index);
} }
} }

View File

@ -1,20 +1,22 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:on_field_work/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/widgets/my_image_compressor.dart'; import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
enum ApiStatus { idle, loading, success, failure } enum ApiStatus { idle, loading, success, failure }
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController()); final DailyTaskPlanningController taskController =
Get.put(DailyTaskPlanningController());
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
class ReportTaskController extends MyController { class ReportTaskController extends MyController {
@ -23,6 +25,7 @@ class ReportTaskController extends MyController {
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
Rx<ApiStatus> reportStatus = ApiStatus.idle.obs; Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs; Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
final RxBool isPickingImage = false.obs;
RxList<File> selectedImages = <File>[].obs; RxList<File> selectedImages = <File>[].obs;
@ -43,17 +46,27 @@ class ReportTaskController extends MyController {
super.onInit(); super.onInit();
logSafe("Initializing ReportTaskController..."); logSafe("Initializing ReportTaskController...");
basicValidator basicValidator
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController) ..addField('assigned_date',
..addField('work_area', label: "Work Area", controller: workAreaController) label: "Assigned Date", controller: assignedDateController)
..addField('work_area',
label: "Work Area", controller: workAreaController)
..addField('activity', label: "Activity", controller: activityController) ..addField('activity', label: "Activity", controller: activityController)
..addField('team_size', label: "Team Size", controller: teamSizeController) ..addField('team_size',
label: "Team Size", controller: teamSizeController)
..addField('task_id', label: "Task Id", controller: taskIdController) ..addField('task_id', label: "Task Id", controller: taskIdController)
..addField('assigned', label: "Assigned", controller: assignedController) ..addField('assigned', label: "Assigned", controller: assignedController)
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController) ..addField('completed_work',
..addField('comment', label: "Comment", required: true, controller: commentController) label: "Completed Work",
..addField('assigned_by', label: "Assigned By", controller: assignedByController) required: true,
..addField('team_members', label: "Team Members", controller: teamMembersController) controller: completedWorkController)
..addField('planned_work', label: "Planned Work", controller: plannedWorkController); ..addField('comment',
label: "Comment", required: true, controller: commentController)
..addField('assigned_by',
label: "Assigned By", controller: assignedByController)
..addField('team_members',
label: "Team Members", controller: teamMembersController)
..addField('planned_work',
label: "Planned Work", controller: plannedWorkController);
logSafe("Form fields initialized."); logSafe("Form fields initialized.");
} }
@ -83,9 +96,13 @@ class ReportTaskController extends MyController {
required DateTime reportedDate, required DateTime reportedDate,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Reporting task for projectId", ); logSafe(
"Reporting task for projectId",
);
final completedWork = completedWorkController.text.trim(); final completedWork = completedWorkController.text.trim();
if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) { if (completedWork.isEmpty ||
int.tryParse(completedWork) == null ||
int.parse(completedWork) < 0) {
_showError("Completed work must be a positive number."); _showError("Completed work must be a positive number.");
return false; return false;
} }
@ -121,7 +138,8 @@ class ReportTaskController extends MyController {
return false; return false;
} }
} catch (e, s) { } catch (e, s) {
logSafe("Exception while reporting task", level: LogLevel.error, error: e, stackTrace: s); logSafe("Exception while reporting task",
level: LogLevel.error, error: e, stackTrace: s);
reportStatus.value = ApiStatus.failure; reportStatus.value = ApiStatus.failure;
_showError("An error occurred while reporting the task."); _showError("An error occurred while reporting the task.");
return false; return false;
@ -138,7 +156,9 @@ class ReportTaskController extends MyController {
required String comment, required String comment,
List<File>? images, List<File>? images,
}) async { }) async {
logSafe("Submitting comment for project", ); logSafe(
"Submitting comment for project",
);
final commentField = commentController.text.trim(); final commentField = commentController.text.trim();
if (commentField.isEmpty) { if (commentField.isEmpty) {
@ -166,14 +186,16 @@ class ReportTaskController extends MyController {
_showError("Failed to comment task."); _showError("Failed to comment task.");
} }
} catch (e, s) { } catch (e, s) {
logSafe("Exception while commenting task", level: LogLevel.error, error: e, stackTrace: s); logSafe("Exception while commenting task",
level: LogLevel.error, error: e, stackTrace: s);
_showError("An error occurred while commenting the task."); _showError("An error occurred while commenting the task.");
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images, String context) async { Future<List<Map<String, dynamic>>?> _prepareImages(
List<File>? images, String context) async {
if (images == null || images.isEmpty) return null; if (images == null || images.isEmpty) return null;
logSafe("Preparing images for $context upload..."); logSafe("Preparing images for $context upload...");
@ -191,7 +213,8 @@ class ReportTaskController extends MyController {
"description": "Image uploaded for $context", "description": "Image uploaded for $context",
}; };
} catch (e) { } catch (e) {
logSafe("Image processing failed: ${file.path}", level: LogLevel.warning, error: e); logSafe("Image processing failed: ${file.path}",
level: LogLevel.warning, error: e);
return null; return null;
} }
})); }));
@ -212,18 +235,31 @@ class ReportTaskController extends MyController {
Future<void> pickImages({required bool fromCamera}) async { Future<void> pickImages({required bool fromCamera}) async {
try { try {
isPickingImage.value = true; // Start loading
if (fromCamera) { if (fromCamera) {
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75); final pickedFile = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 75,
);
if (pickedFile != null) { if (pickedFile != null) {
selectedImages.add(File(pickedFile.path)); // Only camera images get timestamp
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(pickedFile.path),
);
selectedImages.add(timestampedFile);
} }
} else { } else {
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75); final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
// Gallery images added as-is without timestamp
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path))); selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
} }
logSafe("Images picked: ${selectedImages.length}", );
logSafe("Images picked: ${selectedImages.length}");
} catch (e) { } catch (e) {
logSafe("Error picking images", level: LogLevel.warning, error: e); logSafe("Error picking images", level: LogLevel.warning, error: e);
} finally {
isPickingImage.value = false; // Stop loading
} }
} }

View File

@ -0,0 +1,66 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/all_organization_model.dart';
class AllOrganizationController extends GetxController {
RxList<AllOrganization> organizations = <AllOrganization>[].obs;
Rxn<AllOrganization> selectedOrganization = Rxn<AllOrganization>();
final isLoadingOrganizations = false.obs;
String? passedOrgId;
AllOrganizationController({this.passedOrgId});
@override
void onInit() {
super.onInit();
fetchAllOrganizations();
}
Future<void> fetchAllOrganizations() async {
try {
isLoadingOrganizations.value = true;
final response = await ApiService.getAllOrganizations();
if (response != null && response.data.data.isNotEmpty) {
organizations.value = response.data.data;
// Select organization based on passed ID, or fallback to first
if (passedOrgId != null) {
selectedOrganization.value =
organizations.firstWhere(
(org) => org.id == passedOrgId,
orElse: () => organizations.first,
);
} else {
selectedOrganization.value ??= organizations.first;
}
} else {
organizations.clear();
selectedOrganization.value = null;
}
} catch (e, stackTrace) {
logSafe(
"Failed to fetch organizations: $e",
level: LogLevel.error,
error: e,
stackTrace: stackTrace,
);
organizations.clear();
selectedOrganization.value = null;
} finally {
isLoadingOrganizations.value = false;
}
}
void selectOrganization(AllOrganization? org) {
selectedOrganization.value = org;
}
void clearSelection() {
selectedOrganization.value = null;
}
String get currentSelection => selectedOrganization.value?.name ?? "All Organizations";
}

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
class OrganizationController extends GetxController { class OrganizationController extends GetxController {
/// List of organizations assigned to the selected project /// List of organizations assigned to the selected project

View File

@ -1,7 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/tenant/tenant_services_model.dart'; import 'package:on_field_work/model/tenant/tenant_services_model.dart';
class ServiceController extends GetxController { class ServiceController extends GetxController {
List<Service> services = []; List<Service> services = [];

View File

@ -1,15 +1,25 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:marco/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService(); final TenantService _tenantService = TenantService();
var tenants = <Tenant>[].obs; // Tenant list
var isLoading = false.obs; final tenants = <Tenant>[].obs;
// Loading state
final isLoading = false.obs;
// Selected tenant ID
final selectedTenantId = RxnString();
// Flag to indicate auto-selection (for splash screen)
final isAutoSelecting = false.obs;
@override @override
void onInit() { void onInit() {
@ -17,83 +27,97 @@ class TenantSelectionController extends GetxController {
loadTenants(); loadTenants();
} }
/// Load tenants from API /// Load tenants and handle auto-selection
Future<void> loadTenants({bool fromTenantSelectionScreen = false}) async { Future<void> loadTenants() async {
isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection
try { try {
isLoading.value = true;
final data = await _tenantService.getTenants(); final data = await _tenantService.getTenants();
if (data != null) { if (data == null || data.isEmpty) {
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
final recentTenantId = LocalStorage.getRecentTenantId();
// If user came from TenantSelectionScreen & recent tenant exists, auto-select
if (fromTenantSelectionScreen && recentTenantId != null) {
final tenantExists = tenants.any((t) => t.id == recentTenantId);
if (tenantExists) {
await onTenantSelected(recentTenantId);
return;
} else {
// if tenant is no longer valid, clear recentTenant
await LocalStorage.removeRecentTenantId();
}
}
// Auto-select if only one tenant
if (tenants.length == 1) {
await onTenantSelected(tenants.first.id);
}
} else {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
return;
}
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
final recentTenantId = LocalStorage.getRecentTenantId();
// Auto-select if only one tenant
if (tenants.length == 1) {
await _selectTenant(tenants.first.id);
}
// Auto-select recent tenant if available
else if (recentTenantId != null) {
final recentTenant =
tenants.firstWhereOrNull((t) => t.id == recentTenantId);
if (recentTenant != null) {
await _selectTenant(recentTenant.id);
} else {
_clearSelection();
}
}
// No auto-selection
else {
_clearSelection();
} }
} catch (e, st) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", logSafe("❌ Exception in loadTenants",
level: LogLevel.error, error: e, stackTrace: st); level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations. Please try again.",
type: SnackbarType.error,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
isAutoSelecting.value = false; // hide splash
} }
} }
/// Select tenant /// User manually selects a tenant
Future<void> onTenantSelected(String tenantId) async { Future<void> onTenantSelected(String tenantId) async {
isAutoSelecting.value = true;
await _selectTenant(tenantId);
isAutoSelecting.value = false;
}
/// Internal tenant selection logic
Future<void> _selectTenant(String tenantId) async {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (success) { if (!success) {
logSafe("✅ Tenant selection successful: $tenantId");
// Store selected tenant in memory
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
// 🔥 Save in LocalStorage
await LocalStorage.setRecentTenantId(tenantId);
// Navigate to dashboard
Get.offAllNamed('/dashboard');
showAppSnackbar(
title: "Success",
message: "Organization selected successfully.",
type: SnackbarType.success,
);
} else {
logSafe("❌ Tenant selection failed for: $tenantId",
level: LogLevel.warning);
// Show error snackbar
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Unable to select organization. Please try again.", message: "Unable to select organization. Please try again.",
type: SnackbarType.error, type: SnackbarType.error,
); );
return;
} }
} catch (e, st) {
logSafe("❌ Exception in onTenantSelected",
level: LogLevel.error, error: e, stackTrace: st);
// Show error snackbar for exception // Update tenant & persist
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId;
await LocalStorage.setRecentTenantId(tenantId);
// Load permissions if token exists
final token = LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
}
await Get.find<PermissionController>().loadData(token);
}
// Navigate **before changing isAutoSelecting**
await Get.offAllNamed('/dashboard');
// Then hide splash
isAutoSelecting.value = false;
} catch (e) {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "An unexpected error occurred while selecting organization.", message: "An unexpected error occurred while selecting organization.",
@ -103,4 +127,10 @@ class TenantSelectionController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
/// Clear tenant selection
void _clearSelection() {
selectedTenantId.value = null;
TenantService.currentTenant = null;
}
} }

View File

@ -0,0 +1,106 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart';
class TenantSwitchController extends GetxController {
final TenantService _tenantService = TenantService();
final tenants = <Tenant>[].obs;
final isLoading = false.obs;
final selectedTenantId = RxnString();
@override
void onInit() {
super.onInit();
loadTenants();
}
/// Load all tenants for switching (does not auto-select)
Future<void> loadTenants() async {
isLoading.value = true;
try {
final data = await _tenantService.getTenants();
if (data == null || data.isEmpty) {
tenants.clear();
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
return;
}
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
// Keep current tenant as selected
selectedTenantId.value = TenantService.currentTenant?.id;
} catch (e, st) {
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to load organizations for switching.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Switch to a different tenant and navigate fully
Future<void> switchTenant(String tenantId) async {
if (TenantService.currentTenant?.id == tenantId) return;
isLoading.value = true;
try {
final success = await _tenantService.selectTenant(tenantId);
if (!success) {
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
showAppSnackbar(
title: "Error",
message: "Unable to switch organization. Try again.",
type: SnackbarType.error,
);
return;
}
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId;
// Persist recent tenant
await LocalStorage.setRecentTenantId(tenantId);
logSafe("✅ Tenant switched successfully: $tenantId");
// 🔹 Load permissions after tenant switch (null-safe)
final token = await LocalStorage.getJwtToken();
if (token != null && token.isNotEmpty) {
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after tenant switch.");
}
await Get.find<PermissionController>().loadData(token);
} else {
logSafe("⚠️ JWT token is null. Cannot load permissions.", level: LogLevel.warning);
}
// FULL NAVIGATION: reload app/dashboard
Get.offAllNamed('/dashboard');
showAppSnackbar(
title: "Success",
message: "Switched to organization: ${selectedTenant.name}",
type: SnackbarType.success,
);
} catch (e, st) {
logSafe("❌ Exception in switchTenant", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred while switching organization.",
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -1,4 +1,4 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class ButtonsController extends MyController { class ButtonsController extends MyController {
List<bool> selected = List.filled(3, false); List<bool> selected = List.filled(3, false);

View File

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:carousel_slider/carousel_controller.dart'; import 'package:carousel_slider/carousel_controller.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CarouselsController extends MyController { class CarouselsController extends MyController {

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart'; import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
class DialogsController extends MyController { class DialogsController extends MyController {
List<String> dummyTexts = List<String> dummyTexts =

View File

@ -1,3 +1,3 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
class LoadersController extends MyController {} class LoadersController extends MyController {}

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart'; import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
import 'package:flutter/animation.dart'; import 'package:flutter/animation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/extensions/string.dart'; import 'package:on_field_work/helpers/extensions/string.dart';
import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:on_field_work/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/widgets/my_button.dart'; import 'package:on_field_work/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';

View File

@ -1,5 +1,5 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_text_utils.dart'; import 'package:on_field_work/helpers/widgets/my_text_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TabsController extends MyController { class TabsController extends MyController {

View File

@ -1,4 +1,4 @@
import 'package:marco/controller/my_controller.dart'; import 'package:on_field_work/controller/my_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ToastMessageController extends MyController { class ToastMessageController extends MyController {

View File

@ -1,5 +1,5 @@
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:marco/helpers/theme/app_notifier.dart'; import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View File

@ -1,5 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:marco/helpers/services/localizations/translator.dart'; import 'package:on_field_work/helpers/services/localizations/translator.dart';
extension StringUtil on String { extension StringUtil on String {
Color get toColor { Color get toColor {

View File

@ -2,19 +2,53 @@ class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories =
"/Master/expenses-categories";
static const String getExpensePaymentRequestPayee =
"/Expense/payment-request/payee";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview"; "/dashboard/attendance-overview";
static const String createExpensePaymentRequest =
"/expense/payment-request/create";
static const String getExpensePaymentRequestList =
"/Expense/get/payment-requests/list";
static const String getExpensePaymentRequestDetails =
"/Expense/get/payment-request/details";
static const String getExpensePaymentRequestFilter =
"/Expense/payment-request/filter";
static const String updateExpensePaymentRequestStatus =
"/Expense/payment-request/action";
static const String createExpenseforPR = "/expense/payment-request/action";
static const String getExpensePaymentRequestEdit =
"/expense/payment-request/edit";
static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects"; static const String getDashboardProjects = "/dashboard/projects";
static const String getDashboardMonthlyExpenses =
"/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";
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team"; static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
@ -22,6 +56,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
@ -41,6 +76,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
@ -52,6 +88,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
@ -66,7 +104,7 @@ class ApiEndpoints {
static const String editExpense = "/Expense/edit"; static const String editExpense = "/Expense/edit";
static const String getMasterPaymentModes = "/master/payment-modes"; static const String getMasterPaymentModes = "/master/payment-modes";
static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseStatus = "/master/expenses-status";
static const String getMasterExpenseTypes = "/master/expenses-types"; static const String getMasterExpenseCategory = "/master/expenses-categories";
static const String updateExpenseStatus = "/expense/action"; static const String updateExpenseStatus = "/expense/action";
static const String deleteExpense = "/expense/delete"; static const String deleteExpense = "/expense/delete";
@ -93,5 +131,40 @@ class ApiEndpoints {
static const String getAssignedOrganizations = static const String getAssignedOrganizations =
"/project/get/assigned/organization"; "/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
static const String getAdvancePayments = '/Expense/get/transactions';
// Organization Hierarchy endpoints
static const String getOrganizationHierarchyList =
"/organization/hierarchy/list";
static const String manageOrganizationHierarchy =
"/organization/hierarchy/manage";
// Service Project Module API Endpoints
static const String getServiceProjectsList = "/serviceproject/list";
static const String getServiceProjectDetail = "/serviceproject/details";
static const String getServiceProjectJobList = "/serviceproject/job/list";
static const String getServiceProjectJobDetail =
"/serviceproject/job/details";
static const String editServiceProjectJob = "/serviceproject/job/edit";
static const String createServiceProjectJob = "/serviceproject/job/create";
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log";
static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list";
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";
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,13 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:url_strategy/url_strategy.dart'; import 'package:url_strategy/url_strategy.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/firebase/firebase_messaging_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/device_info_service.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:marco/helpers/services/device_info_service.dart';
import 'package:marco/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/theme/app_theme.dart';
Future<void> initializeApp() async { Future<void> initializeApp() async {
try { try {
@ -26,7 +22,6 @@ Future<void> initializeApp() async {
await _setupDeviceInfo(); await _setupDeviceInfo();
await _handleAuthTokens(); await _handleAuthTokens();
await _setupTheme(); await _setupTheme();
await _setupControllers();
await _setupFirebaseMessaging(); await _setupFirebaseMessaging();
_finalizeAppStyle(); _finalizeAppStyle();
@ -43,6 +38,19 @@ Future<void> initializeApp() async {
} }
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupUI() async { Future<void> _setupUI() async {
setPathUrlStrategy(); setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -69,50 +77,11 @@ Future<void> _setupDeviceInfo() async {
logSafe("📱 Device Info: ${deviceInfoService.deviceData}"); logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
} }
Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken();
if (!success) {
logSafe(
"⚠️ Refresh token invalid or expired. Skipping controller injection.");
}
} else {
logSafe("❌ No refresh token found. Skipping refresh.");
}
}
Future<void> _setupTheme() async { Future<void> _setupTheme() async {
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("💡 Theme customizer initialized.");
} }
Future<void> _setupControllers() async {
final token = LocalStorage.getString('jwt_token');
if (token?.isEmpty ?? true) {
logSafe("⚠️ No valid JWT token found. Skipping controller initialization.");
return;
}
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("💡 PermissionController injected.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("💡 ProjectController injected as permanent.");
}
await Future.wait([
Get.find<PermissionController>().loadData(token!),
Get.find<ProjectController>().fetchProjects(),
]);
}
// Commented out Firebase Messaging setup
Future<void> _setupFirebaseMessaging() async { Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize(); await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized."); logSafe("💡 Firebase Messaging initialized.");

View File

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
/// Global logger instance /// Global logger instance
Logger? _appLogger; Logger? _appLogger;

View File

@ -1,12 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart';
class AuthService { class AuthService {
static const String _baseUrl = ApiEndpoints.baseUrl; static const String _baseUrl = ApiEndpoints.baseUrl;
@ -98,8 +94,8 @@ class AuthService {
} }
static Future<bool> refreshToken() async { static Future<bool> refreshToken() async {
final accessToken = await LocalStorage.getJwtToken(); final accessToken = LocalStorage.getJwtToken();
final refreshToken = await LocalStorage.getRefreshToken(); final refreshToken = LocalStorage.getRefreshToken();
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) { if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
logSafe("Missing access or refresh token.", level: LogLevel.warning); logSafe("Missing access or refresh token.", level: LogLevel.warning);
@ -115,7 +111,7 @@ class AuthService {
logSafe("Token refreshed successfully."); logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh // 🔹 Retry FCM token registration after token refresh
final newFcmToken = await LocalStorage.getFcmToken(); final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) { if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!); final success = await registerDeviceToken(newFcmToken!);
logSafe( logSafe(
@ -157,7 +153,7 @@ class AuthService {
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final token = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
return _post( return _post(
"/auth/generate-mpin", "/auth/generate-mpin",
{"employeeId": employeeId, "mpin": mpin}, {"employeeId": employeeId, "mpin": mpin},
@ -290,30 +286,6 @@ class AuthService {
await LocalStorage.setIsMpin(false); await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
if (!Get.isRegistered<PermissionController>()) {
Get.put(PermissionController());
logSafe("✅ PermissionController injected after login.");
}
if (!Get.isRegistered<ProjectController>()) {
Get.put(ProjectController(), permanent: true);
logSafe("✅ ProjectController injected after login.");
}
await Get.find<PermissionController>().loadData(data['token']);
await Get.find<ProjectController>().fetchProjects();
// 🔹 Always try to register FCM token after login
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after login."
: "⚠️ Failed to register FCM token after login.",
level: success ? LogLevel.info : LogLevel.warning);
}
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized."); logSafe("✅ Login flow completed and controllers initialized.");
} }

View File

@ -1,10 +1,10 @@
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/helpers/services/local_notification_service.dart'; import 'package:on_field_work/helpers/services/local_notification_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/notification_action_handler.dart'; import 'package:on_field_work/helpers/services/notification_action_handler.dart';
/// Firebase Notification Service /// Firebase Notification Service
class FirebaseNotificationService { class FirebaseNotificationService {
@ -19,7 +19,7 @@ class FirebaseNotificationService {
_registerMessageListeners(); _registerMessageListeners();
_registerTokenRefreshListener(); _registerTokenRefreshListener();
// Fetch token on app start (but only register with server if JWT available) // Fetch token on app start (and register with server if JWT available)
await getFcmToken(registerOnServer: true); await getFcmToken(registerOnServer: true);
} }
@ -49,6 +49,7 @@ class FirebaseNotificationService {
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// Background messages
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
} }
@ -111,8 +112,6 @@ class FirebaseNotificationService {
} }
} }
/// Handle tap on notification /// Handle tap on notification
void _handleNotificationTap(RemoteMessage message) { void _handleNotificationTap(RemoteMessage message) {
_logger.i('📌 Notification tapped: ${message.data}'); _logger.i('📌 Notification tapped: ${message.data}');
@ -129,7 +128,9 @@ class FirebaseNotificationService {
} }
} }
/// Background handler (required by Firebase) /// 🔹 Background handler (required by Firebase)
/// Must be a top-level function and annotated for AOT
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final logger = Logger(); final logger = Logger();
logger logger

View File

@ -1,5 +1,5 @@
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class Language { class Language {

View File

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get_utils/src/extensions/string_extensions.dart'; import 'package:get/get_utils/src/extensions/string_extensions.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@ -1,17 +1,17 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.dart'; import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart'; import 'package:on_field_work/controller/task_planning/daily_task_controller.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:on_field_work/controller/expense/expense_screen_controller.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:on_field_work/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:on_field_work/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:on_field_work/controller/directory/notes_controller.dart';
import 'package:marco/controller/document/user_document_controller.dart'; import 'package:on_field_work/controller/document/user_document_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart'; import 'package:on_field_work/controller/document/document_details_controller.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers. /// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler { class NotificationActionHandler {
@ -69,7 +69,6 @@ class NotificationActionHandler {
} }
break; break;
case 'Team_Modified': case 'Team_Modified':
// Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data); _handleDashboardUpdate(data);
break; break;
@ -129,6 +128,11 @@ class NotificationActionHandler {
/// ---------------------- HANDLERS ---------------------- /// ---------------------- HANDLERS ----------------------
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) { static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored task planning update from another project.");
return;
}
final projectId = data['ProjectId']; final projectId = data['ProjectId'];
if (projectId == null) { if (projectId == null) {
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data"); _logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
@ -159,13 +163,17 @@ class NotificationActionHandler {
} }
static void _handleExpenseUpdated(Map<String, dynamic> data) { static void _handleExpenseUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored expense update from another project.");
return;
}
final expenseId = data['ExpenseId']; final expenseId = data['ExpenseId'];
if (expenseId == null) { if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data"); _logger.w("⚠️ Expense update received without ExpenseId: $data");
return; return;
} }
// Update Expense List
_safeControllerUpdate<ExpenseController>( _safeControllerUpdate<ExpenseController>(
onFound: (controller) async { onFound: (controller) async {
await controller.fetchExpenses(); await controller.fetchExpenses();
@ -175,7 +183,6 @@ class NotificationActionHandler {
'✅ ExpenseController refreshed from expense notification.', '✅ ExpenseController refreshed from expense notification.',
); );
// Update Expense Detail (if open and matches this expenseId)
_safeControllerUpdate<ExpenseDetailController>( _safeControllerUpdate<ExpenseDetailController>(
onFound: (controller) async { onFound: (controller) async {
if (controller.expense.value?.id == expenseId) { if (controller.expense.value?.id == expenseId) {
@ -190,6 +197,11 @@ class NotificationActionHandler {
} }
static void _handleAttendanceUpdated(Map<String, dynamic> data) { static void _handleAttendanceUpdated(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored attendance update from another project.");
return;
}
_safeControllerUpdate<AttendanceController>( _safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification( onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'], projectId: data['ProjectId'],
@ -201,6 +213,11 @@ class NotificationActionHandler {
static void _handleTaskUpdated(Map<String, dynamic> data, static void _handleTaskUpdated(Map<String, dynamic> data,
{required bool isComment}) { {required bool isComment}) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored task update from another project.");
return;
}
_safeControllerUpdate<DailyTaskController>( _safeControllerUpdate<DailyTaskController>(
onFound: (controller) => controller.refreshTasksFromNotification( onFound: (controller) => controller.refreshTasksFromNotification(
projectId: data['ProjectId'], projectId: data['ProjectId'],
@ -213,11 +230,15 @@ class NotificationActionHandler {
/// ---------------------- DOCUMENT HANDLER ---------------------- /// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) { static void _handleDocumentModified(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored document update from another project.");
return;
}
String entityTypeId; String entityTypeId;
String entityId; String entityId;
String? documentId = data['DocumentId']; String? documentId = data['DocumentId'];
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') { if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity; entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? ''; entityId = data['EmployeeId'] ?? '';
@ -237,7 +258,6 @@ class NotificationActionHandler {
_logger.i( _logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId"); "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) { if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>( _safeControllerUpdate<DocumentController>(
onFound: (controller) async { onFound: (controller) async {
@ -255,11 +275,9 @@ class NotificationActionHandler {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.'); _logger.w('⚠️ DocumentController not registered, skipping list refresh.');
} }
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) { if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>( _safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async { onFound: (controller) async {
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId); await controller.fetchDocumentDetails(documentId);
_logger.i( _logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId"); "✅ DocumentDetailsController refreshed for Document $documentId");
@ -276,13 +294,10 @@ class NotificationActionHandler {
/// ---------------------- DIRECTORY HANDLERS ---------------------- /// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) { static void _handleContactModified(Map<String, dynamic> data) {
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>( _safeControllerUpdate<DirectoryController>(
onFound: (controller) { onFound: (controller) {
controller.fetchContacts(); controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well final contactId = data['ContactId'];
if (contactId != null) { if (contactId != null) {
controller.fetchCommentsForContact(contactId); controller.fetchCommentsForContact(contactId);
} }
@ -293,7 +308,6 @@ class NotificationActionHandler {
'✅ Directory contacts (and notes if applicable) refreshed from notification.', '✅ Directory contacts (and notes if applicable) refreshed from notification.',
); );
// Refresh notes globally as well
_safeControllerUpdate<NotesController>( _safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(), onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.', notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
@ -302,7 +316,6 @@ class NotificationActionHandler {
} }
static void _handleContactNoteModified(Map<String, dynamic> data) { static void _handleContactNoteModified(Map<String, dynamic> data) {
// Refresh both contacts and notes when a note is modified
_handleContactModified(data); _handleContactModified(data);
} }
@ -324,6 +337,11 @@ class NotificationActionHandler {
/// ---------------------- DASHBOARD HANDLER ---------------------- /// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) { static void _handleDashboardUpdate(Map<String, dynamic> data) {
if (!_isCurrentProject(data)) {
_logger.i(" Ignored dashboard update from another project.");
return;
}
_safeControllerUpdate<DashboardController>( _safeControllerUpdate<DashboardController>(
onFound: (controller) async { onFound: (controller) async {
final type = data['type'] ?? ''; final type = data['type'] ?? '';
@ -347,11 +365,9 @@ class NotificationActionHandler {
controller.projectController.selectedProjectId.value; controller.projectController.selectedProjectId.value;
final projectIdsString = data['ProjectIds'] ?? ''; final projectIdsString = data['ProjectIds'] ?? '';
// Convert comma-separated string to List<String>
final notificationProjectIds = final notificationProjectIds =
projectIdsString.split(',').map((e) => e.trim()).toList(); projectIdsString.split(',').map((e) => e.trim()).toList();
// Refresh only if current project ID is in the list
if (notificationProjectIds.contains(currentProjectId)) { if (notificationProjectIds.contains(currentProjectId)) {
await controller.fetchDashboardTeams(projectId: currentProjectId); await controller.fetchDashboardTeams(projectId: currentProjectId);
} }
@ -375,17 +391,40 @@ class NotificationActionHandler {
/// ---------------------- UTILITY ---------------------- /// ---------------------- UTILITY ----------------------
static bool _isCurrentProject(Map<String, dynamic> data) {
try {
final dashboard = Get.find<DashboardController>();
final currentProjectId =
dashboard.projectController.selectedProjectId.value;
final notificationProjectId = data['ProjectId']?.toString();
if (notificationProjectId == null || notificationProjectId.isEmpty) {
return true; // No project info allow global refresh
}
return notificationProjectId == currentProjectId;
} catch (e) {
_logger.w("⚠️ Could not verify project context: $e");
return true;
}
}
static void _safeControllerUpdate<T>({ static void _safeControllerUpdate<T>({
required void Function(T controller) onFound, required void Function(T controller) onFound,
required String notFoundMessage, required String notFoundMessage,
required String successMessage, required String successMessage,
}) { }) {
if (!Get.isRegistered<T>()) {
_logger.w(notFoundMessage);
return;
}
try { try {
final controller = Get.find<T>(); final controller = Get.find<T>();
onFound(controller); onFound(controller);
_logger.i(successMessage); _logger.i(successMessage);
} catch (e) { } catch (e) {
_logger.w(notFoundMessage); _logger.w('⚠️ Error updating controller: $e');
} }
} }
} }

View File

@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/model/user_permission.dart'; import 'package:on_field_work/model/user_permission.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:marco/model/projects_model.dart'; import 'package:on_field_work/model/projects_model.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart';
class PermissionService { class PermissionService {
// In-memory cache keyed by user token // In-memory cache keyed by user token

View File

@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:marco/model/user_permission.dart'; import 'package:on_field_work/model/user_permission.dart';
import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart'; import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
class LocalStorage { class LocalStorage {
static const String _loggedInUserKey = "user"; static const String _loggedInUserKey = "user";

View File

@ -1,13 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:on_field_work/controller/project_controller.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:marco/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
/// Abstract interface for tenant service functionality /// Abstract interface for tenant service functionality
abstract class ITenantService { abstract class ITenantService {
@ -63,29 +63,39 @@ class TenantService implements ITenantService {
{bool hasRetried = false}) async { {bool hasRetried = false}) async {
try { try {
final headers = await _authorizedHeaders(); final headers = await _authorizedHeaders();
logSafe("➡️ GET $_baseUrl/auth/get/user/tenants\nHeaders: $headers",
level: LogLevel.info);
final response = await http final response = await http.get(
.get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers); Uri.parse("$_baseUrl/auth/get/user/tenants"),
final data = jsonDecode(response.body); headers: headers,
);
logSafe( // Handle empty response BEFORE decoding
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", if (response.body.isEmpty || response.body.trim().isEmpty) {
level: LogLevel.info); logSafe("❌ Empty tenant response — auto logout");
await LocalStorage.logout();
if (response.statusCode == 200 && data['success'] == true) { return null;
logSafe("✅ Tenants fetched successfully.");
return List<Map<String, dynamic>>.from(data['data']);
} }
Map<String, dynamic> data;
try {
data = jsonDecode(response.body);
} catch (e) {
logSafe("❌ Invalid JSON in tenant response — auto logout");
await LocalStorage.logout();
return null;
}
// SUCCESS CASE
if (response.statusCode == 200 && data['success'] == true) {
final list = data['data'];
if (list is! List) return null;
return List<Map<String, dynamic>>.from(list);
}
// TOKEN EXPIRED
if (response.statusCode == 401 && !hasRetried) { if (response.statusCode == 401 && !hasRetried) {
logSafe("⚠️ Unauthorized while fetching tenants. Refreshing token...",
level: LogLevel.warning);
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) return getTenants(hasRetried: true); if (refreshed) return getTenants(hasRetried: true);
logSafe("❌ Token refresh failed while fetching tenants.",
level: LogLevel.error);
return null; return null;
} }
@ -129,6 +139,17 @@ class TenantService implements ITenantService {
logSafe("⚠️ ProjectController not found while refreshing projects"); logSafe("⚠️ ProjectController not found while refreshing projects");
} }
// 🔹 Register FCM token after tenant selection
final fcmToken = LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after tenant selection."
: "⚠️ Failed to register FCM token after tenant selection.",
level: success ? LogLevel.info : LogLevel.warning);
}
return true; return true;
} }

View File

@ -1,35 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
enum LeftBarThemeType { light, dark } enum LeftBarThemeType { light, dark }
enum ContentThemeType { light, dark } enum ContentThemeType { light, dark }
enum RightBarThemeType { light, dark } enum RightBarThemeType { light, dark }
enum ContentThemeColor {
primary,
secondary,
success,
info,
warning,
danger,
light,
dark,
pink,
green,
red,
brandRed;
Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black;
}
Color get onColor {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['onColor']) ?? Colors.white;
}
}
class LeftBarTheme { class LeftBarTheme {
final Color background, onBackground; final Color background, onBackground;
final Color labelColor; final Color labelColor;
@ -43,16 +18,15 @@ class LeftBarTheme {
this.activeItemBackground = const Color(0x15663399), this.activeItemBackground = const Color(0x15663399),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final LeftBarTheme lightLeftBarTheme = LeftBarTheme(); static final LeftBarTheme lightLeftBarTheme = LeftBarTheme();
static final LeftBarTheme darkLeftBarTheme = LeftBarTheme( static final LeftBarTheme darkLeftBarTheme = LeftBarTheme(
background: const Color(0xff282c32), background: const Color(0xff282c32),
onBackground: const Color(0xffdcdcdc), onBackground: const Color(0xffdcdcdc),
labelColor: const Color(0xff32BFAE), labelColor: const Color(0xff32BFAE),
activeItemBackground: const Color(0x1532BFAE), activeItemBackground: const Color(0x1532BFAE),
activeItemColor: const Color(0xff32BFAE)); activeItemColor: const Color(0xff32BFAE),
);
static LeftBarTheme getThemeFromType(LeftBarThemeType leftBarThemeType) { static LeftBarTheme getThemeFromType(LeftBarThemeType leftBarThemeType) {
switch (leftBarThemeType) { switch (leftBarThemeType) {
@ -73,11 +47,12 @@ class TopBarTheme {
this.onBackground = const Color(0xff313a46), this.onBackground = const Color(0xff313a46),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final TopBarTheme lightTopBarTheme = TopBarTheme(); static final TopBarTheme lightTopBarTheme = TopBarTheme();
static final TopBarTheme darkTopBarTheme = TopBarTheme(background: const Color(0xff2c3036), onBackground: const Color(0xffdcdcdc)); static final TopBarTheme darkTopBarTheme = TopBarTheme(
background: const Color(0xff2c3036),
onBackground: const Color(0xffdcdcdc),
);
} }
class RightBarTheme { class RightBarTheme {
@ -91,19 +66,41 @@ class RightBarTheme {
this.onDisabled = const Color(0xff313a46), this.onDisabled = const Color(0xff313a46),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final RightBarTheme lightRightBarTheme = RightBarTheme( static final RightBarTheme lightRightBarTheme = RightBarTheme(
disabled: const Color(0xffffffff), disabled: const Color(0xffffffff),
onDisabled: const Color(0xffdee2e6), onDisabled: const Color(0xffdee2e6),
activeSwitchBorderColor: const Color(0xff727cf5), activeSwitchBorderColor: const Color(0xff727cf5),
inactiveSwitchBorderColor: const Color(0xffdee2e6)); inactiveSwitchBorderColor: const Color(0xffdee2e6),
);
static final RightBarTheme darkRightBarTheme = RightBarTheme( static final RightBarTheme darkRightBarTheme = RightBarTheme(
disabled: const Color(0xff444d57), disabled: const Color(0xff444d57),
activeSwitchBorderColor: const Color(0xff727cf5), activeSwitchBorderColor: const Color(0xff727cf5),
inactiveSwitchBorderColor: const Color(0xffdee2e6), inactiveSwitchBorderColor: const Color(0xffdee2e6),
onDisabled: const Color(0xff515a65)); onDisabled: const Color(0xff515a65),
);
}
enum ContentThemeColor {
primary,
secondary,
success,
info,
warning,
danger,
light,
dark,
pink,
green,
red;
Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black;
}
Color get onColor {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['onColor']) ?? Colors.white;
}
} }
class ContentTheme { class ContentTheme {
@ -120,29 +117,11 @@ class ContentTheme {
final Color purple, onPurple; final Color purple, onPurple;
final Color pink, onPink; final Color pink, onPink;
final Color red, onRed; final Color red, onRed;
final Color brandRed, onBrandRed;
final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted; final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted;
final Color title; final Color title;
final Color disabled, onDisabled; final Color disabled, onDisabled;
Map<ContentThemeColor, Map<String, Color>> get getMappedIntoThemeColor {
var c = AdminTheme.theme.contentTheme;
return {
ContentThemeColor.primary: {'color': c.primary, 'onColor': c.onPrimary},
ContentThemeColor.secondary: {'color': c.secondary, 'onColor': c.onSecondary},
ContentThemeColor.success: {'color': c.success, 'onColor': c.onSuccess},
ContentThemeColor.info: {'color': c.info, 'onColor': c.onInfo},
ContentThemeColor.warning: {'color': c.warning, 'onColor': c.onWarning},
ContentThemeColor.danger: {'color': c.danger, 'onColor': c.onDanger},
ContentThemeColor.light: {'color': c.light, 'onColor': c.onLight},
ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark},
ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink},
ContentThemeColor.red: {'color': c.red, 'onColor': c.onRed},
ContentThemeColor.brandRed: {'color': c.brandRed, 'onColor': c.onBrandRed},
};
}
ContentTheme({ ContentTheme({
this.background = const Color(0xfffafbfe), this.background = const Color(0xfffafbfe),
this.onBackground = const Color(0xffF1F1F2), this.onBackground = const Color(0xffF1F1F2),
@ -163,13 +142,11 @@ class ContentTheme {
this.dark = const Color(0xff313a46), this.dark = const Color(0xff313a46),
this.onDark = const Color(0xffffffff), this.onDark = const Color(0xffffffff),
this.purple = const Color(0xff800080), this.purple = const Color(0xff800080),
this.onPurple = const Color(0xffFF0000), this.onPurple = const Color(0xffffffff),
this.pink = const Color(0xffFF1087), this.pink = const Color(0xffff1087),
this.onPink = const Color(0xffffffff), this.onPink = const Color(0xffffffff),
this.red = const Color(0xffFF0000), this.red = const Color(0xffff0000),
this.onRed = const Color(0xffffffff), this.onRed = const Color(0xffffffff),
this.brandRed = const Color.fromARGB(255, 255, 0, 0),
this.onBrandRed = const Color(0xffffffff),
this.cardBackground = const Color(0xffffffff), this.cardBackground = const Color(0xffffffff),
this.cardShadow = const Color(0xffffffff), this.cardShadow = const Color(0xffffffff),
this.cardBorder = const Color(0xffffffff), this.cardBorder = const Color(0xffffffff),
@ -180,44 +157,103 @@ class ContentTheme {
this.onDisabled = const Color(0xffffffff), this.onDisabled = const Color(0xffffffff),
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------// Map<ContentThemeColor, Map<String, Color>> get getMappedIntoThemeColor {
return {
ContentThemeColor.primary: {'color': primary, 'onColor': onPrimary},
ContentThemeColor.secondary: {'color': secondary, 'onColor': onSecondary},
ContentThemeColor.success: {'color': success, 'onColor': onSuccess},
ContentThemeColor.info: {'color': info, 'onColor': onInfo},
ContentThemeColor.warning: {'color': warning, 'onColor': onWarning},
ContentThemeColor.danger: {'color': danger, 'onColor': onDanger},
ContentThemeColor.light: {'color': light, 'onColor': onLight},
ContentThemeColor.dark: {'color': dark, 'onColor': onDark},
ContentThemeColor.pink: {'color': pink, 'onColor': onPink},
ContentThemeColor.red: {'color': red, 'onColor': onRed},
};
}
static final ContentTheme lightContentTheme = ContentTheme( ContentTheme copyWith({
primary: Color(0xff663399), Color? primary,
background: const Color(0xfffafbfe), Color? onPrimary,
onBackground: const Color(0xff313a46), Color? secondary,
cardBorder: const Color(0xffe8ecf1), Color? onSecondary,
cardBackground: const Color(0xffffffff), Color? background,
cardShadow: const Color(0xff9aa1ab), Color? onBackground,
cardText: const Color(0xff6c757d), }) {
title: const Color(0xff6c757d), return ContentTheme(
cardTextMuted: const Color(0xff98a6ad), primary: primary ?? this.primary,
brandRed: const Color.fromARGB(255, 255, 0, 0), onPrimary: onPrimary ?? this.onPrimary,
onBrandRed: const Color(0xffffffff), secondary: secondary ?? this.secondary,
); onSecondary: onSecondary ?? this.onSecondary,
background: background ?? this.background,
onBackground: onBackground ?? this.onBackground,
success: success,
onSuccess: onSuccess,
danger: danger,
onDanger: onDanger,
warning: warning,
onWarning: onWarning,
info: info,
onInfo: onInfo,
light: light,
onLight: onLight,
dark: dark,
onDark: onDark,
purple: purple,
onPurple: onPurple,
pink: pink,
onPink: onPink,
red: red,
onRed: onRed,
cardBackground: cardBackground,
cardShadow: cardShadow,
cardBorder: cardBorder,
cardText: cardText,
cardTextMuted: cardTextMuted,
title: title,
disabled: disabled,
onDisabled: onDisabled,
);
}
static final ContentTheme darkContentTheme = ContentTheme( static ContentTheme withColorTheme(
primary: Color(0xff32BFAE), ColorThemeType colorTheme, {
background: const Color(0xff343a40), ThemeMode mode = ThemeMode.light,
onBackground: const Color(0xffF1F1F2), }) {
disabled: const Color(0xff444d57), final baseTheme = mode == ThemeMode.light
onDisabled: const Color(0xff515a65), ? ContentTheme()
cardBorder: const Color(0xff464f5b), : ContentTheme(
cardBackground: const Color(0xff37404a), primary: const Color(0xff32BFAE),
cardShadow: const Color(0xff01030E), background: const Color(0xff343a40),
cardText: const Color(0xffaab8c5), onBackground: const Color(0xffF1F1F2),
title: const Color(0xffaab8c5), cardBorder: const Color(0xff464f5b),
cardTextMuted: const Color(0xff8391a2), cardBackground: const Color(0xff37404a),
brandRed: const Color.fromARGB(255, 255, 0, 0), cardShadow: const Color(0xff01030E),
onBrandRed: const Color(0xffffffff), cardText: const Color(0xffaab8c5),
); title: const Color(0xffaab8c5),
cardTextMuted: const Color(0xff8391a2),
);
switch (colorTheme) {
case ColorThemeType.purple:
return baseTheme.copyWith(primary: const Color(0xff663399), onPrimary: Colors.white);
case ColorThemeType.red:
return baseTheme.copyWith(primary: const Color(0xffff0000), onPrimary: Colors.white);
case ColorThemeType.green:
return baseTheme.copyWith(primary: const Color(0xff49BF3C), onPrimary: Colors.white);
case ColorThemeType.blue:
return baseTheme.copyWith(primary: const Color(0xff007bff), onPrimary: Colors.white);
}
}
} }
enum ColorThemeType { purple, red, green, blue }
class AdminTheme { class AdminTheme {
final ContentTheme contentTheme;
final LeftBarTheme leftBarTheme; final LeftBarTheme leftBarTheme;
final RightBarTheme rightBarTheme; final RightBarTheme rightBarTheme;
final TopBarTheme topBarTheme; final TopBarTheme topBarTheme;
final ContentTheme contentTheme;
AdminTheme({ AdminTheme({
required this.leftBarTheme, required this.leftBarTheme,
@ -226,19 +262,22 @@ class AdminTheme {
required this.contentTheme, required this.contentTheme,
}); });
//-------------------------------------- Left Bar Theme ----------------------------------------//
static AdminTheme theme = AdminTheme( static AdminTheme theme = AdminTheme(
leftBarTheme: LeftBarTheme.lightLeftBarTheme, leftBarTheme: LeftBarTheme.lightLeftBarTheme,
topBarTheme: TopBarTheme.lightTopBarTheme, topBarTheme: TopBarTheme.lightTopBarTheme,
rightBarTheme: RightBarTheme.lightRightBarTheme, rightBarTheme: RightBarTheme.lightRightBarTheme,
contentTheme: ContentTheme.lightContentTheme); contentTheme: ContentTheme.withColorTheme(ColorThemeType.purple, mode: ThemeMode.light),
);
static void setTheme() { static void setTheme() {
final themeMode = ThemeCustomizer.instance.theme;
final colorTheme = ThemeCustomizer.instance.colorTheme;
theme = AdminTheme( theme = AdminTheme(
leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme, leftBarTheme: themeMode == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme,
topBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme, topBarTheme: themeMode == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme,
rightBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme, rightBarTheme: themeMode == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme,
contentTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? ContentTheme.darkContentTheme : ContentTheme.lightContentTheme); contentTheme: ContentTheme.withColorTheme(colorTheme, mode: themeMode),
);
} }
} }

View File

@ -1,8 +1,8 @@
import 'package:marco/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/widgets/my.dart'; import 'package:on_field_work/helpers/widgets/my.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@ -6,13 +6,13 @@
* */ * */
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:on_field_work/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:marco/helpers/widgets/my.dart'; import 'package:on_field_work/helpers/widgets/my.dart';
import 'package:marco/helpers/widgets/my_breadcrumb_item.dart'; import 'package:on_field_work/helpers/widgets/my_breadcrumb_item.dart';
import 'package:marco/helpers/widgets/my_constant.dart'; import 'package:on_field_work/helpers/widgets/my_constant.dart';
import 'package:marco/helpers/widgets/my_screen_media.dart'; import 'package:on_field_work/helpers/widgets/my_screen_media.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:on_field_work/helpers/widgets/my_text_style.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@ -230,7 +230,7 @@ class AppStyle {
containerRadius: AppStyle.containerRadius.medium, containerRadius: AppStyle.containerRadius.medium,
cardRadius: AppStyle.cardRadius.medium, cardRadius: AppStyle.cardRadius.medium,
buttonRadius: AppStyle.buttonRadius.medium, buttonRadius: AppStyle.buttonRadius.medium,
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'Marco', route: '/client/home'), defaultBreadCrumbItem: MyBreadcrumbItem(name: 'On Field Work', route: '/client/dashboard'),
)); ));
bool isMobile = true; bool isMobile = true;
try { try {

View File

@ -1,14 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/services/json_decoder.dart';
import 'package:marco/helpers/services/localizations/language.dart';
import 'package:marco/helpers/services/localizations/translator.dart';
import 'package:marco/helpers/services/navigation_services.dart';
import 'package:marco/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/app_notifier.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:on_field_work/helpers/services/json_decoder.dart';
import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:on_field_work/helpers/services/localizations/translator.dart';
import 'package:on_field_work/helpers/services/navigation_services.dart';
import 'package:on_field_work/helpers/theme/admin_theme.dart';
import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
typedef ThemeChangeCallback = void Function( typedef ThemeChangeCallback = void Function(
ThemeCustomizer oldVal, ThemeCustomizer newVal); ThemeCustomizer oldVal, ThemeCustomizer newVal);
@ -24,7 +24,7 @@ class ThemeCustomizer {
ThemeMode leftBarTheme = ThemeMode.light; ThemeMode leftBarTheme = ThemeMode.light;
ThemeMode rightBarTheme = ThemeMode.light; ThemeMode rightBarTheme = ThemeMode.light;
ThemeMode topBarTheme = ThemeMode.light; ThemeMode topBarTheme = ThemeMode.light;
ColorThemeType colorTheme = ColorThemeType.red;
bool rightBarOpen = false; bool rightBarOpen = false;
bool leftBarCondensed = false; bool leftBarCondensed = false;
@ -33,6 +33,8 @@ class ThemeCustomizer {
static Future<void> init() async { static Future<void> init() async {
await initLanguage(); await initLanguage();
await _loadColorTheme();
_notify();
} }
static initLanguage() async { static initLanguage() async {
@ -40,7 +42,7 @@ class ThemeCustomizer {
} }
String toJSON() { String toJSON() {
return jsonEncode({'theme': theme.name}); return jsonEncode({'theme': theme.name, 'colorTheme': colorTheme.name});
} }
static ThemeCustomizer fromJSON(String? json) { static ThemeCustomizer fromJSON(String? json) {
@ -49,6 +51,8 @@ class ThemeCustomizer {
JSONDecoder decoder = JSONDecoder(json); JSONDecoder decoder = JSONDecoder(json);
instance.theme = instance.theme =
decoder.getEnum('theme', ThemeMode.values, ThemeMode.light); decoder.getEnum('theme', ThemeMode.values, ThemeMode.light);
instance.colorTheme = decoder.getEnum(
'colorTheme', ColorThemeType.values, ColorThemeType.red);
} }
return instance; return instance;
} }
@ -73,6 +77,11 @@ class ThemeCustomizer {
} }
} }
/// Public method to trigger theme updates externally
static void applyThemeChange() {
_notify();
}
static void notify() { static void notify() {
for (var value in _notifier) { for (var value in _notifier) {
value(oldInstance, instance); value(oldInstance, instance);
@ -112,12 +121,46 @@ class ThemeCustomizer {
tc.topBarTheme = topBarTheme; tc.topBarTheme = topBarTheme;
tc.rightBarOpen = rightBarOpen; tc.rightBarOpen = rightBarOpen;
tc.leftBarCondensed = leftBarCondensed; tc.leftBarCondensed = leftBarCondensed;
tc.colorTheme = colorTheme;
tc.currentLanguage = currentLanguage.clone(); tc.currentLanguage = currentLanguage.clone();
return tc; return tc;
} }
@override @override
String toString() { String toString() {
return 'ThemeCustomizer{theme: $theme}'; return 'ThemeCustomizer{theme: $theme, colorTheme: $colorTheme}';
}
// ---------------------------------------------------------------------------
// 🟢 Color Theme Persistence
// ---------------------------------------------------------------------------
static const _colorThemeKey = 'color_theme_type';
/// Save selected color theme
static Future<void> saveColorTheme(ColorThemeType type) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_colorThemeKey, type.name);
instance.colorTheme = type;
_notify();
}
/// Load saved color theme (called at startup)
static Future<void> _loadColorTheme() async {
final prefs = await SharedPreferences.getInstance();
final savedType = prefs.getString(_colorThemeKey);
if (savedType != null) {
instance.colorTheme = ColorThemeType.values.firstWhere(
(e) => e.name == savedType,
orElse: () => ColorThemeType.red,
);
}
}
/// Change color theme & persist
static Future<void> changeColorTheme(ColorThemeType type) async {
oldInstance = instance.clone();
instance.colorTheme = type;
await saveColorTheme(type);
} }
} }

View File

@ -0,0 +1,273 @@
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/helpers/widgets/wave_background.dart';
import 'package:on_field_work/helpers/theme/admin_theme.dart';
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
class ThemeOption {
final String label;
final Color primary;
final Color button;
final Color brand;
final ColorThemeType colorThemeType;
ThemeOption(
this.label, this.primary, this.button, this.brand, this.colorThemeType);
}
final List<ThemeOption> themeOptions = [
ThemeOption(
"Theme 1", Colors.red, Colors.red, Colors.red, ColorThemeType.red),
ThemeOption(
"Theme 2",
const Color(0xFF49BF3C),
const Color(0xFF49BF3C),
const Color(0xFF49BF3C),
ColorThemeType.green,
),
ThemeOption(
"Theme 3",
const Color(0xFF3F51B5),
const Color(0xFF3F51B5),
const Color(0xFF3F51B5),
ColorThemeType.blue,
),
ThemeOption(
"Theme 4",
const Color(0xFF663399),
const Color(0xFF663399),
const Color(0xFF663399),
ColorThemeType.purple,
),
];
class ThemeController extends GetxController {
RxInt selectedIndex = 0.obs;
RxBool showApplied = false.obs;
void init() {
final currentPrimary = AdminTheme.theme.contentTheme.primary;
int index = themeOptions
.indexWhere((opt) => opt.primary.value == currentPrimary.value);
selectedIndex.value = index == -1 ? 0 : index;
}
void applyTheme(int index) async {
selectedIndex.value = index;
showApplied.value = true;
ThemeCustomizer.instance.colorTheme = themeOptions[index].colorThemeType;
ThemeCustomizer.applyThemeChange();
await Future.delayed(const Duration(milliseconds: 600));
showApplied.value = false;
// Navigate to dashboard after applying theme
Get.offAllNamed('/dashboard');
}
}
class ThemeEditorWidget extends StatefulWidget {
final VoidCallback onClose;
const ThemeEditorWidget({super.key, required this.onClose});
@override
_ThemeEditorWidgetState createState() => _ThemeEditorWidgetState();
}
class _ThemeEditorWidgetState extends State<ThemeEditorWidget> {
final ThemeController themeController = Get.put(ThemeController());
@override
void initState() {
super.initState();
themeController.init();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row with title and close button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyLarge("Theme Customization", fontWeight: 600),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: "Back",
iconSize: 20,
),
],
),
const SizedBox(height: 12),
// Theme cards wrapped in reactive Obx widget
Center(
child: Obx(
() => Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: List.generate(themeOptions.length, (i) {
return ThemeCard(
themeOption: themeOptions[i],
isSelected: themeController.selectedIndex.value == i,
onTap: () => themeController.applyTheme(i),
);
}),
),
),
),
const SizedBox(height: 12),
// Applied indicator reactive widget
Obx(
() => themeController.showApplied.value
? Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
color:
themeOptions[themeController.selectedIndex.value]
.brand,
size: 20,
),
const SizedBox(width: 6),
Text(
"Theme Applied!",
style: TextStyle(
color: themeOptions[
themeController.selectedIndex.value]
.brand,
fontWeight: FontWeight.w700,
),
),
],
),
)
: const SizedBox(),
),
const SizedBox(height: 16),
const Text(
"Preview and select a theme. You can change this anytime.",
style: TextStyle(fontSize: 13, color: Colors.black54),
),
],
),
);
}
}
class ThemeCard extends StatelessWidget {
final ThemeOption themeOption;
final bool isSelected;
final VoidCallback onTap;
const ThemeCard({
Key? key,
required this.themeOption,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 80,
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
elevation: isSelected ? 4 : 1,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? themeOption.brand : Colors.transparent,
width: 2,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: RedWavePainter(themeOption.brand, 0.15)),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Hello, User!",
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w600,
color: themeOption.primary,
fontSize: 12,
),
),
const SizedBox(height: 4),
SizedBox(
height: 18,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: themeOption.button,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
elevation: 1,
textStyle: const TextStyle(fontSize: 10),
),
onPressed: () {},
child: const Text("Welcome"),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 6),
Text(
themeOption.label,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Colors.grey[700],
),
),
],
),
),
),
),
);
}
}

View File

@ -95,7 +95,7 @@ class AttendanceButtonHelper {
} }
} }
static Color getButtonColor({ static Color getprimary({
required bool isYesterday, required bool isYesterday,
required bool isTodayApproved, required bool isTodayApproved,
required int activity, required int activity,

View File

@ -1,15 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class BaseBottomSheet extends StatelessWidget { class BaseBottomSheet extends StatefulWidget {
final String title; final String title;
final String? subtitle;
final Widget child; final Widget child;
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final bool isSubmitting; final bool isSubmitting;
final String submitText; final String submitText;
final Color submitColor; final Color? submitColor;
final IconData submitIcon; final IconData submitIcon;
final bool showButtons; final bool showButtons;
final Widget? bottomContent; final Widget? bottomContent;
@ -20,18 +22,26 @@ class BaseBottomSheet extends StatelessWidget {
required this.child, required this.child,
required this.onCancel, required this.onCancel,
required this.onSubmit, required this.onSubmit,
this.subtitle,
this.isSubmitting = false, this.isSubmitting = false,
this.submitText = 'Submit', this.submitText = 'Submit',
this.submitColor = Colors.indigo, this.submitColor,
this.submitIcon = Icons.check_circle_outline, this.submitIcon = Icons.check_circle_outline,
this.showButtons = true, this.showButtons = true,
this.bottomContent, this.bottomContent,
}); });
@override
State<BaseBottomSheet> createState() => _BaseBottomSheetState();
}
class _BaseBottomSheetState extends State<BaseBottomSheet> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final effectiveSubmitColor =
widget.submitColor ?? contentTheme.primary;
return SingleChildScrollView( return SingleChildScrollView(
padding: mediaQuery.viewInsets, padding: mediaQuery.viewInsets,
@ -50,33 +60,50 @@ class BaseBottomSheet extends StatelessWidget {
], ],
), ),
child: SafeArea( child: SafeArea(
// 👈 prevents overlap with nav bar
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(5), MySpacing.height(5),
Container( Center(
width: 40, child: Container(
height: 5, width: 40,
decoration: BoxDecoration( height: 5,
color: Colors.grey.shade300, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
MySpacing.height(12), MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700), Center(
child: MyText.titleLarge(
widget.title,
fontWeight: 700,
textAlign: TextAlign.center,
),
),
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
MySpacing.height(4),
MyText.bodySmall(
widget.subtitle!,
fontWeight: 600,
color: Colors.grey[700],
),
],
MySpacing.height(12), MySpacing.height(12),
child, widget.child,
MySpacing.height(12), MySpacing.height(12),
if (showButtons) ...[ if (widget.showButtons) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onCancel, onPressed: widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
"Cancel", "Cancel",
@ -88,34 +115,40 @@ class BaseBottomSheet extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding:
const EdgeInsets.symmetric(vertical: 8),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit, onPressed:
icon: Icon(submitIcon, color: Colors.white), widget.isSubmitting ? null : widget.onSubmit,
icon:
Icon(widget.submitIcon, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText, widget.isSubmitting
? "Submitting..."
: widget.submitText,
color: Colors.white, color: Colors.white,
fontWeight: 600, fontWeight: 600,
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: submitColor, backgroundColor: effectiveSubmitColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding:
const EdgeInsets.symmetric(vertical: 8),
), ),
), ),
), ),
], ],
), ),
if (bottomContent != null) ...[ if (widget.bottomContent != null) ...[
MySpacing.height(12), MySpacing.height(12),
bottomContent!, widget.bottomContent!,
], ],
], ],
], ],

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class ContactPickerHelper { class ContactPickerHelper {
static Future<String?> pickIndianPhoneNumber(BuildContext context) async { static Future<String?> pickIndianPhoneNumber(BuildContext context) async {

View File

@ -1,6 +1,9 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateTimeUtils { class DateTimeUtils {
/// Default date format
static const String defaultFormat = 'dd MMM yyyy';
/// Converts a UTC datetime string to local time and formats it. /// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, static String convertUtcToLocal(String utcTimeString,
{String format = 'dd-MM-yyyy'}) { {String format = 'dd-MM-yyyy'}) {

View File

@ -1,7 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
class LauncherUtils { class LauncherUtils {
/// Launches the phone dialer with the provided phone number /// Launches the phone dialer with the provided phone number

View File

@ -1,7 +1,7 @@
import 'package:marco/helpers/theme/admin_theme.dart'; import 'package:on_field_work/helpers/theme/admin_theme.dart';
import 'package:marco/helpers/theme/app_theme.dart'; import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:marco/helpers/widgets/my_dashed_divider.dart'; import 'package:on_field_work/helpers/widgets/my_dashed_divider.dart';
import 'package:marco/helpers/widgets/my_navigation_mixin.dart'; import 'package:on_field_work/helpers/widgets/my_navigation_mixin.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
mixin UIMixin { mixin UIMixin {

View File

@ -25,14 +25,16 @@ class Permissions {
// ------------------- Project Infrastructure -------------------------- // ------------------- Project Infrastructure --------------------------
/// Permission to manage project infrastructure (e.g., site details) /// Permission to manage project infrastructure (e.g., site details)
static const String manageProjectInfra = "cf2825ad-453b-46aa-91d9-27c124d63373"; static const String manageProjectInfra =
"cf2825ad-453b-46aa-91d9-27c124d63373";
/// Permission to view infrastructure-related details /// Permission to view infrastructure-related details
static const String viewProjectInfra = "8d7cc6e3-9147-41f7-aaa7-fa507e450bd4"; static const String viewProjectInfra = "8d7cc6e3-9147-41f7-aaa7-fa507e450bd4";
// ------------------- Attendance Management --------------------------- // ------------------- Attendance Management ---------------------------
/// Permission to regularize (edit/update) attendance records /// Permission to regularize (edit/update) attendance records
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; static const String regularizeAttendance =
"57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
// ------------------- Task Management --------------------------------- // ------------------- Task Management ---------------------------------
/// Permission to create and manage tasks /// Permission to create and manage tasks
@ -90,7 +92,8 @@ class Permissions {
// ------------------- Application Roles ------------------------------- // ------------------- Application Roles -------------------------------
/// Application role ID for users with full expense management rights /// Application role ID for users with full expense management rights
static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7"; static const String expenseManagement =
"a4e25142-449b-4334-a6e5-22f70e4732d7";
// ------------------- Document Entities ------------------------------- // ------------------- Document Entities -------------------------------
/// Entity ID for project documents /// Entity ID for project documents
@ -118,3 +121,73 @@ class Permissions {
/// Permission to verify documents /// Permission to verify documents
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0"; static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
} }
/// Contains constants for menu item IDs fetched from the sidebar menu API.
class MenuItems {
/// Dashboard menu
static const String dashboard = "29e03eda-03e8-4714-92fa-67ae0dc53202";
/// Daily Task Planning menu
static const String dailyTaskPlanning =
"77ac5205-f823-442e-b9e4-2420d658aa02";
/// Daily Progress Report menu
static const String dailyProgressReport =
"299e3cf5-d034-4403-b4a1-ea46d2714832";
/// Employees menu
static const String employees = "78f0206d-c6cc-44d0-832a-2031ed203018";
/// Attendance menu
static const String attendance = "2f212030-f36b-456c-8e7c-11f00f9ba42b";
/// Directory menu
static const String directory = "31bc367b-7c58-4604-95eb-da059a384103";
/// Expense & Reimbursement menu
static const String expenseReimbursement =
"0f0dc1a7-1aca-4cdb-9d7a-8a769ce40728";
/// Payment Requests menu
static const String paymentRequests = "b350a59f-2372-4f68-8dcf-f7cfc44523ca";
/// Advance Payment Statements menu
static const String advancePaymentStatements =
"e0251cc1-e6d9-417a-9c76-489cc4b6c347";
/// Finance menu
static const String finance = "5ac409dd-bbe0-4d56-bcb9-229bd3a6353c";
/// Documents menu
static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3";
/// 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.
class JobStatus {
/// Level 1 - New
static const String newStatus = "32d76a02-8f44-4aa0-9b66-c3716c45a918";
/// Level 2 - Assigned
static const String assigned = "cfa1886d-055f-4ded-84c6-42a2a8a14a66";
/// Level 3 - In Progress
static const String inProgress = "5a6873a5-fed7-4745-a52f-8f61bf3bd72d";
/// Level 4 - Work Done
static const String workDone = "aab71020-2fb8-44d9-9430-c9a7e9bf33b0";
/// Level 5 - Review Done
static const String reviewDone = "ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7";
/// Level 6 - Closed
static const String closed = "3ddeefb5-ae3c-4e10-a922-35e0a452bb69";
/// Level 7 - On Hold
static const String onHold = "75a0c8b8-9c6a-41af-80bf-b35bab722eb2";
}

View File

@ -1,4 +1,5 @@
import 'package:marco/helpers/extensions/date_time_extension.dart'; import 'package:intl/intl.dart';
import 'package:on_field_work/helpers/extensions/date_time_extension.dart';
class Utils { class Utils {
static getDateStringFromDateTime(DateTime dateTime, static getDateStringFromDateTime(DateTime dateTime,
@ -44,6 +45,10 @@ class Utils {
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian"; return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
} }
static String formatDate(DateTime date) {
return DateFormat('d MMM yyyy').format(date);
}
static String getDateTimeStringFromDateTime(DateTime dateTime, static String getDateTimeStringFromDateTime(DateTime dateTime,
{bool showSecond = true, {bool showSecond = true,
bool showDate = true, bool showDate = true,
@ -76,4 +81,12 @@ class Utils {
return "${b.toStringAsFixed(2)} Bytes"; return "${b.toStringAsFixed(2)} Bytes";
} }
} }
static String formatCurrency(num amount,
{String currency = "INR", String locale = "en_US"}) {
// Use en_US for standard K, M, B formatting
final symbol = NumberFormat.simpleCurrency(name: currency).currencySymbol;
final formatter = NumberFormat.compact(locale: 'en_US');
return "$symbol${formatter.format(amount)}";
}
} }

Some files were not shown because too many files have changed in this diff Show More